Skip to content

feat(DropdownMenuRadioItem): an item given children is a cell - #8193

Closed
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:menu-radio-cell
Closed

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:menu-radio-cell

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: the branch sits on main, so this diff is already its own.

User need

What led to it: this comes from moving every menu and picker in our app onto Astryx, which began with a press, slide and lift bug in its phone menus. Some of those menus set one choice from tiles, such as a colour from swatches.

Why: DropdownMenuRadioItem only draws a row with a radio mark, and its group context isn't exported, so an app that wants tiles rebuilds the radio item itself: roles, focus and painting.

Problem and solution fit

Given children, DropdownMenuRadioItem draws them as a cell instead of its row: no radio mark, named by label, with a 2px accent border when checked; only keyboard focus shows a ring. Selection, arrows and typeahead work as for rows. Two-dimensional arrows and data-mode items are out of scope.

Current specification

component:DropdownMenuRadioItem is a draft. This PR proposes a Presentation concept, FR9, AR5 and DEC-1, pending owner review, and scopes FR6, FR7, PR1 and AR1 to rows. #7319 also amends FR6.

Public delta and caller burden

  • New optional children; without them nothing changes. The caller draws, names (label) and lays out the cells.
<DropdownMenuRadioGroup
  value={color}
  onChange={setColor}
  label="Color"
  xstyle={styles.swatchGrid}>
  {colors.map(c => (
    <DropdownMenuRadioItem key={c.value} value={c.value} label={c.label}>
      <Swatch color={c.hex} />
    </DropdownMenuRadioItem>
  ))}
</DropdownMenuRadioGroup>

Design and alternatives

  • A grid variant of the radio group. It would own the layout, but cells still need caller content, and grids differ.
  • Export the group's context. Flexible, but every caller rebuilds role, focus and painting, the bug-prone part.
  • Children make the item a cell (chosen). The tile stays a radio item in the menu's one list, so focus, typeahead and presses keep working. The cost: arrows move through cells in one dimension, like rows.

Evidence

New cases in DropdownMenuRadioCell.test.tsx fail on main and pass here; RadioCell.a11y.chromium.spec.ts checks names, checked state, the border, the focus-ring rules, forced colors and axe in Chromium.

Writing rubric results

R1 narration: FR9, AR5, DEC-1 and the new rows are present tense.
R2 research: none.
R3 evidence: FR9 cites DEC-1; its verification row names the tests.
R4 rejected alternatives: two Rejected: lines in DEC-1.
R5 change references: none.
R6 open questions: none.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

In Storybook, open Core/DropdownMenu → Radio Cells and open "Customize".

  1. The colours show as swatches with no radio marks; pick one: it gets the accent border.
  2. Press the arrow keys: a focus ring follows. Move the mouse instead: no ring.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 26, 2026 7:42pm UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 1:31 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/ContextMenuItem 16.6KB 16.6KB 0B 5.2KB 5.2KB 0B
core/DropdownMenu 72.2KB 72.2KB 0B 14.6KB 14.6KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.1KB 0B 5.6KB 5.6KB 0B
core/DropdownMenuDivider 6.6KB 6.6KB 0B 3.6KB 3.6KB 0B
core/DropdownMenuGroup 6.1KB 6.1KB 0B 3.3KB 3.3KB 0B
core/DropdownMenuItem 15.4KB 15.4KB 0B 5.2KB 5.2KB 0B
core/DropdownMenuRadioGroup 6.0KB 6.0KB 0B 3.2KB 3.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.9KB +725B 5.6KB 5.7KB +162B
core/DropdownMenuSubMenu 57.5KB 57.5KB 0B 9.5KB 9.5KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.3KB 34.4KB +54B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,072
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form for menu radios; it can't be approved yet because FR24 and DEC-16 are new requirements in the draft component:DropdownMenu record, so an engineering owner needs to approve or amend them, including the no-target intentional: theming entry and ArrowLeft/ArrowRight keeping their sub-menu meanings inside a grid. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so. Whatever that decision, two fixes are needed:

  • The cell's focus ring follows the browser's :focus-visible, so after a mouse open, hovering a cell paints the keyboard ring, which in the default theme looks the same as the checked border; draw it only under keyboard modality, as the menu surfaces do (DropdownMenuRadioItem.tsx:261).
  • children != null turns a false or '' child into an empty cell that can still be focused and selected; isRenderable(children) avoids it (DropdownMenuRadioItem.tsx:238).

