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 (
+
+ );
+}
+
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}