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..2cf5d28c847 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'` 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. ## Example @@ -34,6 +36,26 @@ 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 + +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. + + + ### Scrolling `Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms @@ -86,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 }> @@ -94,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 ; ``` @@ -109,7 +131,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. Sets the `variant` its rows take. | | `Item.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -118,18 +140,26 @@ 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` | `default` \| `interactive` | `default` | -| Prop | Attribute | Values | Default | -| --------- | -------------- | ------------------------ | --------- | -| `variant` | `data-variant` | `primary` \| `secondary` | `primary` | +`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 | — | +| Prop | Attribute | Values | Default | +| --------- | ------------------ | ----------------------------------- | ----------- | +| `size` | `data-size` | `xs` \| `md` \| `lg` | `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 97594a694c4..170d67f6bc2 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'; @@ -77,7 +76,7 @@ export function Interactive() { export function Sizes() { return (
- {(['md', 'xs'] as const).map(size => ( + {(['lg', 'md', 'xs'] as const).map(size => ( - Add account + Add account - Sign out of all accounts + Sign out of all accounts @@ -267,6 +269,102 @@ export function Group() { ); } +export function Outline() { + return ( + + + + T + + + + Test Organization + Member + + + + + + ); +} + +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..9efd3a52470 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'], @@ -65,6 +71,11 @@ export const item = stylex.create({ gap: space['3'], height: space['13'], }, + lg: { + gap: space['3'], + paddingInline: space['3'], + height: space['16'], + }, }); export const media = stylex.create({ @@ -78,6 +89,7 @@ export const media = stylex.create({ xs: { width: space['6'] }, md: { width: space['10'] }, + lg: { width: space['10'] }, }); export const content = stylex.create({ @@ -92,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({ @@ -130,9 +139,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..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', () => { @@ -148,6 +148,68 @@ 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('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 e4815730d66..5a6076e98cd 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -10,13 +10,21 @@ 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 row presents itself: seated on a shared surface, or bordered as its own card. */ +type Variant = 'default' | 'outline'; + +const DEFAULT_VARIANT: Variant = 'default'; + +/** Carries `Item.Group`'s variant down to the rows it borders (`Item.Root`). */ +const ItemGroupContext = React.createContext(DEFAULT_VARIANT); + export type ItemProps = MosaicComponentProps<'div'> & { /** * Row height and gap. Also sizes a nested `Item.Media`, which reads this from @@ -25,12 +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. + * to the parts nested within it, and takes its `variant` from the enclosing + * `Item.Group` unless it sets one of its own. * * @example * {children}}> @@ -39,23 +57,28 @@ 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); + // 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, 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, @@ -111,19 +134,19 @@ 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'> & { /** - * `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; }; @@ -186,20 +209,43 @@ 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, so a row only needs its own `variant` to disagree with + * the group. + * + * @default 'default' + */ + variant?: Variant; +}; + +/** + * 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_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 +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. + * 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, 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 ?? '', ); 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}