Skip to content

Commit 49d61ff

Browse files
feat(ui): scale Mosaic Item rows from a single size prop (#9297)
1 parent b0519d2 commit 49d61ff

8 files changed

Lines changed: 254 additions & 182 deletions

File tree

.changeset/mosaic-item-size.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/swingset/src/stories/item.mdx

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as ItemStories from './item.stories';
44

55
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 `<div>` by default; pass it a `render` prop to make a row an interactive link or button, which adds hover and cursor affordances.
66

7+
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.
8+
79
## Example
810

911
<Story
@@ -18,6 +20,13 @@ Item is a flexible row for lists of accounts, organizations, and settings in Mos
1820
storyModule={ItemStories}
1921
/>
2022

23+
### Sizes
24+
25+
<Story
26+
name='Sizes'
27+
storyModule={ItemStories}
28+
/>
29+
2130
### Group
2231

2332
<Story
@@ -34,7 +43,7 @@ import { Item } from '@clerk/ui/mosaic/components/item';
3443
<Item.Group>
3544
<Item.Root render={({ children, ...props }) => <a {...props} href='/org'>{children}</a>}>
3645
<Item.Media>
37-
<Avatar.Root shape='square' size='md'>
46+
<Avatar.Root shape='square' size='fit'>
3847
<Avatar.Image src={org.imageUrl} alt='' />
3948
<Avatar.Fallback>{org.name[0]}</Avatar.Fallback>
4049
</Avatar.Root>
@@ -44,46 +53,62 @@ import { Item } from '@clerk/ui/mosaic/components/item';
4453
<Item.Description>Member</Item.Description>
4554
</Item.Content>
4655
<Item.Actions>
47-
<Button variant='outline'>Manage</Button>
56+
<Button variant='outline' size='sm'>Manage</Button>
4857
</Item.Actions>
4958
</Item.Root>
5059
</Item.Group>;
5160
```
5261

62+
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`:
63+
64+
```tsx
65+
<Item.Root size='xs' render={({ children, ...props }) => <button type='button' {...props}>{children}</button>}>
66+
<Item.Media>
67+
<SignOutIcon width='100%' />
68+
</Item.Media>
69+
<Item.Content>
70+
<Item.Label>Sign out of all accounts</Item.Label>
71+
</Item.Content>
72+
</Item.Root>;
73+
```
74+
5375
## Parts
5476

55-
| Part | Class | Description |
56-
| -------------------- | ------------------------ | -------------------------------------------------------------------- |
57-
| `Item.Root` | `cl-item` | Root row. Renders a `<div>`, or a custom element via `render`. |
58-
| `Item.Media` | `cl-item-media` | Leading (or trailing) media: icon, image, or avatar. |
59-
| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
60-
| `Item.Title` | `cl-item-title` | Primary label. |
61-
| `Item.Description` | `cl-item-description` | Secondary text. Renders a `<p>`. |
62-
| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
63-
| `Item.Header` | `cl-item-header` | Header row above a group: a label with optional actions. |
64-
| `Item.HeaderTitle` | `cl-item-header-title` | Label text within an `Item.Header`. |
65-
| `Item.HeaderActions` | `cl-item-header-actions` | Trailing controls within an `Item.Header`. |
66-
| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
67-
| `Item.Separator` | `cl-item-separator` | Thin divider (`<hr>`) between rows. |
77+
| Part | Class | Description |
78+
| ------------------ | --------------------- | -------------------------------------------------------------------------- |
79+
| `Item.Root` | `cl-item` | Root row. Renders a `<div>`, or a custom element via `render`. |
80+
| `Item.Media` | `cl-item-media` | Square leading column: icon, image, or avatar. Sized by the root's `size`. |
81+
| `Item.Content` | `cl-item-content` | Vertical stack that grows to fill the row between media and actions. |
82+
| `Item.Title` | `cl-item-title` | Primary label. Truncates to a single line. |
83+
| `Item.Description` | `cl-item-description` | Secondary text beneath the title. Truncates to a single line. |
84+
| `Item.Label` | `cl-item-label` | Sole label on an action row, in place of a title. Dimmed until hovered. |
85+
| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
86+
| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows (layout only, no role). |
87+
| `Item.Separator` | `cl-item-separator` | Thin divider (`<hr>`) between rows. |
6888

6989
Every part accepts a `render` prop for element polymorphism and forwards a ref.
7090

7191
## Styling
7292

7393
The root reflects its state as `data-*` attributes on `.cl-item`, so consumers can scope overrides without touching StyleX's hashed atoms:
7494

75-
| Prop | Attribute | Values | Default |
76-
| --------- | ------------------ | ----------------------------------- | -------- |
77-
| `variant` | `data-variant` | `entity` \| `action` | `entity` |
78-
| `render` | `data-interactive` | present when a `render` is provided | |
95+
| Prop | Attribute | Values | Default |
96+
| -------- | ------------------ | ----------------------------------- | ------- |
97+
| `size` | `data-size` | `xs` \| `md` | `md` |
98+
| `render` | `data-interactive` | present when a `render` is provided ||
7999

80-
`variant` sets the row's vertical density (`entity` is standard, `action` is denser) and, on interactive rows, promotes the title color.
100+
`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:
81101

82102
```css
83103
/* Re-theme interactive rows */
84104
.cl-item[data-interactive] {
85105
background-color: var(--cl-color-card);
86106
}
107+
108+
/* Widen the media column on compact rows */
109+
.cl-item-media[data-size='xs'] {
110+
width: 1.5rem;
111+
}
87112
```
88113

89-
`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`).
114+
`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`).

packages/swingset/src/stories/item.stories.tsx

Lines changed: 78 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -35,36 +35,6 @@ function CheckMarkIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
3535
);
3636
}
3737

38-
function EllipsisIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
39-
return (
40-
<svg
41-
viewBox='0 0 16 16'
42-
width='1em'
43-
height='1em'
44-
fill='none'
45-
{...props}
46-
>
47-
<path
48-
d='M2.75 8.01978V8.01001C2.75 7.31965 3.30964 6.76001 4 6.76001C4.69036 6.76001 5.25 7.31965 5.25 8.01001V8.01978C5.25 8.71013 4.69036 9.26978 4 9.26978C3.30964 9.26978 2.75 8.71013 2.75 8.01978Z'
49-
fill='#747686'
50-
/>
51-
<path
52-
d='M6.75 8.00977V8C6.75 7.30964 7.30964 6.75 8 6.75C8.69036 6.75 9.25 7.30964 9.25 8V8.00977C9.25 8.70012 8.69036 9.25977 8 9.25977C7.30964 9.25977 6.75 8.70012 6.75 8.00977Z'
53-
fill='#747686'
54-
/>
55-
<path
56-
d='M10.75 8.00977V8C10.75 7.30964 11.3096 6.75 12 6.75C12.6904 6.75 13.25 7.30964 13.25 8V8.00977C13.25 8.70012 12.6904 9.25977 12 9.25977C11.3096 9.25977 10.75 8.70012 10.75 8.00977Z'
57-
fill='#747686'
58-
/>
59-
<rect
60-
width='0.0001'
61-
height='0.0001'
62-
fill='#747686'
63-
/>
64-
</svg>
65-
);
66-
}
67-
6838
function PlusIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
6939
return (
7040
<svg
@@ -124,7 +94,12 @@ export function Default() {
12494
<Item.Description>Member</Item.Description>
12595
</Item.Content>
12696
<Item.Actions>
127-
<Button variant='outline'>Manage</Button>
97+
<Button
98+
variant='outline'
99+
size='sm'
100+
>
101+
Manage
102+
</Button>
128103
</Item.Actions>
129104
</Item.Root>
130105
);
@@ -158,6 +133,31 @@ export function Interactive() {
158133
);
159134
}
160135

136+
export function Sizes() {
137+
return (
138+
<div className='w-full'>
139+
{(['md', 'xs'] as const).map(size => (
140+
<Item.Root
141+
key={size}
142+
size={size}
143+
>
144+
<Item.Media>
145+
<Avatar.Root
146+
shape='square'
147+
size='fit'
148+
>
149+
<Avatar.Fallback>T</Avatar.Fallback>
150+
</Avatar.Root>
151+
</Item.Media>
152+
<Item.Content>
153+
<Item.Title>Test Organization</Item.Title>
154+
</Item.Content>
155+
</Item.Root>
156+
))}
157+
</div>
158+
);
159+
}
160+
161161
export function Group() {
162162
return (
163163
<div className='w-full'>
@@ -173,25 +173,52 @@ export function Group() {
173173
</Item.Media>
174174
<Item.Content>
175175
<Item.Title>Cameron Walker</Item.Title>
176+
<Item.Description>cameron@clerk.com</Item.Description>
176177
</Item.Content>
177178
<Item.Actions>
178-
<Button variant='outline'>Invite</Button>
179+
<Button
180+
variant='outline'
181+
size='sm'
182+
>
183+
Invite
184+
</Button>
179185
</Item.Actions>
180186
</Item.Root>
181187
</Item.Group>
182188
<Item.Separator />
183189
<Item.Group>
184-
<Item.Header>
185-
<Item.HeaderTitle>cameron@clerk.com</Item.HeaderTitle>
186-
<Button
187-
size='sm'
188-
shape='square'
189-
variant='ghost'
190-
>
191-
<EllipsisIcon />
192-
</Button>
193-
</Item.Header>
194-
<Item.Root>
190+
<Item.Root size='xs'>
191+
<Item.Content>
192+
<Item.Description>cameron.walker@gmail.com</Item.Description>
193+
</Item.Content>
194+
<Item.Actions>
195+
<Button
196+
variant='ghost'
197+
color='neutral'
198+
size='sm'
199+
shape='square'
200+
>
201+
<svg
202+
width='16'
203+
viewBox='0 0 16 16'
204+
>
205+
<path
206+
d='M2.75 8.01978V8.01001C2.75 7.31965 3.30964 6.76001 4 6.76001C4.69036 6.76001 5.25 7.31965 5.25 8.01001V8.01978C5.25 8.71013 4.69036 9.26978 4 9.26978C3.30964 9.26978 2.75 8.71013 2.75 8.01978Z'
207+
fill='currentColor'
208+
/>
209+
<path
210+
d='M6.75 8.00977V8C6.75 7.30964 7.30964 6.75 8 6.75C8.69036 6.75 9.25 7.30964 9.25 8V8.00977C9.25 8.70012 8.69036 9.25977 8 9.25977C7.30964 9.25977 6.75 8.70012 6.75 8.00977Z'
211+
fill='currentColor'
212+
/>
213+
<path
214+
d='M10.75 8.00977V8C10.75 7.30964 11.3096 6.75 12 6.75C12.6904 6.75 13.25 7.30964 13.25 8V8.00977C13.25 8.70012 12.6904 9.25977 12 9.25977C11.3096 9.25977 10.75 8.70012 10.75 8.00977Z'
215+
fill='currentColor'
216+
/>
217+
</svg>
218+
</Button>
219+
</Item.Actions>
220+
</Item.Root>
221+
<Item.Root size='xs'>
195222
<Item.Media>
196223
<Avatar.Root
197224
size='fit'
@@ -206,11 +233,11 @@ export function Group() {
206233
</Item.Media>
207234
<Item.Content>
208235
<Item.Title>Clerk</Item.Title>
209-
<Item.Description>24 members &bull; Basic</Item.Description>
210236
</Item.Content>
211237
<CheckMarkIcon width='28px' />
212238
</Item.Root>
213239
<Item.Root
240+
size='xs'
214241
render={({ children, ...props }) => (
215242
<a
216243
{...props}
@@ -234,13 +261,13 @@ export function Group() {
234261
</Item.Media>
235262
<Item.Content>
236263
<Item.Title>Clerk</Item.Title>
237-
<Item.Description>24 members &bull; Basic</Item.Description>
238264
</Item.Content>
239265
</Item.Root>
240266
</Item.Group>
241267
<Item.Separator />
242268
<Item.Group>
243269
<Item.Root
270+
size='xs'
244271
render={({ children, ...props }) => (
245272
<a
246273
{...props}
@@ -260,14 +287,13 @@ export function Group() {
260287
</Item.Media>
261288
<Item.Content>
262289
<Item.Title>DesignCloud</Item.Title>
263-
<Item.Description>12 members &bull; Pro</Item.Description>
264290
</Item.Content>
265291
</Item.Root>
266292
</Item.Group>
267293
<Item.Separator />
268294
<Item.Group>
269295
<Item.Root
270-
variant='action'
296+
size='xs'
271297
render={({ children, ...props }) => (
272298
<a
273299
{...props}
@@ -278,17 +304,14 @@ export function Group() {
278304
)}
279305
>
280306
<Item.Media>
281-
<PlusIcon width='36px' />
307+
<PlusIcon width='100%' />
282308
</Item.Media>
283309
<Item.Content>
284-
<Item.Title>Add account</Item.Title>
310+
<Item.Label>Add account</Item.Label>
285311
</Item.Content>
286312
</Item.Root>
287-
</Item.Group>
288-
<Item.Separator />
289-
<Item.Group>
290313
<Item.Root
291-
variant='action'
314+
size='xs'
292315
render={({ children, ...props }) => (
293316
<button
294317
type='button'
@@ -299,10 +322,10 @@ export function Group() {
299322
)}
300323
>
301324
<Item.Media>
302-
<SignOutIcon width='36px' />
325+
<SignOutIcon width='100%' />
303326
</Item.Media>
304327
<Item.Content>
305-
<Item.Title>Sign out of all accounts</Item.Title>
328+
<Item.Label>Sign out of all accounts</Item.Label>
306329
</Item.Content>
307330
</Item.Root>
308331
</Item.Group>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
/**
4+
* Scopes a part's ancestor-state selectors to a Mosaic `Item.Root`, so an unrelated hovered
5+
* ancestor can't drive them. `Item.Root` applies it; `Item.Label` matches on it to follow the
6+
* row's hover rather than its own.
7+
*
8+
* Its own module because `@stylexjs/enforce-extension` requires the define-primitives to live in
9+
* a `.stylex.ts` file.
10+
*/
11+
export const itemScope = stylex.defineMarker();

0 commit comments

Comments
 (0)