Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-item-group-outline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment thread
austincalvelage marked this conversation as resolved.
52 changes: 41 additions & 11 deletions packages/swingset/src/stories/item.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<div>` 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
Expand Down Expand Up @@ -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.

<Story
name='Outline'
storyModule={ItemStories}
/>

### 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.

<Story
name='OutlineGroup'
storyModule={ItemStories}
/>

### Scrolling

`Item.Group` is the canonical scroll surface in Mosaic: cap its height, spread the scroll-area atoms
Expand Down Expand Up @@ -86,15 +108,15 @@ import { Item } from '@clerk/ui/mosaic/components/item';
</Item.Group>;
```

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
<Item.Root size='xs' render={({ children, ...props }) => <button type='button' {...props}>{children}</button>}>
<Item.Media>
<Icon name='log-out' size='sm' />
</Item.Media>
<Item.Content>
<Item.Label variant='secondary'>Sign out of all accounts</Item.Label>
<Item.Label variant='interactive'>Sign out of all accounts</Item.Label>
</Item.Content>
</Item.Root>;
```
Expand All @@ -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 (`<hr>`) between rows. |

Every part accepts a `render` prop for element polymorphism and forwards a ref.
Expand All @@ -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 | — |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`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:

Expand Down
108 changes: 103 additions & 5 deletions packages/swingset/src/stories/item.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,7 +76,7 @@ export function Interactive() {
export function Sizes() {
return (
<div className='w-full'>
{(['md', 'xs'] as const).map(size => (
{(['lg', 'md', 'xs'] as const).map(size => (
<Item.Root
key={size}
size={size}
Expand Down Expand Up @@ -162,6 +161,9 @@ export function Group() {
<Icon
name='check'
size='md'
style={{
width: space['7'],
}}
/>
</Item.Root>
<Item.Root
Expand Down Expand Up @@ -238,7 +240,7 @@ export function Group() {
/>
</Item.Media>
<Item.Content>
<Item.Label variant='secondary'>Add account</Item.Label>
<Item.Label variant='interactive'>Add account</Item.Label>
</Item.Content>
</Item.Root>
<Item.Root
Expand All @@ -259,14 +261,110 @@ export function Group() {
/>
</Item.Media>
<Item.Content>
<Item.Label variant='secondary'>Sign out of all accounts</Item.Label>
<Item.Label variant='interactive'>Sign out of all accounts</Item.Label>
</Item.Content>
</Item.Root>
</Item.Group>
</div>
);
}

export function Outline() {
return (
<Item.Root
variant='outline'
size='lg'
>
<Item.Media>
<Avatar.Root
shape='square'
size='fit'
>
<Avatar.Fallback>T</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Label>Test Organization</Item.Label>
<Item.Description>Member</Item.Description>
</Item.Content>
<Item.Actions>
<Button
variant='outline'
size='sm'
>
Manage
</Button>
</Item.Actions>
</Item.Root>
);
}

export function OutlineGroup() {
return (
<div className='w-full'>
<Item.Group variant='outline'>
<Item.Root size='lg'>
<Item.Media>
<Avatar.Root
size='fit'
shape='square'
>
<Avatar.Image
src='https://github.com/clerk.png'
alt='Clerk'
/>
<Avatar.Fallback>C</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Label>Clerk</Item.Label>
<Item.Description>Admin</Item.Description>
</Item.Content>
<Item.Actions>
<Button
variant='outline'
size='sm'
>
Manage
</Button>
</Item.Actions>
</Item.Root>
<Item.Root
size='lg'
render={({ children, ...props }) => (
<a
{...props}
href='#designcloud'
>
{children}
</a>
)}
>
<Item.Media>
<Avatar.Root
size='fit'
shape='square'
>
<Avatar.Fallback>D</Avatar.Fallback>
</Avatar.Root>
</Item.Media>
<Item.Content>
<Item.Label>DesignCloud</Item.Label>
<Item.Description>Member</Item.Description>
</Item.Content>
<Icon
name='chevron-right'
size='md'
style={{
width: space['7'],
}}
/>
</Item.Root>
</Item.Group>
</div>
);
}

const organizations = [
'Clerk',
'Acme Corporation',
Expand Down
36 changes: 28 additions & 8 deletions packages/ui/src/mosaic/components/item/item.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand Down
Loading
Loading