Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/dropdown-menu-radio-cell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] DropdownMenu: a `DropdownMenuRadioItem` given children that render is a cell. The children are the tile (a colour swatch, an icon, a numbered slot) instead of the row, with no radio mark, named by `label`; the checked cell carries an accent border, and keyboard focus adds the focus ring. Cells keep the row's role, focus and selection, so the arrow keys and typeahead reach them in DOM order; lay them out with a grid on the group or on a wrapper inside it. `ContextMenuRadioItem` and `BreadcrumbMenuRadioItem` are the same component and take children too.

@vjeux
120 changes: 120 additions & 0 deletions apps/storybook/stories/DropdownMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,126 @@ export const LabRadioGroup: Story = {
},
};

// Radio cells: a radio item given children is a cell, laid out by the caller.
const radioCellStyles = stylex.create({
swatchGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(4, max-content)',
gap: spacingVars['--spacing-1'],
},
swatch: {
display: 'block',
width: 24,
height: 24,
borderRadius: '50%',
// A tile drawn as a background colour keeps it under forced colors.
forcedColorAdjust: 'none',
},
swatchColor: (color: string) => ({
backgroundColor: color,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under forced colors this background is replaced, and an unchecked cell's border is Canvas, so every unchecked swatch disappears: in Chromium with forced-colors emulation only the checked cell's Highlight border and the focused cell's ring are visible. forcedColorAdjust: 'none' here keeps the swatch colours, and a docs line could tell callers that a tile drawn as a background colour needs it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 0e93926 the story's swatches take forcedColorAdjust: 'none', the children docs tell callers that a tile drawn as a background colour needs it, and the forced-colors Chromium case checks that an unchecked swatch keeps its colour (it reads white Canvas on the previous story).

}),
tileGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: spacingVars['--spacing-1'],
},
tile: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: spacingVars['--spacing-0-5'],
paddingBlock: spacingVars['--spacing-1'],
},
tileIcon: {
width: 20,
height: 20,
},
});

const RADIO_CELL_SWATCHES = [
{value: 'red', label: 'Red', color: '#e5484d'},
{value: 'orange', label: 'Orange', color: '#f76b15'},
{value: 'green', label: 'Green', color: '#30a46c'},
{value: 'blue', label: 'Blue', color: '#0090ff'},
{value: 'purple', label: 'Purple', color: '#8e4ec6'},
{value: 'pink', label: 'Pink', color: '#d6409f'},
{value: 'teal', label: 'Teal', color: '#12a594'},
{value: 'gray', label: 'Gray', color: '#8b8d98'},
];

const RADIO_CELL_TILES = [
{value: 'pencil', label: 'Pencil', Icon: PencilIcon},
{value: 'share', label: 'Share', Icon: ShareIcon},
{value: 'archive', label: 'Archive', Icon: ArchiveBoxIcon},
{value: 'person', label: 'Person', Icon: UserIcon},
{value: 'gear', label: 'Gear', Icon: Cog6ToothIcon},
{value: 'search', label: 'Search', Icon: MagnifyingGlassIcon},
];

export const RadioCells: Story = {
render: function RadioCellsStory() {
const [color, setColor] = useState('blue');
const [icon, setIcon] = useState('default');
return (
<DropdownMenu button={{label: 'Customize'}}>
<DropdownMenuRadioGroup
value={color}
onChange={setColor}
label="Color"
xstyle={radioCellStyles.swatchGrid}>
{RADIO_CELL_SWATCHES.map(swatch => (
<DropdownMenuRadioItem
key={swatch.value}
value={swatch.value}
label={swatch.label}>
<span
aria-hidden="true"
{...stylex.props(
radioCellStyles.swatch,
radioCellStyles.swatchColor(swatch.color),
)}
/>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuDivider />
<DropdownMenuSubMenu label="Tab icon">
<DropdownMenuRadioGroup
value={icon}
onChange={setIcon}
label="Tab icon"
hasCloseOnSelect={false}>
<DropdownMenuRadioItem value="default" label="Default icon">
<span>Default icon</span>
</DropdownMenuRadioItem>
<div {...stylex.props(radioCellStyles.tileGrid)}>
{RADIO_CELL_TILES.map(({value, label, Icon}) => (
<DropdownMenuRadioItem key={value} value={value} label={label}>
<span {...stylex.props(radioCellStyles.tile)}>
<Icon
aria-hidden="true"
{...stylex.props(radioCellStyles.tileIcon)}
/>
{label}
</span>
</DropdownMenuRadioItem>
))}
</div>
</DropdownMenuRadioGroup>
</DropdownMenuSubMenu>
</DropdownMenu>
);
},
parameters: {
docs: {
description: {
story:
'A DropdownMenuRadioItem given children is a cell: the children are the tile, with no radio mark, the label names it, and the checked cell carries an accent border. The caller lays cells out: here the colour group is a grid, and the tab-icon tiles sit in a grid inside their group, after a cell drawn as a row. The arrow keys move through cells in DOM order, as through rows.',
},
},
},
};

export const LabSelectableSizes: Story = {
render: function LabSelectableSizesStory() {
const [sm, setSm] = useState('a');
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/DropdownMenu/DropdownMenu.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ const anatomy = [
description:
'Decorative shared radio indicator with an additional menu-owned target.',
},
{
name: 'Radio cell',
required: false,
description:
'A radio item given children: the caller content as a tile, named by its label, with an accent border when checked.',
},
{
name: 'Pointer section heading',
required: false,
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/DropdownMenu/DropdownMenu.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ visibility helper.
| Caller-rendered item start content | Presents arbitrary React content directly in an action-row start slot. | Caller-supplied content | Context-dependent | FR1, FR3 |
| Checkbox indicator | Draws the decorative checkbox state for a checkbox action row. | `component:Indicator` | Supporting | FR1, FR3 |
| Radio indicator | Draws shared radio chrome with the current menu-specific radio target. | Current source and Indicator | Supporting | FR1, FR2, FR3 |
| Radio cell | Draws caller content as a tile; the checked cell has an accent border. | DropdownMenuRadioItem DEC-1 | Context-dependent | RadioItem FR9 |
| Pointer section heading | Labels a grouped set of pointer action rows. | Current source and public docs | Supporting | FR1, FR2 |
| Pointer divider | Separates groups of pointer action rows. | Current source and public docs | Supporting | FR1, FR2 |
| Pointer submenu indicator icon | Identifies a pointer action row that opens a nested flyout. | Current source and public docs | Supporting | FR1, FR2 |
Expand Down Expand Up @@ -280,6 +281,11 @@ than adding a DropdownMenu-owned heading target.
}
},
"Radio indicator": {"target": "dropdown-menu-radio"},
"Radio cell": {
"none": {
"reason": "intentional: The tile is caller content; its checked border reads the accent token and its focus ring the shared focus tokens, so it adds no DropdownMenu target."
}
},
"Pointer section heading": {"target": "dropdown-menu-section-heading"},
"Pointer divider": {"target": "dropdown-menu-divider"},
"Pointer submenu indicator icon": {
Expand Down
Loading
Loading