[Automated review]

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form of DropdownMenuRadioItem; both earlier findings are fixed on this head, but it can't be approved yet because FR9, AR5 and DEC-1 are new requirements in the draft component:DropdownMenuRadioItem record, together with the intentional: no-target "Radio cell" entry in component:DropdownMenu. An engineering owner needs to approve or amend them (the cell with no radio mark named by its label, the reserved 2px accent border, the keyboard-only ring, and arrow keys in DOM order with ArrowLeft and ArrowRight keeping their sub-menu meanings) by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so. Whatever that decision, one fix is needed:

  • isRenderable(children) is shallow, so children that render nothing through an array ({a && <A />}{b && <B />} with both false, or an empty .map()) still make an invisible cell that keyboard focus lands on and Enter selects; check Children.toArray(children).some(isRenderable) or narrow the documented rule (DropdownMenuRadioItem.tsx:276).

[Automated review]


// Children that render make the option a cell; `false`, `''` and the like
// leave it a row.
if (isRenderable(children)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

isRenderable is shallow, so children that render nothing through an array still make a cell: {a && <A />}{b && <B />} with both false, an empty .map() or [false] each render an 8px cell holding only the hidden label, which ArrowDown lands on and Enter selects. The docs and FR9 promise that children that render nothing leave the row. Children.toArray(children).some(isRenderable) drops empty nodes inside arrays; a fragment or a component returning null still can't be detected, so the docs should say so.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form of DropdownMenuRadioItem; it can't be approved yet because FR9, AR5 and DEC-1 are new requirements in the draft component:DropdownMenuRadioItem record, together with the intentional: no-target "Radio cell" entry in component:DropdownMenu, and no engineering owner has approved them on this revision. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so. Whatever that decision, two fixes are needed:

  • isRenderable(children) is shallow, so children that render nothing through an array ({a && <A />}{b && <B />} with both false, or an empty .map()) still make an invisible 8px cell that the arrow keys land on and Enter selects; check Children.toArray(children).some(isRenderable) and say in FR9 and the docs that a fragment or a component returning null still makes a cell (DropdownMenuRadioItem.tsx:276).
  • AR5 says focus a pointer moved there paints no ring, but after a key press the ring follows mouse-moved focus, as architecture:interaction-modality INV1 requires, so narrow AR5 to focus moved after pointer input, as DEC-1 already words it (DropdownMenuRadioItem.spec.md:192-194).

[Automated review]

Comment on lines +192 to +194
colors. The shared focus ring paints outside it only for focus the keyboard
moved there; focus a pointer moved there paints none, the browser's ring
included (`architecture:interaction-modality` INV1).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

After a key press, hover moves focus with the last input, so a cell the mouse moves to keeps the ring (:focus-visible matches, outline solid in Chromium), as architecture:interaction-modality INV1 requires ("after keyboard input it does"). "Focus a pointer moved there paints none" is only true after a pointer press; DEC-1 already says "after a pointer open". Narrowing this sentence to focus moved after pointer input keeps AR5 consistent with INV1 and with the code.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form of DropdownMenuRadioItem; it can't be approved yet because FR9, AR5 and DEC-1 are new requirements in the draft component:DropdownMenuRadioItem record, together with the relaxed PR1, the row-scoped FR6, FR7 and AR1, and the intentional: "Radio cell" entry in component:DropdownMenu, and no engineering owner has approved them on this revision. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so. Whatever that decision, two fixes are needed:

  • isRenderable(children) is shallow, so children that render nothing through an array ({a && <A />}{b && <B />} with both false, or an empty .map()) still make an empty 8px cell that the arrow keys focus and Enter selects; Children.toArray(children).some(isRenderable) and a test for those shapes would match FR9 and the docs.
  • AR5 says focus a pointer moved there paints no ring, but after a key press the ring correctly shows on the cell the mouse moves to, as architecture:interaction-modality INV1 requires and the code does; AR5 and the component docblock should say "after pointer input", as DEC-1 already does.

[Automated review]

A one-of choice drawn as tiles in a menu (colour swatches, icons, numbered slots) had no Astryx form: DropdownMenuRadioItem always draws a full-width row with a radio mark, and its group context is not exported, so a caller's tile could not join the selection. Given children that render, the item now draws them as a cell instead of its row: one menuitemradio element with aria-checked, no radio mark and no row slots, named by its label through a visually hidden element it references with aria-labelledby, so the name stays the label when the tile shows text of its own. Children that render nothing (false, '') leave the row. The checked cell carries a 2px accent border that every cell reserves (transparent when unchecked; Highlight against Canvas under forced colors, which drop shadows). The shared focus ring paints outside it only for focus the keyboard moved there: focus the menu moves to the cell under the mouse after a pointer open paints none, not even the browser's ring, as the menu surfaces gate theirs on the last input. Cells keep the row's selection, close policy, handler order (caller onClick and onPointerMove run after the row's own), hover focus, press model, DOM-order arrow keys and typeahead; the caller lays them out with a grid on the group or on a wrapper inside it. icon, description and endContent are not shown in a cell and warn in development there. The cell is its own internal component, so the row keeps no state or Effect. ContextMenuRadioItem and BreadcrumbMenuRadioItem are the same component. Proposed FR9, AR5 and DEC-1 of component:DropdownMenuRadioItem record it, with a Presentation concept, a Cell state and design row, and the record's row-only facts (FR6, FR7, PR1, AR1) scoped to rows; component:DropdownMenu, which owns the target inventory, lists the Radio cell anatomy part with no target. A RadioCells story and a Chromium spec check the names in Chromium's accessibility tree, the accent and forced-colors paint, no ring for a cell hovered after a pointer open and the ring after ArrowDown, the arrow keys and Enter, and axe on the menu and on a sub-menu whose tiles sit in a wrapper grid; jsdom cases cover drawing, non-rendering children, selection, keys, typeahead, the keyboard-only ring, a wrapper, a disabled cell and the warning.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form of DropdownMenuRadioItem; it can't be approved yet because FR9, AR5 and DEC-1 are new requirements in the draft component:DropdownMenuRadioItem record, together with the relaxed PR1, the row-scoped FR6, FR7 and AR1, and the intentional: "Radio cell" entry in component:DropdownMenu, and no engineering owner has approved them on this revision. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so. Whatever that decision, these need fixing:

  • isRenderable(children) is shallow, so children that render nothing through an array ({a && <A />}{b && <B />} with both false, or an empty .map()) still make an empty 8px cell that the arrow keys focus and Enter selects; Children.toArray(children).some(isRenderable) with a test for those shapes, and docs that say a fragment or a component returning null still makes a cell, would match FR9.
  • AR5 says focus a pointer moved there paints no ring, but after a key press the ring correctly shows on the cell the mouse moves to, as architecture:interaction-modality INV1 requires and the code does; AR5, its verification row and the component docblock should say "after pointer input", as DEC-1 does.
  • Under forced colors the story's swatches disappear (each is a background colour and an unchecked cell's border is Canvas), leaving only the checked and focused cells visible; forcedColorAdjust: 'none' on the swatch and a docs note for background-colour tiles would fix the example.

