Skip to content

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

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

vjeux wants to merge 3 commits into
facebook: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

A menu often sets one choice from tiles: a colour from swatches, a slot from numbered cells. Each is a menuitemradio, but DropdownMenuRadioItem always draws a row with a radio mark and its group context is not exported, so apps rebuild the radio item beside Astryx's.

Problem and solution fit

  • Primary: children on DropdownMenuRadioItem: given children that render, the item draws them as a cell instead of its row, with no radio mark, named by label through a visually hidden element; false or '' leaves the row.
  • Supporting: the checked cell gets a 2px accent border every cell reserves (Highlight under forced colors); keyboard focus, never mouse-moved focus, adds the shared ring. Selection, handlers, presses, arrows and typeahead are the row's; row-only props warn.
  • Out of scope: two-dimensional arrow keys (ArrowLeft/ArrowRight keep sub-menu meanings); data-mode items.

Current specification

component:DropdownMenuRadioItem (draft) gains a Presentation concept, FR9, AR5 and DEC-1 (pending owner review) and scopes FR6, FR7, PR1 and AR1 to rows; component:DropdownMenu lists a Radio cell part with no target. #7319 also amends FR6. Overlap search ("radio cell", "tile", "swatch", "grid"): only #7517, forced-colors focus paint.

Public delta and caller burden

  • New optional children; omitted, nothing changes. The caller draws, names (label) and lays out 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

  • The tile stays a radio item, in the menu's one list for focus, typeahead and presses.
  • Rejected: a grid variant of the group (it still needs caller content); exporting the group's context (every caller rebuilds role, focus and paint).

Evidence

  • DropdownMenuRadioCell.test.tsx: drawn, named, no mark, checked paint; non-rendering children; click, arrows, typeahead; keyboard-only ring; disabled; the warning.
  • RadioCell.a11y.chromium.spec.ts: Chromium's names and checked state; the accent border; no ring on a cell hovered after a pointer open, a ring after ArrowDown; forced colors; Enter; axe, with a wrapper grid.

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

  • Review round (0e93926: main 0aec0ee merged in, plus one commit): children that are an array of nothing ({a && <A />}{b && <B />} both false, an empty .map()) leave a row, through a local array walk (React.Children is banned by no-react-introspection); an element, even a fragment or a component returning null, is still a cell, as the docs now say. AR5 and its docs say "after pointer input"; the story's swatches take forcedColorAdjust: 'none', and the forced-colors Chromium case checks one keeps its colour. The row case fails on the previous head, the swatch case on the previous story. DropdownMenu and CLI suites: 651 tests; the 26 Chromium specs CI selects (153 cases); native types, typecheck, lint:strict, check:knowledge and the Storybook build pass.
  • Rebased onto main 71601aa (582007e): main's ci: discover PR browser accessibility specs, so a new spec needs no workflow edit #8367 discovers pull-request browser specs, so this PR's ci.yml step is gone and RadioCell.a11y.chromium.spec.ts runs because the PR changes it. DropdownMenu and CLI suites: 650 tests; the 26 Chromium specs CI selects (153 cases); native types, typecheck, lint:strict, check:knowledge and the Storybook build pass.
    Rebased onto main 10ca8ea (97057c8), patch unchanged: on 3db817d the build-storybook job built and then failed to upload its artifact (ECONNRESET), and build, pr-rtl and pr-rtl (core) failed for want of it. The suites and Chromium specs below pass on this head again.

Rebased onto main 7c45ff3 (3db817d), patch unchanged: the pr-a11y step sits beside main's menu hover focus step (#8221). DropdownMenu, ContextMenu, Breadcrumbs, MoreMenu and cli suites: 788 tests; RadioCell, radio item audit and menu hover focus Chromium specs: 37; typecheck, eslint (strict), check:knowledge and the Storybook build pass.

Review round (rebased onto main 9effa5c): main added component:DropdownMenuRadioItem (#8191), so the cell's rules move there from component:DropdownMenu (FR24 and DEC-16 become FR9, AR5 and DEC-1), and the cell composes caller onClick and onPointerMove after its own, as #8191's rows do. A cell needs children that render (isRenderable), so {show && <Swatch />} with show false stays a row. The ring now marks only focus the keyboard moved there, as the menu surfaces gate theirs, and the cell suppresses the browser's own ring; the cell is an internal component, so rows keep no state or Effect. With the previous DropdownMenuRadioItem.tsx, the non-rendering and keyboard-ring cases fail, and Chromium's hover case reads a solid outline.

vitest DropdownMenu, ContextMenu, Breadcrumbs, MoreMenu, cli   Tests 786 passed
playwright RadioCell, DropdownMenuRadioItemAudit, DropdownMenuRadioGroupAudit, MenuPress, Menu --project=chromium   56 passed
typecheck, lint:strict, check:knowledge, storybook:build   ✓

Red on main (DropdownMenuRadioItem.tsx from main, same tests):
vitest DropdownMenuRadioCell.test.tsx   3 failed (drawing, keyboard ring, warning), 5 passed
playwright RadioCell   3 failed (accent border; ring after ArrowDown; forced colors), 1 passed

Red on the previous head (66e41bb9ad's DropdownMenuRadioItem.tsx):
vitest DropdownMenuRadioCell.test.tsx   2 failed (non-rendering children, keyboard ring)
playwright RadioCell   1 failed (hovered cell after a pointer open: outline solid, expected none)

@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 26, 2026, 8:03 PM 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.


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]

Comment thread packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx Outdated
Comment thread packages/core/src/DropdownMenu/DropdownMenuRadioItem.tsx Outdated

@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]

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.

1 participant