From dbe00017f55b1fbeaa355c0e3c01bf3b53ed4dd2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 30 Jul 2026 19:44:32 -0400 Subject: [PATCH 1/3] feat(ui): scale Mosaic Item rows from a single size prop Item.Root now takes size (xs | md), fixing the row height and gap, and provides it through context so Item.Media derives its column width instead of taking a size of its own. Replaces the entity/action variant, adds Item.Label for action rows, and drops Item.Header/HeaderTitle/HeaderActions, which had no consumers. --- .changeset/mosaic-item-size.md | 2 + packages/swingset/src/stories/item.mdx | 67 ++++++--- .../swingset/src/stories/item.stories.tsx | 102 +++++++------- .../src/mosaic/components/item/item.styles.ts | 63 ++++----- .../src/mosaic/components/item/item.test.tsx | 28 +++- .../ui/src/mosaic/components/item/item.tsx | 129 +++++++++--------- packages/ui/src/mosaic/tokens.stylex.ts | 1 + 7 files changed, 210 insertions(+), 182 deletions(-) create mode 100644 .changeset/mosaic-item-size.md diff --git a/.changeset/mosaic-item-size.md b/.changeset/mosaic-item-size.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-item-size.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/item.mdx b/packages/swingset/src/stories/item.mdx index dd5aea2f3f8..2aea12d0cac 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.Title`, …). `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 `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 +### Sizes + + + ### Group {children}}> - + {org.name[0]} @@ -44,27 +53,38 @@ import { Item } from '@clerk/ui/mosaic/components/item'; Member - + ; ``` +Media sizes itself from the row, so give it a child that fills its column — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'`. An action row that has no secondary text uses `Item.Label` in place of `Item.Title`: + +```tsx + }> + + + + + Sign out of all accounts + +; +``` + ## 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.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 wrapper around a set of rows (layout only, no role). | -| `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` | Square leading column: icon, image, or avatar. Sized by the root's `size`. | +| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. | +| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. | +| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. | +| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Dimmed until hovered. | +| `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.Separator` | `cl-item-separator` | Thin divider (`
`) between rows. | Every part accepts a `render` prop for element polymorphism and forwards a ref. @@ -72,18 +92,23 @@ 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 | -| --------- | ------------------ | ----------------------------------- | -------- | -| `variant` | `data-variant` | `entity` \| `action` | `entity` | -| `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 | — | -`variant` sets the row's vertical density (`entity` is standard, `action` is denser) and, on interactive rows, promotes the title color. +`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: ```css /* Re-theme interactive rows */ .cl-item[data-interactive] { background-color: var(--cl-color-card); } + +/* Widen the media column on compact rows */ +.cl-item-media[data-size='xs'] { + width: 1.5rem; +} ``` -`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`). +`Item.Media` is a square that centers its child. Because the column is sized by the row, give it a child that fills it — an `Avatar.Root` with `size='fit'`, or an icon at `width='100%'` — rather than a fixed pixel size that won't track `size`. 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 7dd6c1fc288..32b5b9c6da5 100644 --- a/packages/swingset/src/stories/item.stories.tsx +++ b/packages/swingset/src/stories/item.stories.tsx @@ -35,36 +35,6 @@ function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) { ); } -function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) { - return ( - - - - - - - ); -} - function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) { return ( Member - + ); @@ -158,6 +133,31 @@ export function Interactive() { ); } +export function Sizes() { + return ( +
+ {(['md', 'xs'] as const).map(size => ( + + + + T + + + + Test Organization + + + ))} +
+ ); +} + export function Group() { return (
@@ -173,25 +173,21 @@ export function Group() { Cameron Walker + cameron@clerk.com - + - - cameron@clerk.com - - - + Clerk - 24 members • Basic ( Clerk - 24 members • Basic ( DesignCloud - 12 members • Pro ( - + - Add account + Add account - - - ( + + Date: Thu, 30 Jul 2026 20:00:35 -0400 Subject: [PATCH 3/3] fix(ui): key Mosaic Item label hover to the row Item.Label reacted to :hover on its own text, so the color only promoted when the pointer was over the label itself rather than anywhere in the row. Adds an itemScope marker applied by Item.Root and keys the label color to stylex.when.ancestor against it, so the scope cannot be triggered by an unrelated hovered ancestor. --- .../src/mosaic/components/item/item.markers.stylex.ts | 11 +++++++++++ packages/ui/src/mosaic/components/item/item.styles.ts | 9 +++++---- packages/ui/src/mosaic/components/item/item.tsx | 3 ++- 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/mosaic/components/item/item.markers.stylex.ts diff --git a/packages/ui/src/mosaic/components/item/item.markers.stylex.ts b/packages/ui/src/mosaic/components/item/item.markers.stylex.ts new file mode 100644 index 00000000000..03e38ee908f --- /dev/null +++ b/packages/ui/src/mosaic/components/item/item.markers.stylex.ts @@ -0,0 +1,11 @@ +import * as stylex from '@stylexjs/stylex'; + +/** + * Scopes a part's ancestor-state selectors to a Mosaic `Item.Root`, so an unrelated hovered + * ancestor can't drive them. `Item.Root` applies it; `Item.Label` matches on it to follow the + * row's hover rather than its own. + * + * Its own module because `@stylexjs/enforce-extension` requires the define-primitives to live in + * a `.stylex.ts` file. + */ +export const itemScope = stylex.defineMarker(); diff --git a/packages/ui/src/mosaic/components/item/item.styles.ts b/packages/ui/src/mosaic/components/item/item.styles.ts index d962b97206d..bb787fc74a3 100644 --- a/packages/ui/src/mosaic/components/item/item.styles.ts +++ b/packages/ui/src/mosaic/components/item/item.styles.ts @@ -1,6 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; +import { itemScope } from './item.markers.stylex'; export const item = stylex.create({ base: { @@ -89,11 +90,11 @@ export const description = stylex.create({ export const label = stylex.create({ base: { + // keyed to the row's hover, not the text's, so pointing anywhere in the row promotes the label color: { - default: colorVars['--cl-color-neutral-faded'], - '@media (hover: hover)': { - ':hover': colorVars['--cl-color-neutral'], - }, + default: null, + [stylex.when.ancestor(':not(:hover)', itemScope)]: colorVars['--cl-color-neutral-faded'], + [stylex.when.ancestor(':hover', itemScope)]: colorVars['--cl-color-neutral'], }, fontSize: typeScaleVars['--cl-text-xs-size'], fontWeight: fontWeightVars['--cl-font-medium'], diff --git a/packages/ui/src/mosaic/components/item/item.tsx b/packages/ui/src/mosaic/components/item/item.tsx index 6af0187ed4e..d79cc885538 100644 --- a/packages/ui/src/mosaic/components/item/item.tsx +++ b/packages/ui/src/mosaic/components/item/item.tsx @@ -5,6 +5,7 @@ import React from 'react'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { truncationStyles } from '../typography.styles'; +import { itemScope } from './item.markers.stylex'; import * as slots from './item.styles'; /** The row's height and gap, and the width of the media column inside it. */ @@ -51,7 +52,7 @@ const Root = React.forwardRef(function MosaicItem( props: { ...mergeStyleProps( themeProps('item', { interactive, size }), - stylex.props(slots.item.base, slots.item[size], interactive && slots.item.interactive), + stylex.props(itemScope, slots.item.base, slots.item[size], interactive && slots.item.interactive), className, style, ),