[Automated review]

borderRadius: '50%',
},
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).

vjeux added 2 commits October 26, 2026 12:07
…ve a row, AR5 says after pointer input, and the story's swatches keep their colour under forced colors

Three points from the review on 582007e. isRenderable(children) was shallow, so an array that renders nothing ({a && <A />}{b && <B />} with both false, or an empty .map()) made an empty cell the arrow keys reached; a local rendersContent now counts an array only through an item that renders (React.Children is banned by no-react-introspection, so it walks the array itself), and an element always counts. The row case gains both array shapes (it fails on the previous head), a new case pins that a fragment or a component returning null is still a cell, and the docs (en, zh, dense) say so. AR5, its verification row, the docblock and the RadioCell comment now say focus after keyboard input paints the ring, also where the mouse then moves it, and focus after pointer input none; the keyboard-ring case adds that hover-moved step. The story's background-colour swatches take forcedColorAdjust: 'none', the children docs tell callers so, and the Chromium forced-colors case checks a swatch keeps its colour (white Canvas without it).

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the tile form of DropdownMenuRadioItem; the three fixes from the last round hold on this revision and no code change is needed. Approval now waits only on the spec decision: FR9, AR5 and DEC-1 are new requirements in the draft component:DropdownMenuRadioItem record, together with the relaxed PR1, the row-scoped FR6, FR7 and AR1, and the intentional: "Radio cell" entry in component:DropdownMenu, and an engineering owner needs to approve or amend them. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner the author may do so.

[Automated review]

@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

I'm pausing this work for now to reduce contention on the repo. Thank you for the reviews and the spec guidance.

@vjeux vjeux closed this Oct 30, 2026

This branch was successfully deployed

1 active deployment
Preview — 0e939265 Deployed Oct 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants