From 6cd52df62121b36fd07d268e7b4e3f9358bd64fb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 27 Aug 2026 18:01:36 -0400 Subject: [PATCH 1/7] feat(ui): Add an outline variant to the Mosaic Item Group `Item.Group` takes a `variant` of `default` or `outline`. `outline` borders each row and reads the set as separate cards: the group drops its gutter and spaces the rows apart instead. Rows take it from the group through context, the way they already take `size` from the row. --- .changeset/mosaic-item-group-outline.md | 2 + packages/swingset/src/stories/item.mdx | 32 +++++++-- .../swingset/src/stories/item.stories.tsx | 68 ++++++++++++++++++- .../src/mosaic/components/item/item.styles.ts | 19 +++++- .../src/mosaic/components/item/item.test.tsx | 42 ++++++++++++ .../ui/src/mosaic/components/item/item.tsx | 50 +++++++++++--- 6 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 .changeset/mosaic-item-group-outline.md diff --git a/.changeset/mosaic-item-group-outline.md b/.changeset/mosaic-item-group-outline.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-item-group-outline.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index f60726f4729..7ee1894f607 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -4,6 +4,8 @@ import * as ItemStories from './item.stories'; Item is a flexible row for lists of accounts, organizations, and settings in Mosaic. It's composed from parts via dot syntax (`Item.Root`, `Item.Media`, `Item.Content`, `Item.Label`, …). `Item.Root` renders as a `
` by default; pass it a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. +Set `variant='outline'` once on `Item.Group` and every row inside it gains a border, with the group spacing them apart rather than seating them on one surface. + Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's height and gap, and `Item.Media` picks the matching column width up from context rather than taking a size of its own. ## Example @@ -34,6 +36,17 @@ Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's storyModule={ItemStories} /> +### Outline group + +`Item.Group` takes a `variant`. `outline` borders each row and reads the set as separate cards, so +the group drops its own gutter and spaces the rows 8px apart instead. Rows pick the variant up from +the group through context — they take no `variant` prop of their own. + + + ### Scrolling `Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms @@ -109,7 +122,7 @@ Media sizes itself from the row, so give it a child that fills its column — an | `Item.Label` | `cl-item-label` | The row's label. Truncates to a single line. | | `Item.Description` | `cl-item-description` | Secondary text beneath the label. Truncates to a single line. | | `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). | -| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). | +| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows. Takes the `variant` its rows read. | | `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -124,12 +137,21 @@ The row carries the text color and, through `--_cl-icon-color`, the strength of | --------- | -------------- | ------------------------ | --------- | | `variant` | `data-variant` | `primary` \| `secondary` | `primary` | +`Item.Group` chooses how its rows read as a set. `outline` borders each row and separates them; the +group drops its gutter and spaces the rows by 8px. Rows read it from the group rather than taking it +themselves, and reflect it as `data-variant` on `.cl-item`: + +| Prop | Attribute | Values | Default | +| --------- | -------------- | ---------------------- | --------- | +| `variant` | `data-variant` | `default` \| `outline` | `default` | + 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` | `xs` \| `md` | `md` | -| `render` | `data-interactive` | present when a `render` is provided | — | +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | --------- | +| `size` | `data-size` | `xs` \| `md` | `md` | +| `render` | `data-interactive` | present when a `render` is provided | — | +| _(group)_ | `data-variant` | `default` \| `outline` | `default` | `size` fixes the row's height and gap. `Item.Media` reflects the same value as `data-size` and takes its width from it, so the two stay in step without being set twice: diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 97594a694c4..ca7083f84e1 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -3,9 +3,8 @@ import { Button } from '@clerk/ui/mosaic/components/button'; import { Icon } from '@clerk/ui/mosaic/components/icon'; import { Item } from '@clerk/ui/mosaic/components/item'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; -import { radiusVars } from '@clerk/ui/mosaic/styles'; +import { radiusVars, space } from '@clerk/ui/mosaic/styles'; import * as stylex from '@stylexjs/stylex'; -import * as React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -267,6 +266,71 @@ export function Group() { ); } +export function OutlineGroup() { + return ( +
+ + + + + + C + + + + Clerk + Admin + + + + + + ( + + {children} + + )} + > + + + D + + + + DesignCloud + Member + + + + +
+ ); +} + const organizations = [ 'Clerk', 'Acme Corporation', diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index d4b3dac4d3c..bf0880a91ed 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -57,6 +57,12 @@ export const item = stylex.create({ }, }, + outline: { + borderColor: colorVars['--cl-color-border'], + borderStyle: 'solid', + borderWidth: '1px', + }, + xs: { gap: space['2'], height: space['9'], @@ -130,9 +136,20 @@ export const actions = stylex.create({ export const group = stylex.create({ base: { - padding: space['1.5'], width: '100%', }, + + // The gutter belongs to the variant rather than `base`: `outline` has none, and holding it here + // keeps the two from overriding each other. + default: { + padding: space['1.5'], + }, + // Bordered rows read as separate cards, so the group drops its gutter and spaces them instead. + outline: { + gap: space['2'], + display: 'flex', + flexDirection: 'column', + }, }); export const separator = stylex.create({ diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index 9cb19c56494..77a64f048c2 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -148,6 +148,48 @@ describe('Mosaic Item', () => { expect(screen.getByTestId('sep')).toHaveClass('cl-item-separator'); }); + it('reflects the group variant as a data attribute, defaulting to default', () => { + const { rerender } = render(One); + expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'default'); + rerender( + + One + , + ); + expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'outline'); + }); + + it('reads the variant from the group in its rows', () => { + render( + + Outlined + , + ); + expect(screen.getByText('Outlined')).toHaveAttribute('data-variant', 'outline'); + }); + + it('falls back to the default variant when a row renders outside a group', () => { + render(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'default'); + }); + + it('gives an outlined row a border atom a default row does not carry', () => { + render( + <> + Plain + + Outlined + + , + ); + + const atoms = (text: string) => screen.getByText(text).className.split(' '); + expect(atoms('Outlined').filter(atom => !atoms('Plain').includes(atom))).not.toHaveLength(0); + }); + it('forwards the ref to the root element', () => { const ref = React.createRef(); render(Hi); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index e4815730d66..d8d90a9a917 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -17,6 +17,14 @@ const DEFAULT_SIZE: Size = 'md'; /** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */ const ItemContext = React.createContext(DEFAULT_SIZE); +/** How a group presents its rows: as one continuous list, or as separated bordered rows. */ +type GroupVariant = 'default' | 'outline'; + +const DEFAULT_GROUP_VARIANT: GroupVariant = 'default'; + +/** Carries `Item.Group`'s variant down to the rows it borders (`Item.Root`). */ +const ItemGroupContext = React.createContext(DEFAULT_GROUP_VARIANT); + export type ItemProps = MosaicComponentProps<'div'> & { /** * Row height and gap. Also sizes a nested `Item.Media`, which reads this from @@ -30,7 +38,8 @@ export type ItemProps = MosaicComponentProps<'div'> & { /** * Root row. Renders a `
`, or a custom element (link/button) via `render`, * which also opts the row into hover and cursor affordances. Provides its `size` - * to the parts nested within it. + * to the parts nested within it, and takes its border from the `variant` of the + * enclosing `Item.Group`. * * @example * {children}}> @@ -44,18 +53,20 @@ const Root = React.forwardRef(function MosaicItem( ) { // A custom render (link/button row) opts into hover + cursor affordances. const interactive = Boolean(render); + const variant = React.useContext(ItemGroupContext); const element = useRender({ defaultTagName: 'div', render, ref, props: { ...mergeStyleProps( - themeProps('item', { interactive, size }), + themeProps('item', { interactive, size, variant }), stylex.props( reset.base, focusOutline.visible, slots.item.base, slots.item[size], + variant === 'outline' && slots.item.outline, interactive && slots.item.interactive, ), className, @@ -186,20 +197,42 @@ const Actions = React.forwardRef>(fu }); }); -/** Vertical wrapper around a set of rows. Layout only; the rows carry their own semantics. */ -const Group = React.forwardRef>(function MosaicItemGroup( - { render, className, style, ...rest }, +export type ItemGroupProps = MosaicComponentProps<'div'> & { + /** + * `default` keeps the rows on one continuous surface, inset by the group's own + * gutter. `outline` gives each row a border and reads them as separate cards, + * so the group drops its gutter and spaces the rows apart instead. Reaches the + * rows through context rather than a prop on each one. + * + * @default 'default' + */ + variant?: GroupVariant; +}; + +/** + * Vertical wrapper around a set of rows. Layout only; the rows carry their own + * semantics. Provides its `variant` to the rows nested within it. + */ +const Group = React.forwardRef(function MosaicItemGroup( + { variant = DEFAULT_GROUP_VARIANT, render, className, style, ...rest }, ref, ) { - return useRender({ + const element = useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('item-group'), stylex.props(reset.base, slots.group.base), className, style), + ...mergeStyleProps( + themeProps('item-group', { variant }), + stylex.props(reset.base, slots.group.base, slots.group[variant]), + className, + style, + ), ...rest, }, }); + + return {element}; }); /** Thin divider (`
`) between rows or groups. */ @@ -230,7 +263,8 @@ const Separator = React.forwardRef>(fu * `Item.Separator`. Every part takes a `render` prop and forwards a ref. * * `size` is set once on `Item.Root` and reaches `Item.Media` through context, so - * a row scales as a unit rather than per part. + * a row scales as a unit rather than per part. `variant` is set once on + * `Item.Group` and reaches its rows the same way. */ export const Item = { Root, From 25bc4c747a678210fda237606289fbc03eec545c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 27 Aug 2026 18:04:16 -0400 Subject: [PATCH 2/7] adjust placement --- packages/swingset/src/stories/item.stories.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index ca7083f84e1..25ea321b4f4 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -161,6 +161,9 @@ export function Group() {
Date: Thu, 27 Aug 2026 18:19:28 -0400 Subject: [PATCH 3/7] feat(ui): Let a Mosaic Item set its own outline variant `variant` moves onto `Item.Root` as well as `Item.Group`, so a row standing on its own can border itself. A row that sets one wins over its group, which also lets a single row opt out of an outlined group with `default`. --- packages/swingset/src/stories/item.mdx | 42 +++++++++++-------- .../swingset/src/stories/item.stories.tsx | 27 ++++++++++++ .../src/mosaic/components/item/item.test.tsx | 20 +++++++++ .../ui/src/mosaic/components/item/item.tsx | 41 +++++++++++------- 4 files changed, 99 insertions(+), 31 deletions(-) diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 7ee1894f607..08edade50b0 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -4,7 +4,7 @@ import * as ItemStories from './item.stories'; Item is a flexible row for lists of accounts, organizations, and settings in Mosaic. It's composed from parts via dot syntax (`Item.Root`, `Item.Media`, `Item.Content`, `Item.Label`, …). `Item.Root` renders as a `
` by default; pass it a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances. -Set `variant='outline'` once on `Item.Group` and every row inside it gains a border, with the group spacing them apart rather than seating them on one surface. +Set `variant='outline'` on a row to border it, or once on `Item.Group` to border every row inside it — the group then spaces them apart rather than seating them on one surface. Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's height and gap, and `Item.Media` picks the matching column width up from context rather than taking a size of its own. @@ -36,11 +36,20 @@ Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's storyModule={ItemStories} /> +### Outline + +`variant='outline'` borders a row so it reads as its own card. + + + ### Outline group -`Item.Group` takes a `variant`. `outline` borders each row and reads the set as separate cards, so -the group drops its own gutter and spaces the rows 8px apart instead. Rows pick the variant up from -the group through context — they take no `variant` prop of their own. +Set the same `variant` on `Item.Group` to border a whole set at once: the group drops its own gutter +and spaces the rows 8px apart instead. Rows pick it up from the group through context, so only a row +that disagrees needs a `variant` of its own. `) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -137,21 +146,20 @@ The row carries the text color and, through `--_cl-icon-color`, the strength of | --------- | -------------- | ------------------------ | --------- | | `variant` | `data-variant` | `primary` \| `secondary` | `primary` | -`Item.Group` chooses how its rows read as a set. `outline` borders each row and separates them; the -group drops its gutter and spaces the rows by 8px. Rows read it from the group rather than taking it -themselves, and reflect it as `data-variant` on `.cl-item`: - -| Prop | Attribute | Values | Default | -| --------- | -------------- | ---------------------- | --------- | -| `variant` | `data-variant` | `default` \| `outline` | `default` | +`variant` decides whether a row is bordered. It sits on both `Item.Root` and `Item.Group`: set it on +the group and every row inside it takes it through context, with the group dropping its gutter and +spacing the rows by 8px. A row that sets its own wins over its group, in either direction — so one +row can opt out of an outlined group with `variant='default'`. 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` | `xs` \| `md` | `md` | -| `render` | `data-interactive` | present when a `render` is provided | — | -| _(group)_ | `data-variant` | `default` \| `outline` | `default` | +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | ----------- | +| `size` | `data-size` | `xs` \| `md` | `md` | +| `variant` | `data-variant` | `default` \| `outline` | its group's | +| `render` | `data-interactive` | present when a `render` is provided | — | + +`Item.Group` reflects its own `variant` as `data-variant` on `.cl-item-group`. `size` fixes the row's height and gap. `Item.Media` reflects the same value as `data-size` and takes its width from it, so the two stay in step without being set twice: diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 25ea321b4f4..91ed6e18dd4 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -269,6 +269,33 @@ export function Group() { ); } +export function Outline() { + return ( + + + + T + + + + Test Organization + Member + + + + + + ); +} + export function OutlineGroup() { return (
diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index 77a64f048c2..863b42426f3 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -190,6 +190,26 @@ describe('Mosaic Item', () => { expect(atoms('Outlined').filter(atom => !atoms('Plain').includes(atom))).not.toHaveLength(0); }); + it('takes a variant of its own, outside any group', () => { + render(Hi); + expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'outline'); + }); + + it('lets a row override the variant its group provides, in both directions', () => { + render( + <> + + Opted out + + + Opted in + + , + ); + expect(screen.getByText('Opted out')).toHaveAttribute('data-variant', 'default'); + expect(screen.getByText('Opted in')).toHaveAttribute('data-variant', 'outline'); + }); + it('forwards the ref to the root element', () => { const ref = React.createRef(); render(Hi); diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index d8d90a9a917..e56375ca6c9 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -10,20 +10,20 @@ import { truncationStyles } from '../../utils/typography.styles'; import * as slots from './item.styles'; /** The row's height and gap, and the width of the media column inside it. */ -type Size = 'xs' | 'md'; +type Size = 'xs' | 'md' | 'lg'; const DEFAULT_SIZE: Size = 'md'; /** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */ const ItemContext = React.createContext(DEFAULT_SIZE); -/** How a group presents its rows: as one continuous list, or as separated bordered rows. */ -type GroupVariant = 'default' | 'outline'; +/** How a row presents itself: seated on a shared surface, or bordered as its own card. */ +type Variant = 'default' | 'outline'; -const DEFAULT_GROUP_VARIANT: GroupVariant = 'default'; +const DEFAULT_VARIANT: Variant = 'default'; /** Carries `Item.Group`'s variant down to the rows it borders (`Item.Root`). */ -const ItemGroupContext = React.createContext(DEFAULT_GROUP_VARIANT); +const ItemGroupContext = React.createContext(DEFAULT_VARIANT); export type ItemProps = MosaicComponentProps<'div'> & { /** @@ -33,13 +33,22 @@ export type ItemProps = MosaicComponentProps<'div'> & { * @default 'md' */ size?: Size; + /** + * `outline` borders the row so it reads as its own card. Set it here for a row + * standing on its own, or on `Item.Group` to border a whole set at once — a + * row set here wins over the group either way, so a row can opt out of an + * outlined group with `default`. + * + * @default the enclosing `Item.Group`'s variant, or `'default'` + */ + variant?: Variant; }; /** * Root row. Renders a `
`, or a custom element (link/button) via `render`, * which also opts the row into hover and cursor affordances. Provides its `size` - * to the parts nested within it, and takes its border from the `variant` of the - * enclosing `Item.Group`. + * to the parts nested within it, and takes its `variant` from the enclosing + * `Item.Group` unless it sets one of its own. * * @example * {children}}> @@ -48,12 +57,15 @@ export type ItemProps = MosaicComponentProps<'div'> & { * */ const Root = React.forwardRef(function MosaicItem( - { size = DEFAULT_SIZE, render, className, style, ...rest }, + { size = DEFAULT_SIZE, variant: variantProp, render, className, style, ...rest }, ref, ) { // A custom render (link/button row) opts into hover + cursor affordances. const interactive = Boolean(render); - const variant = React.useContext(ItemGroupContext); + // The group is the default, not the authority: a row that names a variant keeps it, which is what + // lets one row opt out of an outlined group. + const groupVariant = React.useContext(ItemGroupContext); + const variant = variantProp ?? groupVariant; const element = useRender({ defaultTagName: 'div', render, @@ -202,11 +214,12 @@ export type ItemGroupProps = MosaicComponentProps<'div'> & { * `default` keeps the rows on one continuous surface, inset by the group's own * gutter. `outline` gives each row a border and reads them as separate cards, * so the group drops its gutter and spaces the rows apart instead. Reaches the - * rows through context rather than a prop on each one. + * rows through context, so a row only needs its own `variant` to disagree with + * the group. * * @default 'default' */ - variant?: GroupVariant; + variant?: Variant; }; /** @@ -214,7 +227,7 @@ export type ItemGroupProps = MosaicComponentProps<'div'> & { * semantics. Provides its `variant` to the rows nested within it. */ const Group = React.forwardRef(function MosaicItemGroup( - { variant = DEFAULT_GROUP_VARIANT, render, className, style, ...rest }, + { variant = DEFAULT_VARIANT, render, className, style, ...rest }, ref, ) { const element = useRender({ @@ -263,8 +276,8 @@ const Separator = React.forwardRef>(fu * `Item.Separator`. Every part takes a `render` prop and forwards a ref. * * `size` is set once on `Item.Root` and reaches `Item.Media` through context, so - * a row scales as a unit rather than per part. `variant` is set once on - * `Item.Group` and reaches its rows the same way. + * a row scales as a unit rather than per part. `variant` is set on a row, or + * once on `Item.Group` to reach every row in it. */ export const Item = { Root, From 68d38eace839f6165647cc0364fccc0bcc483ecf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 27 Aug 2026 18:26:18 -0400 Subject: [PATCH 4/7] feat(ui): Add an lg size to the Mosaic Item `lg` is a taller row with a wider gap and its own inline padding. The media column narrows to match at `md` and `lg`. --- packages/swingset/src/stories/item.stories.tsx | 10 +++++++--- packages/ui/src/mosaic/components/item/item.styles.ts | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 91ed6e18dd4..0bd9a9c024f 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -76,7 +76,7 @@ export function Interactive() { export function Sizes() { return (
- {(['md', 'xs'] as const).map(size => ( + {(['lg', 'md', 'xs'] as const).map(size => ( + - + ( Date: Thu, 27 Aug 2026 18:52:06 -0400 Subject: [PATCH 5/7] fix font sizing for interactive items --- packages/ui/src/mosaic/components/item/item.styles.ts | 11 ++++------- packages/ui/src/mosaic/components/item/item.tsx | 4 ++-- .../ui/src/mosaic/user-button/user-button.view.tsx | 4 ++-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index 6c6d5c09dab..a43c6efec7a 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -104,22 +104,19 @@ export const content = stylex.create({ export const label = stylex.create({ base: { + fontSize: typeScaleVars['--cl-text-sm-size'], fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], }, - primary: { + default: { color: colorVars['--cl-color-neutral'], - fontSize: typeScaleVars['--cl-text-sm-size'], - lineHeight: typeScaleVars['--cl-text-sm-leading'], }, // Declares no color, so `reset`'s `inherit` stands and the row's own color reaches it. That is // what carries it through the hover promotion on an interactive row, which a fixed color would // freeze. It has to stay undeclared here rather than restated: StyleX resolves a property to the // last style that declares it, so `base` cannot hold a color either. - secondary: { - fontSize: typeScaleVars['--cl-text-xs-size'], - lineHeight: typeScaleVars['--cl-text-xs-leading'], - }, + interactive: {}, }); export const description = stylex.create({ diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index e56375ca6c9..aad5e04b891 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -134,9 +134,9 @@ const Content = React.forwardRef>(fu }); /** Where the label sits in the row's hierarchy. */ -type LabelVariant = 'primary' | 'secondary'; +type LabelVariant = 'default' | 'interactive'; -const DEFAULT_LABEL_VARIANT: LabelVariant = 'primary'; +const DEFAULT_LABEL_VARIANT: LabelVariant = 'default'; export type ItemLabelProps = MosaicComponentProps<'div'> & { /** diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index ada9c3a745f..da1d2fb09d3 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -314,7 +314,7 @@ function ActionRow({ icon, label, href, onClick, busyKey }: ActionRowProps) { > {busy ? : icon} - {label} + {label} ); @@ -795,7 +795,7 @@ function SwitchAccountRow() { )} - {m.accounts.switch} + {m.accounts.switch} Date: Thu, 27 Aug 2026 19:25:53 -0400 Subject: [PATCH 6/7] back to 40 --- packages/ui/src/mosaic/components/item/item.styles.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index a43c6efec7a..9efd3a52470 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -88,8 +88,8 @@ export const media = stylex.create({ }, xs: { width: space['6'] }, - md: { width: space['9.5'] }, - lg: { width: space['9.5'] }, + md: { width: space['10'] }, + lg: { width: space['10'] }, }); export const content = stylex.create({ From 8ad819a908b4757a7d51303562a47f528aa6716e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 28 Aug 2026 09:25:26 -0400 Subject: [PATCH 7/7] fix(ui): Catch the Item.Label call sites up to the renamed variants The label union moved from primary|secondary to default|interactive; the stories, tests, and docs still named the old values, which failed the swingset typecheck. Also documents the lg size. --- packages/swingset/src/stories/item.mdx | 14 +++++++------- packages/swingset/src/stories/item.stories.tsx | 4 ++-- .../src/mosaic/components/item/item.test.tsx | 18 +++++++++--------- .../ui/src/mosaic/components/item/item.tsx | 12 ++++++------ .../__tests__/user-button.view.test.tsx | 8 ++++---- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index 08edade50b0..2cf5d28c847 100644 --- a/packages/swingset/src/stories/item.mdx +++ b/packages/swingset/src/stories/item.mdx @@ -108,7 +108,7 @@ import { Item } from '@clerk/ui/mosaic/components/item'; ; ``` -Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an `Icon`. A row whose text _is_ the row, rather than naming a subject, uses `Item.Label` with `variant='secondary'`: +Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an `Icon`. A row whose text _is_ the row, rather than naming a subject, uses `Item.Label` with `variant='interactive'`: ```tsx }> @@ -116,7 +116,7 @@ Media sizes itself from the row, so give it a child that fills its column — an - Sign out of all accounts + Sign out of all accounts ; ``` @@ -140,11 +140,11 @@ Every part accepts a `render` prop for element polymorphism and forwards a ref. The row carries the text color and, through `--_cl-icon-color`, the strength of any `Icon` inside it: faded at rest, full-strength while hovered. Only interactive rows promote — a static row isn't pointing at anything, so its icon holds its resting color. A `Button` in `Item.Actions` sets its own icon color and is unaffected. -`Item.Label` chooses whether it joins that promotion. `variant='primary'` sets its own color and holds it, so a subject the row names reads at full strength either way. `variant='secondary'` sets none and inherits the row's, so it fades and brightens with the row: +`Item.Label` chooses whether it joins that promotion. `variant='default'` sets its own color and holds it, so a subject the row names reads at full strength either way. `variant='interactive'` sets none and inherits the row's, so it fades and brightens with the row: -| Prop | Attribute | Values | Default | -| --------- | -------------- | ------------------------ | --------- | -| `variant` | `data-variant` | `primary` \| `secondary` | `primary` | +| Prop | Attribute | Values | Default | +| --------- | -------------- | -------------------------- | --------- | +| `variant` | `data-variant` | `default` \| `interactive` | `default` | `variant` decides whether a row is bordered. It sits on both `Item.Root` and `Item.Group`: set it on the group and every row inside it takes it through context, with the group dropping its gutter and @@ -155,7 +155,7 @@ The root reflects its state as `data-*` attributes on `.cl-item`, so consumers c | Prop | Attribute | Values | Default | | --------- | ------------------ | ----------------------------------- | ----------- | -| `size` | `data-size` | `xs` \| `md` | `md` | +| `size` | `data-size` | `xs` \| `md` \| `lg` | `md` | | `variant` | `data-variant` | `default` \| `outline` | its group's | | `render` | `data-interactive` | present when a `render` is provided | — | diff --git a/packages/swingset/src/stories/item.stories.tsx b/packages/swingset/src/stories/item.stories.tsx index 0bd9a9c024f..170d67f6bc2 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -240,7 +240,7 @@ export function Group() { /> - Add account + Add account - Sign out of all accounts + Sign out of all accounts diff --git a/packages/ui/src/mosaic/components/item/item.test.tsx b/packages/ui/src/mosaic/components/item/item.test.tsx index 863b42426f3..f6ce8a3ac86 100644 --- a/packages/ui/src/mosaic/components/item/item.test.tsx +++ b/packages/ui/src/mosaic/components/item/item.test.tsx @@ -44,22 +44,22 @@ describe('Mosaic Item', () => { expect(screen.getByTestId('media')).toHaveAttribute('data-size', 'md'); }); - it('reflects the label variant as a data attribute, defaulting to primary', () => { + it('reflects the label variant as a data attribute, defaulting to default', () => { const { rerender } = render(Clerk); - expect(screen.getByText('Clerk')).toHaveAttribute('data-variant', 'primary'); - rerender(Clerk); - expect(screen.getByText('Clerk')).toHaveAttribute('data-variant', 'secondary'); + expect(screen.getByText('Clerk')).toHaveAttribute('data-variant', 'default'); + rerender(Clerk); + expect(screen.getByText('Clerk')).toHaveAttribute('data-variant', 'interactive'); }); // StyleX keeps only the last atom that declares a property, so the reset's `color: inherit` - // survives on the variant that declares no color of its own. That is what puts the secondary + // survives on the variant that declares no color of its own. That is what puts the interactive // label on the row's color and carries it through the row's hover promotion. `Item.Content` // declares no color either, so it stands in for the untouched reset. - it('lets the row color the secondary label, and not the primary one', () => { + it('lets the row color the interactive label, and not the default one', () => { render( - Primary - Secondary + Default + Interactive , ); @@ -67,7 +67,7 @@ describe('Mosaic Item', () => { const resetAtoms = atoms(screen.getByTestId('reset-only')); const inherited = (text: string) => resetAtoms.filter(atom => atoms(screen.getByText(text)).includes(atom)); - expect(inherited('Secondary')).toHaveLength(inherited('Primary').length + 1); + expect(inherited('Interactive')).toHaveLength(inherited('Default').length + 1); }); 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 aad5e04b891..5a6076e98cd 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -140,13 +140,13 @@ const DEFAULT_LABEL_VARIANT: LabelVariant = 'default'; export type ItemLabelProps = MosaicComponentProps<'div'> & { /** - * `primary` names the row's subject (a person, an organization) and holds its - * own strength. `secondary` is quieter and takes its color from the row rather - * than setting one, so on an interactive row it brightens with the row on - * hover instead of staying fixed. Use it where the text is the row itself - * (`Add account`, `Sign out`) or a group heading, not a subject the row names. + * `default` names the row's subject (a person, an organization) and sets its + * own color, so it holds that strength whatever the row does. `interactive` + * sets no color and takes the row's, so on an interactive row it brightens + * with the row on hover instead of staying fixed. Use it where the text is + * the row itself (`Add account`, `Sign out`), not a subject the row names. * - * @default 'primary' + * @default 'default' */ variant?: LabelVariant; }; diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index 3d6ffbb851c..8d0b3b87699 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -74,12 +74,12 @@ const popup = () => screen.getByRole('dialog', { name: 'Account' }); // popup's sections rather than an implementation detail. const groups = () => Array.from(popup().querySelectorAll('.cl-item-group')); const labels = (group: HTMLElement | undefined) => - Array.from(group?.querySelectorAll(".cl-item-label[data-variant='primary']") ?? []).map( + Array.from(group?.querySelectorAll(".cl-item-label[data-variant='default']") ?? []).map( node => node.textContent ?? '', ); const row = (group: HTMLElement | undefined, label: string) => Array.from(group?.querySelectorAll('.cl-item') ?? []).find( - node => node.querySelector(".cl-item-label[data-variant='primary']")?.textContent === label, + node => node.querySelector(".cl-item-label[data-variant='default']")?.textContent === label, ); const scrollClasses = stylex.props(...scrollAreaViewport('auto')).className?.split(' ') ?? []; @@ -290,7 +290,7 @@ describe('UserButtonView, combined mode', () => { const onCreateOrganization = vi.fn(); renderCombined({ onCreateOrganization }); - // Not `labels`: the row is an action rather than a workspace, so its label is the secondary one. + // Not `labels`: the row is an action rather than a workspace, so its label is the interactive one. const rows = Array.from(workspaceList()?.querySelectorAll('.cl-item-label') ?? []); expect(rows.at(-1)?.textContent).toBe('Create organization'); await userEvent.setup().click(screen.getByRole('button', { name: 'Create organization' })); @@ -588,7 +588,7 @@ describe('UserButtonView, the foot', () => { /** The foot's rows, in the order it lists them. It is the last group in the popup. */ const footActions = () => - Array.from(groups().at(-1)?.querySelectorAll(".cl-item-label[data-variant='secondary']") ?? []).map( + Array.from(groups().at(-1)?.querySelectorAll(".cl-item-label[data-variant='interactive']") ?? []).map( node => node.textContent ?? '', );