From 71b7813a01cc30092efc1a26425b987003e1e6d1 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:56:19 -0400 Subject: [PATCH 01/13] fix(ui): resolve @clerk/headless types from source in Mosaic dts build The Mosaic StyleX build resolved @clerk/headless types against its published dist .d.ts re-export barrels, which rolldown-plugin-dts can't follow when inlining a bundled dependency. Since @clerk/headless is private and must be inlined, map it to source in tsconfig.mosaic.json (the monorepo default, same pattern already used for @/core/*), so Mosaic components can import headless types directly. --- packages/ui/tsconfig.mosaic.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/ui/tsconfig.mosaic.json b/packages/ui/tsconfig.mosaic.json index d4b1a2302da..05fde7579f6 100644 --- a/packages/ui/tsconfig.mosaic.json +++ b/packages/ui/tsconfig.mosaic.json @@ -1,6 +1,18 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "jsxImportSource": "react" + "jsxImportSource": "react", + "paths": { + // Resolve the private, unpublished `@clerk/headless` to its SOURCE for the Mosaic + // declaration bundle. Its published `dist/*.d.ts` are re-export barrels that + // rolldown-plugin-dts can't follow when inlining, so building types against source + // (the monorepo default) lets Mosaic components import headless types directly. + "@clerk/headless/utils": ["../headless/src/utils/index.ts"], + "@clerk/headless/*": ["../headless/src/*"], + // Preserve the base config's test-only aliases (extends replaces `paths` wholesale). + "@/core/*": ["../clerk-js/src/core/*"], + "@/*": ["./src/*"], + "@/ui*": ["./src/*"] + } } } From af529349f56655d48e58f745ce2fe6688d3eb62f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 23 Jul 2026 19:58:00 -0400 Subject: [PATCH 02/13] feat(ui): add Mosaic Item component with StyleX styling Add a Mosaic `Item` component for lists of accounts, organizations, and settings rows. Composed via dot syntax (`Item.Media`, `Item.Content`, `Item.Title`, `Item.Description`, `Item.Actions`, `Item.Footer`, `Item.Group`, `Item.Separator`) with `default`/`outline`/`muted` variants and `default`/`sm` sizes. Per-slot StyleX styles compile into the shared sheet, and a `render` prop allows an item to render as a link or button. Includes swingset docs and stories. --- .changeset/mosaic-item.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 16 ++ packages/swingset/src/stories/item.mdx | 92 +++++++++ .../swingset/src/stories/item.stories.tsx | 153 ++++++++++++++ .../ui/src/mosaic/components/item/index.ts | 2 + .../src/mosaic/components/item/item.styles.ts | 154 ++++++++++++++ .../src/mosaic/components/item/item.test.tsx | 108 ++++++++++ .../ui/src/mosaic/components/item/item.tsx | 193 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 10 files changed, 723 insertions(+) create mode 100644 .changeset/mosaic-item.md create mode 100644 packages/swingset/src/stories/item.mdx create mode 100644 packages/swingset/src/stories/item.stories.tsx create mode 100644 packages/ui/src/mosaic/components/item/index.ts create mode 100644 packages/ui/src/mosaic/components/item/item.styles.ts create mode 100644 packages/ui/src/mosaic/components/item/item.test.tsx create mode 100644 packages/ui/src/mosaic/components/item/item.tsx diff --git a/.changeset/mosaic-item.md b/.changeset/mosaic-item.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-item.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 82d9e39eb83..7bbe0bbacb8 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -33,6 +33,7 @@ const docModules: Record> = { button: dynamic(() => import('../stories/button.mdx')), card: dynamic(() => import('../stories/card.component.mdx')), input: dynamic(() => import('../stories/input.mdx')), + item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index bfa22e2ee2b..60c0403cdc0 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -46,6 +46,13 @@ import { meta as inputMeta, Sizes as InputSizes, } from '../stories/input.stories'; +import { + Default as ItemDefault, + Group as ItemGroup, + Interactive as ItemInteractive, + meta as itemMeta, + Variants as ItemVariants, +} from '../stories/item.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationProfileDefault, @@ -149,6 +156,14 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const itemModule: StoryModule = { + meta: itemMeta, + Default: ItemDefault, + Variants: ItemVariants, + Interactive: ItemInteractive, + Group: ItemGroup, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -204,6 +219,7 @@ export const registry: StoryModule[] = [ buttonModule, cardComponentModule, inputModule, + itemModule, dialogComponentModule, headingModule, iconModule, diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx new file mode 100644 index 00000000000..7d13e113c43 --- /dev/null +++ b/packages/swingset/src/stories/item.mdx @@ -0,0 +1,92 @@ +import * as ItemStories from './item.stories'; + +# Item + +Item is a flexible row for lists of accounts, organizations, and settings in Mosaic. It's composed from parts via dot syntax (`Item.Media`, `Item.Content`, `Item.Title`, …) and renders as a `
` by default. Pass a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. + +## Example + + + +### Variants + + + +### Interactive + + + +### Group + + + +## Usage + +```tsx +import { Item } from '@clerk/ui/mosaic/components/item'; + + + {children}}> + + + + + Test Organization + Member + + + + + +; +``` + +## Parts + +| Part | Slot (`data-cl-slot` / class) | Description | +| ------------------ | ----------------------------- | -------------------------------------------------------------------- | +| `Item` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. | +| `Item.Description` | `cl-item-description` | Secondary text, clamped to two lines. Renders a `

`. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Footer` | `cl-item-footer` | Full-width band below the content row for supplementary metadata. | +| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | +| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | + +Every part accepts a `render` prop for element polymorphism and forwards a ref. + +## Styling + +The root reflects its props as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: + +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | --------- | +| `variant` | `data-variant` | `default` \| `outline` \| `muted` | `default` | +| `size` | `data-size` | `default` \| `sm` | `default` | +| `render` | `data-interactive` | present when a `render` is provided | — | + +```css +/* Tighten a specific list's rows and re-theme the muted variant */ +.cl-item[data-size='sm'] { + --cl-spacing: 0.2rem; +} +.cl-item[data-variant='muted'] { + background-color: var(--cl-color-card); +} +``` + +`Item.Media` also carries `data-variant` (`default` | `icon` | `image`): `icon` renders a bordered `--cl-color-muted` tile, `image` a rounded avatar frame that clips its child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx new file mode 100644 index 00000000000..c88d6945fe8 --- /dev/null +++ b/packages/swingset/src/stories/item.stories.tsx @@ -0,0 +1,153 @@ +/** @jsxImportSource @emotion/react */ +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Item } from '@clerk/ui/mosaic/components/item'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './item.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'Item', + source: 'packages/ui/src/mosaic/components/item/item.tsx', +}; + +function BuildingIcon() { + return ( + + + + ); +} + +function ArrowIcon() { + return ( + + + + ); +} + +export function Default() { + return ( + + + + + + Test Organization + Member + + + + + + ); +} + +export function Variants() { + return ( +
+ + + Default + Transparent background, no border + + + + + Outline + Bordered container + + + + + Muted + Subtle filled background + + +
+ ); +} + +export function Interactive() { + return ( + ( + + {children} + + )} + > + + + + + Test Organization + Member + + + + + + ); +} + +export function Group() { + return ( + + ( + + {children} + + )} + > + + Clerk + + + + ( + + {children} + + )} + > + + Clerk Cloud + + + + ); +} diff --git a/packages/ui/src/mosaic/components/item/index.ts b/packages/ui/src/mosaic/components/item/index.ts new file mode 100644 index 00000000000..9344113ffd9 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/index.ts @@ -0,0 +1,2 @@ +export { Item } from './item'; +export type { ItemMediaProps, ItemProps } from './item'; diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts new file mode 100644 index 00000000000..4fbae953b20 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -0,0 +1,154 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const item = stylex.create({ + base: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-element'], + borderStyle: 'solid', + borderWidth: '1px', + outline: { + default: 'none', + ':focus-visible': `2px solid color-mix(in oklab, ${colorVars['--cl-color-primary']} 50%, transparent)`, + }, + alignItems: 'center', + boxSizing: 'border-box', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + flexWrap: 'wrap', + fontFamily: 'inherit', + fontSize: typeScaleVars['--cl-text-label-size'], + lineHeight: typeScaleVars['--cl-text-label-leading'], + outlineOffset: '2px', + textAlign: 'start', + transitionDuration: '150ms', + transitionProperty: 'background-color, border-color, color', + width: '100%', + }, + + variantDefault: { backgroundColor: 'transparent' }, + variantOutline: { borderColor: colorVars['--cl-color-border'], backgroundColor: 'transparent' }, + variantMuted: { + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-muted']} 50%, transparent)`, + }, + + // size — generous block padding; the inline axis stays constant + sizeMd: { padding: space['4'], gap: space['4'] }, + sizeSm: { gap: space['2.5'], paddingBlock: space['3'], paddingInline: space['4'] }, + + // interactive rows (rendered as a link/button via asChild) gain hover + cursor + interactive: { + backgroundColor: { + default: null, + ':active': `color-mix(in oklab, ${colorVars['--cl-color-muted']} 70%, transparent)`, + '@media (hover: hover)': { + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-muted']} 50%, transparent)`, + }, + }, + cursor: 'pointer', + }, + + disabled: { cursor: 'not-allowed', opacity: 0.5, pointerEvents: 'none' }, +}); + +export const media = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + }, + icon: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-inner'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-muted'], + boxSizing: 'border-box', + height: space['8'], + width: space['8'], + }, + image: { + borderRadius: radiusVars['--cl-radius-inner'], + overflow: 'hidden', + height: space['10'], + width: space['10'], + }, +}); + +export const content = stylex.create({ + base: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + justifyContent: 'center', + minWidth: 0, + }, +}); + +export const title = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-label-size'], + fontWeight: typeScaleVars['--cl-text-label-weight'], + lineHeight: typeScaleVars['--cl-text-label-leading'], + width: 'fit-content', + }, +}); + +export const description = stylex.create({ + base: { + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 2, + color: colorVars['--cl-color-muted-foreground'], + display: '-webkit-box', + fontSize: typeScaleVars['--cl-text-label-size'], + fontWeight: 400, + lineHeight: typeScaleVars['--cl-text-label-leading'], + }, +}); + +export const actions = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexShrink: 0, + }, +}); + +// ItemHeader / ItemFooter share the same full-width band layout. +export const band = stylex.create({ + base: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + flexBasis: '100%', + justifyContent: 'space-between', + }, +}); + +export const group = stylex.create({ + base: { + display: 'flex', + flexDirection: 'column', + }, +}); + +export const separator = stylex.create({ + base: { + margin: 0, + borderStyle: 'none', + backgroundColor: colorVars['--cl-color-border'], + flexShrink: 0, + height: '1px', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx new file mode 100644 index 00000000000..4364476937a --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +import { Item } from './item'; + +describe('Mosaic Item', () => { + it('renders a div with its children', () => { + render(Hi); + expect(screen.getByText('Hi')).toBeInTheDocument(); + }); + + it('applies default variants when none are passed', () => { + render(Hi); + const item = screen.getByText('Hi'); + expect(item).toHaveClass('cl-item'); + expect(item).toHaveAttribute('data-variant', 'default'); + expect(item).toHaveAttribute('data-size', 'default'); + expect(item).not.toHaveAttribute('data-interactive'); + }); + + it('wires variant props and consumer className/style through to the element', () => { + render( + + Hi + , + ); + const item = screen.getByText('Hi'); + expect(item).toHaveAttribute('data-variant', 'outline'); + expect(item).toHaveAttribute('data-size', 'sm'); + expect(item).toHaveClass('cl-item', 'my-item'); + expect(item).toHaveStyle({ marginTop: '8px' }); + }); + + it('renders a custom element via render and marks it interactive', () => { + render( + ( + + {children} + + )} + > + + Settings + + , + ); + const link = screen.getByRole('link', { name: 'Settings' }); + expect(link).toHaveClass('cl-item'); + expect(link).toHaveAttribute('data-interactive', ''); + expect(link).toHaveAttribute('href', '/settings'); + }); + + it('renders the media variants', () => { + render( + + icon + , + ); + const media = screen.getByText('icon').parentElement; + expect(media).toHaveClass('cl-item-media'); + expect(media).toHaveAttribute('data-variant', 'icon'); + }); + + it('renders the composed slots with their stable classes', () => { + render( + + + Test Organization + Member + + + + + , + ); + expect(screen.getByText('Test Organization')).toHaveClass('cl-item-title'); + expect(screen.getByText('Member')).toHaveClass('cl-item-description'); + expect(screen.getByRole('button', { name: 'Manage' }).parentElement).toHaveClass('cl-item-actions'); + }); + + it('renders a group as a list and a separator', () => { + render( + + One + + Two + , + ); + expect(screen.getByRole('list')).toHaveClass('cl-item-group'); + expect(screen.getByTestId('sep')).toHaveClass('cl-item-separator'); + }); + + it('forwards the ref to the root element', () => { + const ref = React.createRef(); + render(Hi); + expect(ref.current).toBe(screen.getByText('Hi')); + }); +}); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx new file mode 100644 index 00000000000..8e751bcc623 --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -0,0 +1,193 @@ +import { type ComponentProps, type RenderProp, useRender } from '@clerk/headless/utils'; +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { mergeStyleProps, themeProps } from '../../props'; +import * as slots from './item.styles'; + +export type ItemProps = Omit, 'render'> & { + variant?: 'default' | 'outline' | 'muted'; + size?: 'default' | 'sm'; + /** Render a custom element (e.g. a link or button) in place of the default `div`. */ + render?: RenderProp>; +}; + +const Root = React.forwardRef(function MosaicItem( + { variant = 'default', size = 'default', render, className, style, ...rest }, + ref, +) { + // A custom render (link/button row) opts into hover + cursor affordances. + const interactive = Boolean(render); + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('item', { variant, size, interactive }), + stylex.props( + slots.item.base, + variant === 'outline' && slots.item.variantOutline, + variant === 'muted' && slots.item.variantMuted, + variant === 'default' && slots.item.variantDefault, + size === 'sm' ? slots.item.sizeSm : slots.item.sizeMd, + interactive && slots.item.interactive, + ), + className, + style, + ), + ...rest, + }, + }); +}); + +export type ItemMediaProps = ComponentProps<'div'> & { + variant?: 'default' | 'icon' | 'image'; +}; + +const Media = React.forwardRef(function MosaicItemMedia( + { variant = 'default', render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps( + themeProps('item-media', { variant }), + stylex.props( + slots.media.base, + variant === 'icon' && slots.media.icon, + variant === 'image' && slots.media.image, + ), + className, + style, + ), + ...rest, + }, + }); +}); + +const Content = React.forwardRef>(function MosaicItemContent( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-content'), stylex.props(slots.content.base), className, style), + ...rest, + }, + }); +}); + +const Title = React.forwardRef>(function MosaicItemTitle( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-title'), stylex.props(slots.title.base), className, style), + ...rest, + }, + }); +}); + +const Description = React.forwardRef>(function MosaicItemDescription( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'p', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-description'), stylex.props(slots.description.base), className, style), + ...rest, + }, + }); +}); + +const Actions = React.forwardRef>(function MosaicItemActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-actions'), stylex.props(slots.actions.base), className, style), + ...rest, + }, + }); +}); + +const Footer = React.forwardRef>(function MosaicItemFooter( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-footer'), stylex.props(slots.band.base), className, style), + ...rest, + }, + }); +}); + +const Group = React.forwardRef>(function MosaicItemGroup( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + role: 'list', + ...mergeStyleProps(themeProps('item-group'), stylex.props(slots.group.base), className, style), + ...rest, + }, + }); +}); + +const Separator = React.forwardRef>(function MosaicItemSeparator( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'hr', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-separator'), stylex.props(slots.separator.base), className, style), + ...rest, + }, + }); +}); + +/** + * Mosaic `Item` — a row for lists of accounts, organizations, and settings. + * Composed via dot syntax: `Item.Media`, `Item.Content`, `Item.Title`, + * `Item.Description`, `Item.Actions`, `Item.Footer`, `Item.Group`, + * `Item.Separator`. + */ +export const Item = Object.assign(Root, { + Media, + Content, + Title, + Description, + Actions, + Footer, + Group, + Separator, +}); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 64242a1374d..864543ef23d 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -14,6 +14,8 @@ export { Heading, HeadingContext } from '../components/heading'; export type { HeadingProps } from '../components/heading'; export { Icon } from '../components/icon'; export type { IconProps } from '../components/icon'; +export { Item } from '../components/item'; +export type { ItemMediaProps, ItemProps } from '../components/item'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; From 526e8e6b3098b617bcd939b2d76db7438ad04128 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 28 Jul 2026 16:20:01 -0400 Subject: [PATCH 03/13] refactor(ui): simplify Mosaic Item styles and API - 8px padding on the row; media slot fits its child (no fixed-width variants) - drop Item variant/size props and the Footer part - examples use an avatar in the media slot; interactive trailing slot is 28px --- packages/swingset/src/lib/registry.ts | 2 - packages/swingset/src/stories/item.mdx | 51 +++++-------- .../swingset/src/stories/item.stories.tsx | 71 ++++++++----------- .../ui/src/mosaic/components/item/index.ts | 2 +- .../src/mosaic/components/item/item.styles.ts | 60 +++------------- .../src/mosaic/components/item/item.test.tsx | 15 ++-- .../ui/src/mosaic/components/item/item.tsx | 52 ++------------ packages/ui/src/mosaic/styles/index.ts | 2 +- 8 files changed, 72 insertions(+), 183 deletions(-) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 60c0403cdc0..62388ba0985 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -51,7 +51,6 @@ import { Group as ItemGroup, Interactive as ItemInteractive, meta as itemMeta, - Variants as ItemVariants, } from '../stories/item.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { @@ -159,7 +158,6 @@ const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: const itemModule: StoryModule = { meta: itemMeta, Default: ItemDefault, - Variants: ItemVariants, Interactive: ItemInteractive, Group: ItemGroup, }; diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 7d13e113c43..de2979f4585 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -11,13 +11,6 @@ Item is a flexible row for lists of accounts, organizations, and settings in Mos storyModule={ItemStories} /> -### Variants - - - ### Interactive {children}}> - - + + Test Organization @@ -55,38 +48,32 @@ import { Item } from '@clerk/ui/mosaic/components/item'; ## Parts -| Part | Slot (`data-cl-slot` / class) | Description | -| ------------------ | ----------------------------- | -------------------------------------------------------------------- | -| `Item` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | -| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | -| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | -| `Item.Title` | `cl-item-title` | Primary label. | -| `Item.Description` | `cl-item-description` | Secondary text, clamped to two lines. Renders a `

`. | -| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | -| `Item.Footer` | `cl-item-footer` | Full-width band below the content row for supplementary metadata. | -| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | -| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | +| Part | Class | Description | +| ------------------ | --------------------- | -------------------------------------------------------------------- | +| `Item` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. | +| `Item.Description` | `cl-item-description` | Secondary text, clamped to two lines. Renders a `

`. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | +| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. ## Styling -The root reflects its props as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: +The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: -| Prop | Attribute | Values | Default | -| --------- | ------------------ | ----------------------------------- | --------- | -| `variant` | `data-variant` | `default` \| `outline` \| `muted` | `default` | -| `size` | `data-size` | `default` \| `sm` | `default` | -| `render` | `data-interactive` | present when a `render` is provided | — | +| Prop | Attribute | Values | Default | +| -------- | ------------------ | ----------------------------------- | ------- | +| `render` | `data-interactive` | present when a `render` is provided | — | ```css -/* Tighten a specific list's rows and re-theme the muted variant */ -.cl-item[data-size='sm'] { - --cl-spacing: 0.2rem; -} -.cl-item[data-variant='muted'] { +/* Re-theme interactive rows */ +.cl-item[data-interactive] { background-color: var(--cl-color-card); } ``` -`Item.Media` also carries `data-variant` (`default` | `icon` | `image`): `icon` renders a bordered `--cl-color-muted` tile, `image` a rounded avatar frame that clips its child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). +`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need (the examples use a 36px avatar and a 28px trailing icon), then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index c88d6945fe8..a4c6812f6e6 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -14,20 +14,26 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/item/item.tsx', }; -function BuildingIcon() { +// A square org/account avatar sized for the media slot (36px). Since `Item.Media` +// fits its child, the avatar's own dimensions drive the slot width. +function Avatar({ initial }: { initial: string }) { return ( - - - + {initial} + ); } @@ -51,8 +57,8 @@ function ArrowIcon() { export function Default() { return ( - - + + Test Organization @@ -65,31 +71,6 @@ export function Default() { ); } -export function Variants() { - return ( -
- - - Default - Transparent background, no border - - - - - Outline - Bordered container - - - - - Muted - Subtle filled background - - -
- ); -} - export function Interactive() { return ( )} > - - + + Test Organization Member - + @@ -129,6 +110,9 @@ export function Group() { )} > + + + Clerk @@ -144,6 +128,9 @@ export function Group() { )} > + + + Clerk Cloud diff --git a/packages/ui/src/mosaic/components/item/index.ts b/packages/ui/src/mosaic/components/item/index.ts index 9344113ffd9..9d8050d9ddf 100644 --- a/packages/ui/src/mosaic/components/item/index.ts +++ b/packages/ui/src/mosaic/components/item/index.ts @@ -1,2 +1,2 @@ export { Item } from './item'; -export type { ItemMediaProps, ItemProps } from './item'; +export type { ItemProps } from './item'; diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index 4fbae953b20..621571c40a5 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -4,10 +4,9 @@ import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex export const item = stylex.create({ base: { - borderColor: 'transparent', + padding: space['2'], borderRadius: radiusVars['--cl-radius-element'], - borderStyle: 'solid', - borderWidth: '1px', + gap: space['3'], outline: { default: 'none', ':focus-visible': `2px solid color-mix(in oklab, ${colorVars['--cl-color-primary']} 50%, transparent)`, @@ -23,21 +22,11 @@ export const item = stylex.create({ outlineOffset: '2px', textAlign: 'start', transitionDuration: '150ms', - transitionProperty: 'background-color, border-color, color', + transitionProperty: 'background-color, color', width: '100%', }, - variantDefault: { backgroundColor: 'transparent' }, - variantOutline: { borderColor: colorVars['--cl-color-border'], backgroundColor: 'transparent' }, - variantMuted: { - backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-muted']} 50%, transparent)`, - }, - - // size — generous block padding; the inline axis stays constant - sizeMd: { padding: space['4'], gap: space['4'] }, - sizeSm: { gap: space['2.5'], paddingBlock: space['3'], paddingInline: space['4'] }, - - // interactive rows (rendered as a link/button via asChild) gain hover + cursor + // interactive rows (rendered as a link/button via `render`) gain hover + cursor interactive: { backgroundColor: { default: null, @@ -48,39 +37,22 @@ export const item = stylex.create({ }, cursor: 'pointer', }, - - disabled: { cursor: 'not-allowed', opacity: 0.5, pointerEvents: 'none' }, }); export const media = stylex.create({ + // sizes to its child; height follows the row (taller on a 2-line item, shorter on a button). base: { gap: space['2'], alignItems: 'center', display: 'flex', flexShrink: 0, justifyContent: 'center', - }, - icon: { - borderColor: colorVars['--cl-color-border'], - borderRadius: radiusVars['--cl-radius-inner'], - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: colorVars['--cl-color-muted'], - boxSizing: 'border-box', - height: space['8'], - width: space['8'], - }, - image: { - borderRadius: radiusVars['--cl-radius-inner'], - overflow: 'hidden', - height: space['10'], - width: space['10'], + width: 'fit-content', }, }); export const content = stylex.create({ base: { - gap: space['1'], display: 'flex', flexDirection: 'column', flexGrow: 1, @@ -108,10 +80,9 @@ export const description = stylex.create({ WebkitBoxOrient: 'vertical', WebkitLineClamp: 2, color: colorVars['--cl-color-muted-foreground'], - display: '-webkit-box', - fontSize: typeScaleVars['--cl-text-label-size'], + fontSize: typeScaleVars['--cl-text-label-sm-size'], fontWeight: 400, - lineHeight: typeScaleVars['--cl-text-label-leading'], + lineHeight: typeScaleVars['--cl-text-label-sm-leading'], }, }); @@ -124,28 +95,19 @@ export const actions = stylex.create({ }, }); -// ItemHeader / ItemFooter share the same full-width band layout. -export const band = stylex.create({ - base: { - gap: space['2'], - alignItems: 'center', - display: 'flex', - flexBasis: '100%', - justifyContent: 'space-between', - }, -}); - export const group = stylex.create({ base: { display: 'flex', flexDirection: 'column', + width: '100%', }, }); export const separator = stylex.create({ base: { - margin: 0, borderStyle: 'none', + marginBlock: space['2'], + marginInline: 0, backgroundColor: colorVars['--cl-color-border'], flexShrink: 0, height: '1px', diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index 4364476937a..97804bf5c31 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -10,20 +10,16 @@ describe('Mosaic Item', () => { expect(screen.getByText('Hi')).toBeInTheDocument(); }); - it('applies default variants when none are passed', () => { + it('applies its base class and is not interactive by default', () => { render(Hi); const item = screen.getByText('Hi'); expect(item).toHaveClass('cl-item'); - expect(item).toHaveAttribute('data-variant', 'default'); - expect(item).toHaveAttribute('data-size', 'default'); expect(item).not.toHaveAttribute('data-interactive'); }); - it('wires variant props and consumer className/style through to the element', () => { + it('wires consumer className/style through to the element', () => { render( @@ -31,8 +27,6 @@ describe('Mosaic Item', () => { , ); const item = screen.getByText('Hi'); - expect(item).toHaveAttribute('data-variant', 'outline'); - expect(item).toHaveAttribute('data-size', 'sm'); expect(item).toHaveClass('cl-item', 'my-item'); expect(item).toHaveStyle({ marginTop: '8px' }); }); @@ -60,15 +54,14 @@ describe('Mosaic Item', () => { expect(link).toHaveAttribute('href', '/settings'); }); - it('renders the media variants', () => { + it('renders media with its stable class', () => { render( - + icon , ); const media = screen.getByText('icon').parentElement; expect(media).toHaveClass('cl-item-media'); - expect(media).toHaveAttribute('data-variant', 'icon'); }); it('renders the composed slots with their stable classes', () => { diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 8e751bcc623..ad007fc9623 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -6,14 +6,12 @@ import { mergeStyleProps, themeProps } from '../../props'; import * as slots from './item.styles'; export type ItemProps = Omit, 'render'> & { - variant?: 'default' | 'outline' | 'muted'; - size?: 'default' | 'sm'; /** Render a custom element (e.g. a link or button) in place of the default `div`. */ render?: RenderProp>; }; const Root = React.forwardRef(function MosaicItem( - { variant = 'default', size = 'default', render, className, style, ...rest }, + { render, className, style, ...rest }, ref, ) { // A custom render (link/button row) opts into hover + cursor affordances. @@ -24,15 +22,8 @@ const Root = React.forwardRef(function MosaicItem( ref, props: { ...mergeStyleProps( - themeProps('item', { variant, size, interactive }), - stylex.props( - slots.item.base, - variant === 'outline' && slots.item.variantOutline, - variant === 'muted' && slots.item.variantMuted, - variant === 'default' && slots.item.variantDefault, - size === 'sm' ? slots.item.sizeSm : slots.item.sizeMd, - interactive && slots.item.interactive, - ), + themeProps('item', { interactive }), + stylex.props(slots.item.base, interactive && slots.item.interactive), className, style, ), @@ -41,12 +32,8 @@ const Root = React.forwardRef(function MosaicItem( }); }); -export type ItemMediaProps = ComponentProps<'div'> & { - variant?: 'default' | 'icon' | 'image'; -}; - -const Media = React.forwardRef(function MosaicItemMedia( - { variant = 'default', render, className, style, ...rest }, +const Media = React.forwardRef>(function MosaicItemMedia( + { render, className, style, ...rest }, ref, ) { return useRender({ @@ -54,16 +41,7 @@ const Media = React.forwardRef(function MosaicIt render, ref, props: { - ...mergeStyleProps( - themeProps('item-media', { variant }), - stylex.props( - slots.media.base, - variant === 'icon' && slots.media.icon, - variant === 'image' && slots.media.image, - ), - className, - style, - ), + ...mergeStyleProps(themeProps('item-media'), stylex.props(slots.media.base), className, style), ...rest, }, }); @@ -129,21 +107,6 @@ const Actions = React.forwardRef>(function }); }); -const Footer = React.forwardRef>(function MosaicItemFooter( - { render, className, style, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps(themeProps('item-footer'), stylex.props(slots.band.base), className, style), - ...rest, - }, - }); -}); - const Group = React.forwardRef>(function MosaicItemGroup( { render, className, style, ...rest }, ref, @@ -178,7 +141,7 @@ const Separator = React.forwardRef>(function /** * Mosaic `Item` — a row for lists of accounts, organizations, and settings. * Composed via dot syntax: `Item.Media`, `Item.Content`, `Item.Title`, - * `Item.Description`, `Item.Actions`, `Item.Footer`, `Item.Group`, + * `Item.Description`, `Item.Actions`, `Item.Group`, * `Item.Separator`. */ export const Item = Object.assign(Root, { @@ -187,7 +150,6 @@ export const Item = Object.assign(Root, { Title, Description, Actions, - Footer, Group, Separator, }); diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 864543ef23d..3e7c18cb3ea 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -15,7 +15,7 @@ export type { HeadingProps } from '../components/heading'; export { Icon } from '../components/icon'; export type { IconProps } from '../components/icon'; export { Item } from '../components/item'; -export type { ItemMediaProps, ItemProps } from '../components/item'; +export type { ItemProps } from '../components/item'; export { Text, TextContext } from '../components/text'; export type { TextProps } from '../components/text'; From c066685b8482942972e782b3eecd72f2065aa09f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 28 Jul 2026 16:28:49 -0400 Subject: [PATCH 04/13] docs(repo): update Mosaic styling contract to the StyleX class approach The public contract is now the `.cl-` class plus `data-` attrs emitted by `themeProps`/`mergeStyleProps`, not `data-cl-slot`. Mark the slot-recipe engine and its `data-cl-*`/`appearance.elements` cascade as the legacy authoring path still used by un-migrated components. --- references/mosaic-architecture.md | 48 ++++++++++++++----------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/references/mosaic-architecture.md b/references/mosaic-architecture.md index 11e90006b09..37133a94468 100644 --- a/references/mosaic-architecture.md +++ b/references/mosaic-architecture.md @@ -6,7 +6,7 @@ Mosaic is the next-generation design system for Clerk's UI components, replacing Mosaic uses Emotion for CSS-in-JS but delivers theme tokens via its own React context (not Emotion's `ThemeProvider`). This avoids type conflicts with the existing system's `InternalTheme` augmentation on Emotion's global `Theme` interface. -The public styling contract is **data attributes** (`data-cl-slot`, `data-cl-`, `data-cl-`), the same convention shipping in `@clerk/headless`. There is no classname derivation, no `__state` concatenation, and no central appearance-key registry. Components are authored with **slot recipes** (`defineSlotRecipe`), which own variants, slot identity, state→attribute mapping, and the appearance cascade in one place. +The public styling contract is a **stable per-slot class** plus **`data-` attributes**: a part emits `class="cl-"` for its identity (`.cl-button`, `.cl-item`) and `data-=""` / presence `data-` for its variants and state. Consumers target those, never StyleX's hashed atoms; tokens ship as overridable `--cl-*` custom properties. Newer components are authored with **StyleX** (`stylex.create` + the `themeProps`/`mergeStyleProps` helpers in `props.ts`). The slot-recipe engine described further below (`defineSlotRecipe`, `useRecipe`, and its older `data-cl-slot`/`data-cl-*` attribute contract) is the legacy authoring path still used by un-migrated components during the migration. Once migration is complete, the old system is removed and Mosaic becomes the sole design system. @@ -86,51 +86,45 @@ What we keep: `css` prop (with plain objects), `keyframes`, `Global`, style seri ## Public styling API -Every Mosaic part is targetable through stable data attributes — no classnames or registry keys to learn. A styled element emits: +Every Mosaic part carries a **stable class** and reflects its variants and state as **data attributes** — no hashed classnames or registry keys to learn. `themeProps(slot, variants)` (in `props.ts`) emits: -- `data-cl-slot=""` — the slot identity -- `data-cl-` — boolean state, presence-only (`data-cl-disabled=""`); omitted when the state is false -- `data-cl-=""` — the resolved variant (`data-cl-size="sm"`); boolean variant axes use presence semantics like state +- `class="cl-"` — the slot identity (`.cl-button`, `.cl-item`, `.cl-item-title`) +- `data-=""` — the resolved variant (`data-variant="outline"`, `data-size="sm"`) +- `data-` — boolean state or boolean variant, presence-only (`data-interactive=""`); omitted when the value is false/null -Defaults are emitted too, so e.g. `data-cl-size="md"` vs `"sm"` is always distinguishable. +Consumers target the class and its attributes, never StyleX's hashed `x…` atoms. -Three ways to style a part — all target the **same** attributes: +Two ways to style a part — both hit the same class + attributes: ```css /* 1. Plain CSS / stylesheet */ -[data-cl-slot='button'] { +.cl-button { border-radius: 8px; } -[data-cl-slot='button'][data-cl-size='sm'] { +.cl-button[data-size='sm'] { border-radius: 4px; } -[data-cl-slot='button'][data-cl-disabled] { - opacity: 0.4; +.cl-item[data-interactive] { + background-color: var(--cl-color-card); } ``` ```tsx -// 2. appearance.elements — keyed by slot id; state/variant via nested conditions or attr selectors -appearance={{ - elements: { - button: { - color: 'lime', - _disabled: { opacity: 0.4 }, // condition key - "&[data-cl-size='sm']": { borderRadius: 4 }, // raw attr selector - }, - }, -}} +// 2. className / style props — merged onto the element by `mergeStyleProps`, applied last so they win + -
+ ; ``` @@ -50,7 +54,7 @@ import { Item } from '@clerk/ui/mosaic/components/item'; | Part | Class | Description | | ------------------ | --------------------- | -------------------------------------------------------------------- | -| `Item` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | | `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | | `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | | `Item.Title` | `cl-item-title` | Primary label. | @@ -67,6 +71,7 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c | Prop | Attribute | Values | Default | | -------- | ------------------ | ----------------------------------- | ------- | +| `size` | `data-size` | `flush` \| `md` | `md` | | `render` | `data-interactive` | present when a `render` is provided | — | ```css @@ -76,4 +81,4 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c } ``` -`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need (the examples use a 36px avatar and a 28px trailing icon), then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). +`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need (the examples use a 40px Mosaic `Avatar` and a 28px trailing icon), then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index a4c6812f6e6..aa0aedf922f 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -1,6 +1,8 @@ /** @jsxImportSource @emotion/react */ +import { Avatar } from '@clerk/ui/mosaic/components/avatar'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Item } from '@clerk/ui/mosaic/components/item'; +import * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -14,29 +16,6 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/item/item.tsx', }; -// A square org/account avatar sized for the media slot (36px). Since `Item.Media` -// fits its child, the avatar's own dimensions drive the slot width. -function Avatar({ initial }: { initial: string }) { - return ( - - {initial} - - ); -} - function ArrowIcon() { return ( ) { + return ( + + + + ); +} + +function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + + + ); +} + export function Default() { return ( - + - + + T + Test Organization @@ -67,13 +100,13 @@ export function Default() { - + ); } export function Interactive() { return ( - ( - + + T + Test Organization @@ -93,48 +131,110 @@ export function Interactive() { - + ); } export function Group() { return ( - - ( - - {children} - - )} - > - - - - - Clerk - - +
+ + + + + C + + + + Cameron Walker + + + + + + - ( - - {children} - - )} - > - - - - - Clerk Cloud - - - + + + + cameron@clerk.com + + + + + + + + + + C + + + + Clerk + 24 members • Basic + + + + + + ( + + {children} + + )} + > + + + + C + + + + Clerk + 24 members • Basic + + + + + + ( + + {children} + + )} + > + + + D + + + + DesignCloud + 12 members • Pro + + + +
); } diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index 7e920064739..f9948484f5f 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -31,8 +31,8 @@ export const styles = stylex.create({ // fallback fills the box, centering its content and inheriting the sized font fallback: { alignItems: 'center', - backgroundColor: colorVars['--cl-color-neutral'], - color: colorVars['--cl-color-neutral-foreground'], + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], display: 'flex', fontSize: 'inherit', justifyContent: 'center', @@ -49,8 +49,8 @@ export const shapes = stylex.create({ // size — square box; fallback text scales with the box via inherited font-size export const sizes = stylex.create({ - xs: { fontSize: '0.625rem', height: space['6'], width: space['6'] }, - sm: { fontSize: '0.75rem', height: space['8'], width: space['8'] }, - md: { fontSize: '0.875rem', height: space['10'], width: space['10'] }, + xs: { fontSize: '0.625rem', height: space['5'], width: space['5'] }, + sm: { fontSize: '0.75rem', height: space['7'], width: space['7'] }, + md: { fontSize: '0.875rem', height: space['9'], width: space['9'] }, lg: { fontSize: '1rem', height: space['12'], width: space['12'] }, }); diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index e3010954ea8..8618694a57a 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -4,13 +4,13 @@ import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../ export const item = stylex.create({ base: { - padding: space['2'], borderRadius: radiusVars['--cl-radius-element'], gap: space['3'], outline: { default: 'none', ':focus-visible': `2px solid color-mix(in oklab, ${colorVars['--cl-color-primary']} 50%, transparent)`, }, + paddingInline: space['2'], alignItems: 'center', boxSizing: 'border-box', color: colorVars['--cl-color-card-foreground'], @@ -21,8 +21,6 @@ export const item = stylex.create({ lineHeight: typeScaleVars['--cl-text-sm-leading'], outlineOffset: '2px', textAlign: 'start', - transitionDuration: '150ms', - transitionProperty: 'background-color, color', width: '100%', }, @@ -30,17 +28,24 @@ export const item = stylex.create({ interactive: { backgroundColor: { default: null, - ':active': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 70%, transparent)`, + ':active': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, '@media (hover: hover)': { - ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 50%, transparent)`, + ':hover': `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 4%, transparent)`, }, }, cursor: 'pointer', }, + + // vertical density; horizontal padding is shared by the base + md: { + paddingBlock: space['2'], + }, + flush: { + paddingBlock: space['0'], + }, }); export const media = stylex.create({ - // sizes to its child; height follows the row (taller on a 2-line item, shorter on a button). base: { gap: space['2'], alignItems: 'center', @@ -63,23 +68,16 @@ export const content = stylex.create({ export const title = stylex.create({ base: { - gap: space['2'], - alignItems: 'center', color: colorVars['--cl-color-card-foreground'], - display: 'flex', fontSize: typeScaleVars['--cl-text-sm-size'], fontWeight: fontWeightVars['--cl-font-medium'], lineHeight: typeScaleVars['--cl-text-sm-leading'], - width: 'fit-content', }, }); export const description = stylex.create({ base: { - overflow: 'hidden', - WebkitBoxOrient: 'vertical', - WebkitLineClamp: 2, - color: colorVars['--cl-color-neutral-foreground'], + color: colorVars['--cl-color-neutral-faded'], fontSize: typeScaleVars['--cl-text-xs-size'], fontWeight: fontWeightVars['--cl-font-normal'], lineHeight: typeScaleVars['--cl-text-xs-leading'], diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index 97804bf5c31..f93375b7807 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -6,25 +6,32 @@ import { Item } from './item'; describe('Mosaic Item', () => { it('renders a div with its children', () => { - render(Hi); + render(Hi); expect(screen.getByText('Hi')).toBeInTheDocument(); }); it('applies its base class and is not interactive by default', () => { - render(Hi); + render(Hi); const item = screen.getByText('Hi'); expect(item).toHaveClass('cl-item'); expect(item).not.toHaveAttribute('data-interactive'); }); + it('reflects the size prop as a data attribute, defaulting to md', () => { + const { rerender } = render(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'md'); + rerender(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'flush'); + }); + it('wires consumer className/style through to the element', () => { render( - Hi - , + , ); const item = screen.getByText('Hi'); expect(item).toHaveClass('cl-item', 'my-item'); @@ -33,7 +40,7 @@ describe('Mosaic Item', () => { it('renders a custom element via render and marks it interactive', () => { render( - ( { Settings - , + , ); const link = screen.getByRole('link', { name: 'Settings' }); expect(link).toHaveClass('cl-item'); @@ -66,7 +73,7 @@ describe('Mosaic Item', () => { it('renders the composed slots with their stable classes', () => { render( - + Test Organization Member @@ -74,7 +81,7 @@ describe('Mosaic Item', () => { - , + , ); expect(screen.getByText('Test Organization')).toHaveClass('cl-item-title'); expect(screen.getByText('Member')).toHaveClass('cl-item-description'); @@ -84,9 +91,9 @@ describe('Mosaic Item', () => { it('renders a group as a list and a separator', () => { render( - One + One - Two + Two , ); expect(screen.getByRole('list')).toHaveClass('cl-item-group'); @@ -95,7 +102,7 @@ describe('Mosaic Item', () => { it('forwards the ref to the root element', () => { const ref = React.createRef(); - render(Hi); + render(Hi); expect(ref.current).toBe(screen.getByText('Hi')); }); }); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 20ede994a6e..64dafb3b5ed 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -7,12 +7,14 @@ import { mergeStyleProps, themeProps } from '../../props'; import * as slots from './item.styles'; export type ItemProps = Omit, 'render'> & { + /** Vertical density of the row. `flush` removes the block padding. */ + size?: 'flush' | 'md'; /** Render a custom element (e.g. a link or button) in place of the default `div`. */ render?: RenderProp>; }; const Root = React.forwardRef(function MosaicItem( - { render, className, style, ...rest }, + { size = 'md', render, className, style, ...rest }, ref, ) { // A custom render (link/button row) opts into hover + cursor affordances. @@ -23,8 +25,8 @@ const Root = React.forwardRef(function MosaicItem( ref, props: { ...mergeStyleProps( - themeProps('item', { interactive }), - stylex.props(slots.item.base, interactive && slots.item.interactive), + themeProps('item', { interactive, size }), + stylex.props(slots.item.base, slots.item[size], interactive && slots.item.interactive), className, style, ), @@ -141,11 +143,12 @@ const Separator = React.forwardRef>(fu /** * Mosaic `Item` — a row for lists of accounts, organizations, and settings. - * Composed via dot syntax: `Item.Media`, `Item.Content`, `Item.Title`, - * `Item.Description`, `Item.Actions`, `Item.Group`, + * Composed via dot syntax: `Item.Root`, `Item.Media`, `Item.Content`, + * `Item.Title`, `Item.Description`, `Item.Actions`, `Item.Group`, * `Item.Separator`. */ -export const Item = Object.assign(Root, { +export const Item = { + Root, Media, Content, Title, @@ -153,4 +156,4 @@ export const Item = Object.assign(Root, { Actions, Group, Separator, -}); +}; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 21eeb8c0911..5cd7f7be2fb 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -28,8 +28,9 @@ const colorDefaults = { '--cl-color-primary-foreground': 'light-dark(oklch(0.985 0 0), oklch(0.205 0 0))', '--cl-color-primary-faded': 'light-dark(oklch(0.9583 0.0214 291.74), oklch(0.3097 0.1008 285.05))', - '--cl-color-neutral': 'light-dark(oklch(0.97 0 0), oklch(0.32 0 0))', + '--cl-color-neutral': 'light-dark(oklch(0.2928 0.0163 285.35), oklch(0.9854 0.0013 286.38))', '--cl-color-neutral-foreground': 'light-dark(oklch(0.24 0 0), oklch(0.96 0 0))', + '--cl-color-neutral-faded': 'light-dark(oklch(0.5697 0.0246 279.94), oklch(0.6953 0.0261 285.7))', '--cl-color-negative': 'light-dark(oklch(0.577 0.245 27.325), oklch(0.637 0.237 25.331))', '--cl-color-negative-foreground': 'oklch(0.985 0 0)', @@ -43,6 +44,8 @@ const colorDefaults = { '--cl-color-warning-foreground': 'oklch(0.985 0 0)', '--cl-color-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', + '-cl-color-static': 'light-dark(oklch(0 0 0), oklch(1 0 0))', + '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', From 7ccd7be9ae9c606a9ddd61888ee390e66192efa4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 29 Jul 2026 11:03:32 -0400 Subject: [PATCH 08/13] simplify --- packages/ui/src/mosaic/components/avatar/avatar.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts index f9948484f5f..f9a471241a4 100644 --- a/packages/ui/src/mosaic/components/avatar/avatar.styles.ts +++ b/packages/ui/src/mosaic/components/avatar/avatar.styles.ts @@ -21,7 +21,7 @@ export const styles = stylex.create({ // image fills the clipped box image: { - aspectRatio: '1 / 1', + aspectRatio: '1', display: 'block', objectFit: 'cover', height: '100%', From 150f757128fa2e24a5938758009bf0a2bef1ca7e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 29 Jul 2026 11:23:08 -0400 Subject: [PATCH 09/13] feat(ui): single-line truncate Mosaic Item title and description Add a shared truncationStyles set to typography.styles.ts and compose singleLine on Item.Title and Item.Description so long labels ellipsis instead of wrapping. --- packages/swingset/src/stories/item.mdx | 4 ++-- .../src/mosaic/components/item/item.styles.ts | 1 - packages/ui/src/mosaic/components/item/item.tsx | 15 +++++++++++++-- .../src/mosaic/components/typography.styles.ts | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 3f8a6c9eb3c..e762b016ad8 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -58,7 +58,7 @@ import { Item } from '@clerk/ui/mosaic/components/item'; | `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | | `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | | `Item.Title` | `cl-item-title` | Primary label. | -| `Item.Description` | `cl-item-description` | Secondary text, clamped to two lines. Renders a `

`. | +| `Item.Description` | `cl-item-description` | Secondary text. Renders a `

`. | | `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | | `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | | `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | @@ -81,4 +81,4 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c } ``` -`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need (the examples use a 40px Mosaic `Avatar` and a 28px trailing icon), then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). +`Item.Media` sizes to its child and centers it; its height follows the row. Bring your own icon, avatar, or image at whatever dimensions you need, then size the slot by sizing that child. Colors, radii, and spacing all resolve from the Mosaic tokens (`--cl-color-*`, `--cl-radius-*`, `--cl-spacing`). diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index 8618694a57a..f9aa3e9c3f5 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -15,7 +15,6 @@ export const item = stylex.create({ boxSizing: 'border-box', color: colorVars['--cl-color-card-foreground'], display: 'flex', - flexWrap: 'wrap', fontFamily: 'inherit', fontSize: typeScaleVars['--cl-text-sm-size'], lineHeight: typeScaleVars['--cl-text-sm-leading'], diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 64dafb3b5ed..ecd6ef9bdba 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; +import { truncationStyles } from '../typography.styles'; import * as slots from './item.styles'; export type ItemProps = Omit, 'render'> & { @@ -74,7 +75,12 @@ const Title = React.forwardRef>(func render, ref, props: { - ...mergeStyleProps(themeProps('item-title'), stylex.props(slots.title.base), className, style), + ...mergeStyleProps( + themeProps('item-title'), + stylex.props(slots.title.base, truncationStyles.singleLine), + className, + style, + ), ...rest, }, }); @@ -89,7 +95,12 @@ const Description = React.forwardRef Date: Wed, 29 Jul 2026 11:36:32 -0400 Subject: [PATCH 10/13] button up story --- .../swingset/src/stories/item.stories.tsx | 83 +++++++++++++++++++ .../ui/src/mosaic/components/item/item.tsx | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index aa0aedf922f..3a50e07714b 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -82,6 +82,49 @@ function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) { ); } +function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + + ); +} + +function SignOutIcon(props: React.ComponentPropsWithoutRef<'svg'>) { + return ( + + + + ); +} + export function Default() { return ( @@ -235,6 +278,46 @@ export function Group() { + + + ( +
+ {children} + + )} + > + + + + + Add account + + + + + + ( + + {children} + + )} + > + + + + + Sign out of all accounts + + +
); } diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index ecd6ef9bdba..91c4695ef9c 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -91,7 +91,7 @@ const Description = React.forwardRef Date: Wed, 29 Jul 2026 14:15:18 -0400 Subject: [PATCH 11/13] feat(ui): derive Mosaic Item density from variant Replace the Item size prop with a variant-driven density: entity (8px) and action (10px). Add JSDoc to every Item part and fix the Header parts' display names. --- packages/swingset/src/stories/item.mdx | 33 ++++---- .../swingset/src/stories/item.stories.tsx | 39 ++++----- .../src/mosaic/components/item/item.styles.ts | 49 +++++++++-- .../src/mosaic/components/item/item.test.tsx | 8 +- .../ui/src/mosaic/components/item/item.tsx | 83 +++++++++++++++++-- packages/ui/src/mosaic/tokens.stylex.ts | 6 +- 6 files changed, 159 insertions(+), 59 deletions(-) diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index e762b016ad8..84b3f68f5f1 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -52,16 +52,19 @@ import { Item } from '@clerk/ui/mosaic/components/item'; ## Parts -| Part | Class | Description | -| ------------------ | --------------------- | -------------------------------------------------------------------- | -| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | -| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | -| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | -| `Item.Title` | `cl-item-title` | Primary label. | -| `Item.Description` | `cl-item-description` | Secondary text. Renders a `

`. | -| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | -| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | -| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | +| Part | Class | Description | +| -------------------- | ------------------------ | -------------------------------------------------------------------- | +| `Item.Root` | `cl-item` | Root row. Renders a `
`, or a custom element via `render`. | +| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. | +| `Item.Description` | `cl-item-description` | Secondary text. Renders a `

`. | +| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | +| `Item.Header` | `cl-item-header` | Header row above a group: a label with optional actions. | +| `Item.HeaderTitle` | `cl-item-header-title` | Label text within an `Item.Header`. | +| `Item.HeaderActions` | `cl-item-header-actions` | Trailing controls within an `Item.Header`. | +| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | +| `Item.Separator` | `cl-item-separator` | Thin divider (`


`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -69,10 +72,12 @@ Every part accepts a `render` prop for element polymorphism and forwards a ref. The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms: -| Prop | Attribute | Values | Default | -| -------- | ------------------ | ----------------------------------- | ------- | -| `size` | `data-size` | `flush` \| `md` | `md` | -| `render` | `data-interactive` | present when a `render` is provided | — | +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | -------- | +| `variant` | `data-variant` | `entity` \| `action` | `entity` | +| `render` | `data-interactive` | present when a `render` is provided | — | + +`variant` sets the row's vertical density (`entity` is standard, `action` is denser) and, on interactive rows, promotes the title color. ```css /* Re-theme interactive rows */ diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 3a50e07714b..c5a94b2105e 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -171,9 +171,6 @@ export function Interactive() { Test Organization Member - - - ); } @@ -198,20 +195,16 @@ export function Group() { - - - cameron@clerk.com - - - - - + + cameron@clerk.com + + @@ -226,9 +219,7 @@ export function Group() { Clerk 24 members • Basic - - - + ( @@ -281,6 +272,7 @@ export function Group() { ( ( - {children} - + )} > diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index f9aa3e9c3f5..9b83d54743d 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -4,6 +4,8 @@ import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../ export const item = stylex.create({ base: { + margin: 0, + padding: 0, borderRadius: radiusVars['--cl-radius-element'], gap: space['3'], outline: { @@ -35,23 +37,22 @@ export const item = stylex.create({ cursor: 'pointer', }, - // vertical density; horizontal padding is shared by the base - md: { + // vertical density derived from the variant; horizontal padding is shared by the base + entity: { paddingBlock: space['2'], }, - flush: { - paddingBlock: space['0'], + action: { + paddingBlock: space['2.5'], }, }); export const media = stylex.create({ base: { - gap: space['2'], alignItems: 'center', display: 'flex', flexShrink: 0, justifyContent: 'center', - width: 'fit-content', + width: space['9'], }, }); @@ -67,7 +68,14 @@ export const content = stylex.create({ export const title = stylex.create({ base: { - color: colorVars['--cl-color-card-foreground'], + // Non-action rows inherit the row's foreground. Only `action` rows start faded and + // darken to neutral on hover. Both overrides are scoped to the marker that item.tsx + // sets exclusively on interactive `action` rows, so no other row's title is touched. + color: { + default: null, + [stylex.when.ancestor(':not(:hover)')]: colorVars['--cl-color-neutral-faded'], + [stylex.when.ancestor(':hover')]: colorVars['--cl-color-neutral'], + }, fontSize: typeScaleVars['--cl-text-sm-size'], fontWeight: fontWeightVars['--cl-font-medium'], lineHeight: typeScaleVars['--cl-text-sm-leading'], @@ -92,6 +100,31 @@ export const actions = stylex.create({ }, }); +export const header = stylex.create({ + base: { + paddingInline: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); + +export const headerTitle = stylex.create({ + base: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, +}); + +export const headerActions = stylex.create({ + base: { + width: space['7'], + }, +}); + export const group = stylex.create({ base: { padding: space['2'], @@ -102,7 +135,7 @@ export const group = stylex.create({ export const separator = stylex.create({ base: { borderStyle: 'none', - backgroundColor: colorVars['--cl-color-border'], + backgroundColor: colorVars['--cl-color-border-faded'], flexShrink: 0, height: '1px', width: '100%', diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index f93375b7807..b681f4daa61 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -17,11 +17,11 @@ describe('Mosaic Item', () => { expect(item).not.toHaveAttribute('data-interactive'); }); - it('reflects the size prop as a data attribute, defaulting to md', () => { + it('reflects the variant prop as a data attribute, defaulting to entity', () => { const { rerender } = render(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'md'); - rerender(Hi); - expect(screen.getByText('Hi')).toHaveAttribute('data-size', 'flush'); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'entity'); + rerender(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'action'); }); it('wires consumer className/style through to the element', () => { diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 91c4695ef9c..0d327c6f7a6 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -8,14 +8,19 @@ import { truncationStyles } from '../typography.styles'; import * as slots from './item.styles'; export type ItemProps = Omit, 'render'> & { - /** Vertical density of the row. `flush` removes the block padding. */ - size?: 'flush' | 'md'; + /** + * Visual treatment, which also sets the row's vertical density. `entity` (default) + * is a standard row; `action` is a denser row whose title color is promoted on + * interactive rows. + */ + variant?: 'entity' | 'action'; /** Render a custom element (e.g. a link or button) in place of the default `div`. */ render?: RenderProp>; }; +/** Root row. Renders a `
`, or a custom element (link/button) via `render`. */ const Root = React.forwardRef(function MosaicItem( - { size = 'md', render, className, style, ...rest }, + { variant = 'entity', render, className, style, ...rest }, ref, ) { // A custom render (link/button row) opts into hover + cursor affordances. @@ -26,8 +31,14 @@ const Root = React.forwardRef(function MosaicItem( ref, props: { ...mergeStyleProps( - themeProps('item', { interactive, size }), - stylex.props(slots.item.base, slots.item[size], interactive && slots.item.interactive), + themeProps('item', { interactive, variant }), + stylex.props( + slots.item.base, + slots.item[variant], + interactive && slots.item.interactive, + // Marks action rows so `Item.Title` can react to their hover via `when.ancestor`. + interactive && variant === 'action' && stylex.defaultMarker(), + ), className, style, ), @@ -36,6 +47,7 @@ const Root = React.forwardRef(function MosaicItem( }); }); +/** Fixed-width leading i that centers its media (icon, image, or avatar). */ const Media = React.forwardRef>(function MosaicItemMedia( { render, className, style, ...rest }, ref, @@ -51,6 +63,7 @@ const Media = React.forwardRef>(func }); }); +/** Vertical stack (title + description) that grows to fill the row between media and actions. */ const Content = React.forwardRef>(function MosaicItemContent( { render, className, style, ...rest }, ref, @@ -66,6 +79,7 @@ const Content = React.forwardRef>(fu }); }); +/** Primary label. On interactive `action` rows its color darkens on hover. */ const Title = React.forwardRef>(function MosaicItemTitle( { render, className, style, ...rest }, ref, @@ -86,6 +100,7 @@ const Title = React.forwardRef>(func }); }); +/** Secondary text beneath the title. */ const Description = React.forwardRef>(function MosaicItemDescription( { render, className, style, ...rest }, ref, @@ -106,6 +121,7 @@ const Description = React.forwardRef>(function MosaicItemActions( { render, className, style, ...rest }, ref, @@ -121,6 +137,55 @@ const Actions = React.forwardRef>(fu }); }); +/** Header row above a group: a label (`Item.HeaderTitle`) with optional `Item.HeaderActions`. */ +const Header = React.forwardRef>(function MosaicItemHeader( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header'), stylex.props(slots.header.base), className, style), + ...rest, + }, + }); +}); + +/** Label text within an `Item.Header`. */ +const HeaderTitle = React.forwardRef>(function MosaicItemHeaderTitle( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header-title'), stylex.props(slots.headerTitle.base), className, style), + ...rest, + }, + }); +}); + +/** Trailing controls within an `Item.Header`. */ +const HeaderActions = React.forwardRef>(function MosaicItemHeaderActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('item-header-actions'), stylex.props(slots.headerActions.base), className, style), + ...rest, + }, + }); +}); + +/** Vertical list wrapper (`role="list"`) around a set of rows. */ const Group = React.forwardRef>(function MosaicItemGroup( { render, className, style, ...rest }, ref, @@ -137,6 +202,7 @@ const Group = React.forwardRef>(func }); }); +/** Thin divider (`
`) between rows or groups. */ const Separator = React.forwardRef>(function MosaicItemSeparator( { render, className, style, ...rest }, ref, @@ -155,8 +221,8 @@ const Separator = React.forwardRef>(fu /** * Mosaic `Item` — a row for lists of accounts, organizations, and settings. * Composed via dot syntax: `Item.Root`, `Item.Media`, `Item.Content`, - * `Item.Title`, `Item.Description`, `Item.Actions`, `Item.Group`, - * `Item.Separator`. + * `Item.Title`, `Item.Description`, `Item.Actions`, `Item.Header`, + * `Item.HeaderTitle`, `Item.HeaderActions`, `Item.Group`, `Item.Separator`. */ export const Item = { Root, @@ -165,6 +231,9 @@ export const Item = { Title, Description, Actions, + Header, + HeaderTitle, + HeaderActions, Group, Separator, }; diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index 5cd7f7be2fb..96765b6fd73 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -44,11 +44,11 @@ const colorDefaults = { '--cl-color-warning-foreground': 'oklch(0.985 0 0)', '--cl-color-warning-faded': 'light-dark(oklch(0.9799 0.0147 70.89), oklch(0.2725 0.0547 55.7))', - '-cl-color-static': 'light-dark(oklch(0 0 0), oklch(1 0 0))', - '--cl-color-card': 'light-dark(oklch(1 0 0), oklch(0.205 0 0))', '--cl-color-card-foreground': 'light-dark(oklch(0.145 0 0), oklch(0.985 0 0))', - '--cl-color-border': 'light-dark(oklch(0.922 0 0), oklch(1 0 0 / 10%))', + + '--cl-color-border': 'light-dark(oklch(0.9475 0.0067 286.27), oklch(0.3321 0.014 285.61))', + '--cl-color-border-faded': 'light-dark(oklch(0.9587 0.0027 286.35), oklch(0.296 0.0126 285.61))', } as const; export const colorVars = stylex.defineVars(colorDefaults); From 85c95c0f51e14a72733d9084f7c5defd54078cc9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 29 Jul 2026 14:20:46 -0400 Subject: [PATCH 12/13] fix(swingset): remove unused ArrowIcon from Item stories --- packages/swingset/src/stories/item.stories.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index c5a94b2105e..d5b514ba0ef 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -16,23 +16,6 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/components/item/item.tsx', }; -function ArrowIcon() { - return ( - - - - ); -} - function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) { return ( Date: Wed, 29 Jul 2026 14:43:48 -0400 Subject: [PATCH 13/13] fix(ui): drop role=list from Mosaic Item Group The group wraps interactive rows that carry their own semantics and can include a header, so a list role (with no listitem children) was misleading. Leave the wrapper unlabelled. --- packages/swingset/src/stories/item.mdx | 2 +- packages/ui/src/mosaic/components/item/item.test.tsx | 8 +++++--- packages/ui/src/mosaic/components/item/item.tsx | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 84b3f68f5f1..dd5aea2f3f8 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -63,7 +63,7 @@ import { Item } from '@clerk/ui/mosaic/components/item'; | `Item.Header` | `cl-item-header` | Header row above a group: a label with optional actions. | | `Item.HeaderTitle` | `cl-item-header-title` | Label text within an `Item.Header`. | | `Item.HeaderActions` | `cl-item-header-actions` | Trailing controls within an `Item.Header`. | -| `Item.Group` | `cl-item-group` | Vertical list wrapper (`role="list"`). | +| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). | | `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index b681f4daa61..01515263e9d 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -88,15 +88,17 @@ describe('Mosaic Item', () => { expect(screen.getByRole('button', { name: 'Manage' }).parentElement).toHaveClass('cl-item-actions'); }); - it('renders a group as a list and a separator', () => { + it('renders a group and a separator without imposing a role', () => { render( - + One Two , ); - expect(screen.getByRole('list')).toHaveClass('cl-item-group'); + const group = screen.getByTestId('group'); + expect(group).toHaveClass('cl-item-group'); + expect(group).not.toHaveAttribute('role'); expect(screen.getByTestId('sep')).toHaveClass('cl-item-separator'); }); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 0d327c6f7a6..3c850591355 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -185,7 +185,7 @@ const HeaderActions = React.forwardRef>(function MosaicItemGroup( { render, className, style, ...rest }, ref, @@ -195,7 +195,6 @@ const Group = React.forwardRef>(func render, ref, props: { - role: 'list', ...mergeStyleProps(themeProps('item-group'), stylex.props(slots.group.base), className, style), ...rest, },