Skip to content

Commit 6e5f74f

Browse files
committed
feat(ui): Let a Mosaic Item set its own outline variant
`variant` moves onto `Item.Root` as well as `Item.Group`, so a row standing on its own can border itself. A row that sets one wins over its group, which also lets a single row opt out of an outlined group with `default`.
1 parent 25bc4c7 commit 6e5f74f

4 files changed

Lines changed: 99 additions & 31 deletions

File tree

packages/swingset/src/stories/item.mdx

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ 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.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.
66

7-
Set `variant='outline'` once on `Item.Group` and every row inside it gains a border, with the group spacing them apart rather than seating them on one surface.
7+
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.
88

99
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.
1010

@@ -36,11 +36,20 @@ Set `size` once on `Item.Root` and the row scales as a unit: it fixes the row's
3636
storyModule={ItemStories}
3737
/>
3838

39+
### Outline
40+
41+
`variant='outline'` borders a row so it reads as its own card.
42+
43+
<Story
44+
name='Outline'
45+
storyModule={ItemStories}
46+
/>
47+
3948
### Outline group
4049

41-
`Item.Group` takes a `variant`. `outline` borders each row and reads the set as separate cards, so
42-
the group drops its own gutter and spaces the rows 8px apart instead. Rows pick the variant up from
43-
the group through context — they take no `variant` prop of their own.
50+
Set the same `variant` on `Item.Group` to border a whole set at once: the group drops its own gutter
51+
and spaces the rows 8px apart instead. Rows pick it up from the group through context, so only a row
52+
that disagrees needs a `variant` of its own.
4453

4554
<Story
4655
name='OutlineGroup'
@@ -122,7 +131,7 @@ Media sizes itself from the row, so give it a child that fills its column — an
122131
| `Item.Label` | `cl-item-label` | The row's label. Truncates to a single line. |
123132
| `Item.Description` | `cl-item-description` | Secondary text beneath the label. Truncates to a single line. |
124133
| `Item.Actions` | `cl-item-actions` | Trailing controls (buttons, badges). |
125-
| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows. Takes the `variant` its rows read. |
134+
| `Item.Group` | `cl-item-group` | Vertical wrapper around a set of rows. Sets the `variant` its rows take. |
126135
| `Item.Separator` | `cl-item-separator` | Thin divider (`<hr>`) between rows. |
127136

128137
Every part accepts a `render` prop for element polymorphism and forwards a ref.
@@ -137,21 +146,20 @@ The row carries the text color and, through `--_cl-icon-color`, the strength of
137146
| --------- | -------------- | ------------------------ | --------- |
138147
| `variant` | `data-variant` | `primary` \| `secondary` | `primary` |
139148

140-
`Item.Group` chooses how its rows read as a set. `outline` borders each row and separates them; the
141-
group drops its gutter and spaces the rows by 8px. Rows read it from the group rather than taking it
142-
themselves, and reflect it as `data-variant` on `.cl-item`:
143-
144-
| Prop | Attribute | Values | Default |
145-
| --------- | -------------- | ---------------------- | --------- |
146-
| `variant` | `data-variant` | `default` \| `outline` | `default` |
149+
`variant` decides whether a row is bordered. It sits on both `Item.Root` and `Item.Group`: set it on
150+
the group and every row inside it takes it through context, with the group dropping its gutter and
151+
spacing the rows by 8px. A row that sets its own wins over its group, in either direction — so one
152+
row can opt out of an outlined group with `variant='default'`.
147153

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

150-
| Prop | Attribute | Values | Default |
151-
| --------- | ------------------ | ----------------------------------- | --------- |
152-
| `size` | `data-size` | `xs` \| `md` | `md` |
153-
| `render` | `data-interactive` | present when a `render` is provided ||
154-
| _(group)_ | `data-variant` | `default` \| `outline` | `default` |
156+
| Prop | Attribute | Values | Default |
157+
| --------- | ------------------ | ----------------------------------- | ----------- |
158+
| `size` | `data-size` | `xs` \| `md` | `md` |
159+
| `variant` | `data-variant` | `default` \| `outline` | its group's |
160+
| `render` | `data-interactive` | present when a `render` is provided ||
161+
162+
`Item.Group` reflects its own `variant` as `data-variant` on `.cl-item-group`.
155163

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

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,33 @@ export function Group() {
269269
);
270270
}
271271

272+
export function Outline() {
273+
return (
274+
<Item.Root variant='outline'>
275+
<Item.Media>
276+
<Avatar.Root
277+
shape='square'
278+
size='fit'
279+
>
280+
<Avatar.Fallback>T</Avatar.Fallback>
281+
</Avatar.Root>
282+
</Item.Media>
283+
<Item.Content>
284+
<Item.Label>Test Organization</Item.Label>
285+
<Item.Description>Member</Item.Description>
286+
</Item.Content>
287+
<Item.Actions>
288+
<Button
289+
variant='outline'
290+
size='sm'
291+
>
292+
Manage
293+
</Button>
294+
</Item.Actions>
295+
</Item.Root>
296+
);
297+
}
298+
272299
export function OutlineGroup() {
273300
return (
274301
<div className='w-full'>

packages/ui/src/mosaic/components/item/item.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,26 @@ describe('Mosaic Item', () => {
190190
expect(atoms('Outlined').filter(atom => !atoms('Plain').includes(atom))).not.toHaveLength(0);
191191
});
192192

193+
it('takes a variant of its own, outside any group', () => {
194+
render(<Item.Root variant='outline'>Hi</Item.Root>);
195+
expect(screen.getByText('Hi')).toHaveAttribute('data-variant', 'outline');
196+
});
197+
198+
it('lets a row override the variant its group provides, in both directions', () => {
199+
render(
200+
<>
201+
<Item.Group variant='outline'>
202+
<Item.Root variant='default'>Opted out</Item.Root>
203+
</Item.Group>
204+
<Item.Group>
205+
<Item.Root variant='outline'>Opted in</Item.Root>
206+
</Item.Group>
207+
</>,
208+
);
209+
expect(screen.getByText('Opted out')).toHaveAttribute('data-variant', 'default');
210+
expect(screen.getByText('Opted in')).toHaveAttribute('data-variant', 'outline');
211+
});
212+
193213
it('forwards the ref to the root element', () => {
194214
const ref = React.createRef<HTMLDivElement>();
195215
render(<Item.Root ref={ref}>Hi</Item.Root>);

packages/ui/src/mosaic/components/item/item.tsx

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,20 @@ import { truncationStyles } from '../../utils/typography.styles';
1010
import * as slots from './item.styles';
1111

1212
/** The row's height and gap, and the width of the media column inside it. */
13-
type Size = 'xs' | 'md';
13+
type Size = 'xs' | 'md' | 'lg';
1414

1515
const DEFAULT_SIZE: Size = 'md';
1616

1717
/** Carries `Item.Root`'s size down to the parts it scales (`Item.Media`). */
1818
const ItemContext = React.createContext<Size>(DEFAULT_SIZE);
1919

20-
/** How a group presents its rows: as one continuous list, or as separated bordered rows. */
21-
type GroupVariant = 'default' | 'outline';
20+
/** How a row presents itself: seated on a shared surface, or bordered as its own card. */
21+
type Variant = 'default' | 'outline';
2222

23-
const DEFAULT_GROUP_VARIANT: GroupVariant = 'default';
23+
const DEFAULT_VARIANT: Variant = 'default';
2424

2525
/** Carries `Item.Group`'s variant down to the rows it borders (`Item.Root`). */
26-
const ItemGroupContext = React.createContext<GroupVariant>(DEFAULT_GROUP_VARIANT);
26+
const ItemGroupContext = React.createContext<Variant>(DEFAULT_VARIANT);
2727

2828
export type ItemProps = MosaicComponentProps<'div'> & {
2929
/**
@@ -33,13 +33,22 @@ export type ItemProps = MosaicComponentProps<'div'> & {
3333
* @default 'md'
3434
*/
3535
size?: Size;
36+
/**
37+
* `outline` borders the row so it reads as its own card. Set it here for a row
38+
* standing on its own, or on `Item.Group` to border a whole set at once — a
39+
* row set here wins over the group either way, so a row can opt out of an
40+
* outlined group with `default`.
41+
*
42+
* @default the enclosing `Item.Group`'s variant, or `'default'`
43+
*/
44+
variant?: Variant;
3645
};
3746

3847
/**
3948
* Root row. Renders a `<div>`, or a custom element (link/button) via `render`,
4049
* which also opts the row into hover and cursor affordances. Provides its `size`
41-
* to the parts nested within it, and takes its border from the `variant` of the
42-
* enclosing `Item.Group`.
50+
* to the parts nested within it, and takes its `variant` from the enclosing
51+
* `Item.Group` unless it sets one of its own.
4352
*
4453
* @example
4554
* <Item.Root size='xs' render={({ children, ...props }) => <a {...props} href='/org'>{children}</a>}>
@@ -48,12 +57,15 @@ export type ItemProps = MosaicComponentProps<'div'> & {
4857
* </Item.Root>
4958
*/
5059
const Root = React.forwardRef<HTMLDivElement, ItemProps>(function MosaicItem(
51-
{ size = DEFAULT_SIZE, render, className, style, ...rest },
60+
{ size = DEFAULT_SIZE, variant: variantProp, render, className, style, ...rest },
5261
ref,
5362
) {
5463
// A custom render (link/button row) opts into hover + cursor affordances.
5564
const interactive = Boolean(render);
56-
const variant = React.useContext(ItemGroupContext);
65+
// The group is the default, not the authority: a row that names a variant keeps it, which is what
66+
// lets one row opt out of an outlined group.
67+
const groupVariant = React.useContext(ItemGroupContext);
68+
const variant = variantProp ?? groupVariant;
5769
const element = useRender({
5870
defaultTagName: 'div',
5971
render,
@@ -202,19 +214,20 @@ export type ItemGroupProps = MosaicComponentProps<'div'> & {
202214
* `default` keeps the rows on one continuous surface, inset by the group's own
203215
* gutter. `outline` gives each row a border and reads them as separate cards,
204216
* so the group drops its gutter and spaces the rows apart instead. Reaches the
205-
* rows through context rather than a prop on each one.
217+
* rows through context, so a row only needs its own `variant` to disagree with
218+
* the group.
206219
*
207220
* @default 'default'
208221
*/
209-
variant?: GroupVariant;
222+
variant?: Variant;
210223
};
211224

212225
/**
213226
* Vertical wrapper around a set of rows. Layout only; the rows carry their own
214227
* semantics. Provides its `variant` to the rows nested within it.
215228
*/
216229
const Group = React.forwardRef<HTMLDivElement, ItemGroupProps>(function MosaicItemGroup(
217-
{ variant = DEFAULT_GROUP_VARIANT, render, className, style, ...rest },
230+
{ variant = DEFAULT_VARIANT, render, className, style, ...rest },
218231
ref,
219232
) {
220233
const element = useRender({
@@ -263,8 +276,8 @@ const Separator = React.forwardRef<HTMLHRElement, MosaicComponentProps<'hr'>>(fu
263276
* `Item.Separator`. Every part takes a `render` prop and forwards a ref.
264277
*
265278
* `size` is set once on `Item.Root` and reaches `Item.Media` through context, so
266-
* a row scales as a unit rather than per part. `variant` is set once on
267-
* `Item.Group` and reaches its rows the same way.
279+
* a row scales as a unit rather than per part. `variant` is set on a row, or
280+
* once on `Item.Group` to reach every row in it.
268281
*/
269282
export const Item = {
270283
Root,

0 commit comments

Comments
 (0)