Skip to content

feat: a single-choice set of selectable cards is a radio group (lab SelectableCardGroup) - #8260

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c06-card-radio
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c06-card-radio

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review only this PR's change: vjeux/astryx@8c3f3be...dbb7db1.

User need

A plan picker built from SelectableCards is a single choice, but each card is a checkbox: no position is spoken, every card is a tab stop, and arrows do nothing. A radio group has one tab stop, arrows that move and select, and "2 of 3" spoken.

  1. Why: SelectableCard always renders a checkbox.
  2. Why: nothing groups the cards or names the set.

Evidence

iOS 26.2 Mobile Safari: a tap moves the choice from Pro to Team.

SelectableCardGroup tap

Keyboard, Chromium (captions read the page's focus and selection). Before: 3 checkboxes, 3 tab stops. After: 3 radios, 1 tab stop, arrows move the choice and wrap:

Before After
Keyboard before Keyboard after

Problem and solution fit

  • SelectableCard takes inputType="radio" and an htmlName, as RadioList does. Its hidden input becomes a radio in that group, so the browser supplies the tab stop, arrow keys and position. A radio card never deselects itself; Enter selects as Space does. A disabled card submits nothing (AST-085 FR3).
  • SelectableCardGroup (lab) wraps the set: role="radiogroup" named by a visible Field label, with value/onChange; container* styles its Field box (input-fields FR10). selectionMode="multiple" gives a named group of checkboxes.

Current specification

Public delta and caller burden

  • Core: two optional SelectableCard props; the checkbox default leaves existing cards unchanged.
  • Lab: SelectableCardGroup and SelectableCardGroupItem. Callers pass a value per card and own the state.

Design and alternatives

  • Native radios, not a scripted roving tabindex: browsers and screen readers agree on keys and position.
  • Rejected: role="radio" on the card div (duplicating the input's focus and keyboard).
  • Not RadioList or CheckboxList: their children are their own rows (RadioListItem, CheckboxListItem, bound through the list's context); a card is its own surface with free content, often in a grid, so hosting cards would add a second item anatomy.
  • If SelectableCardGroup graduates, SelectableCard's manual grouping (inputType + htmlName) is deprecated in a follow-up (AST-017 lifecycle; architecture:public-component-api INV9).
  • Not here: the optional circle mark under a card (U22), which waits on CircleCheckIndicator (feat(lab): CircleCheckIndicator, the iOS multiple-selection circle #8255).

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

Main 8c3f3be (with #8419) merged in; the results below are from the merged head.

  • pnpm vitest run SelectableCard, lab SelectableCardGroup and Card: 44 pass; build-css 12/12. New: a disabled group's form data is empty while its radios keep one name; an enabled group submits its choice under htmlName; container* styles the Field box, not the group (all three failed on the previous head). SelectableCardRadio.test.tsx: a radio card is a native radio in the named group, a click selects and unchecks the others, the chosen card never deselects (click or Enter), Enter selects, a disabled radio card stays focusable and selects nothing, the default stays a checkbox. 5 of 6 fail without the change. SelectableCardGroup.test.tsx: a named, described radiogroup of radios sharing one name; choosing moves the value; the chosen card again changes nothing; a disabled group; multiple is a named group of checkboxes that toggle; an item outside a group throws. Full lab suite: 840 pass.
  • pnpm test:a11y-contract-style Chromium spec SelectableCardRadio.a11y.chromium.spec.ts (found by a11y-pr-contracts.mjs, scoped to SelectableCard; no workflow step): 3/3. It checks the named radiogroup of native radios, one tab stop on the checked card (Tab out, Shift+Tab back), Right/Left moving and selecting with wrapping, Space, and a click that keeps the choice. The CI routing, shard and scope tests pass (45).
  • Lab typecheck:docs passes (it failed before the second commit: the Chinese doc had no usage). pnpm build; the ShadCN registry test (29/29, route lock included); the whole CLI node suite run one file at a time (9514 pass, 3 skipped; in parallel on a loaded box some files hit worker errors, and each passes alone); check-changesets; core typecheck; lab typecheck except one error that is also on main (MobileTokenizer.test.tsx); eslint, prettier.
  • iOS 26.2 simulator: this branch's build, real taps, a 60 fps recording with a page-drawn touch 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 25, 2026 11:05pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. 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:42 PM UTC

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

Approval waits on an engineering owner providing or identifying the governing spec for SelectableCard's new stable inputType/name props and the lab SelectableCardGroup built on them: no current SelectableCard record covers them, so what must be settled is whether a single-choice card set becomes a native radio group through these props, recorded in a SelectableCard component record that names the adopted radio-group parts (AST-002 DEC-5, AST-020 FR15). An engineering owner records that decision by commenting /approve-spec b7e0cb67d92da148bfb7838034529f00d0350f78 on this pull request, and the author may do so if they are an engineering owner. The radio behavior itself checks out in Chromium (one tab stop on the chosen card, arrow keys that move and select), and these fixes are needed whatever the decision:

  • A disabled card set still submits <name>=on with its form, so detach the input while disabled (form="", as RadioListItem does) to meet AST-085 FR3.
  • SelectableCardGroup composes Field, so input-fields FR10 asks for container* styling props in place of root xstyle/className/style.
  • The native input name should be htmlName, the prop RadioList, CheckboxInput and the other core inputs use, rather than name.
  • pnpm -F @astryxdesign/lab typecheck:docs fails because docsZh in SelectableCardGroup.doc.mjs has no usage block.

[Automated review]

ref={interactiveRef}
type="checkbox"
type={isRadio ? 'radio' : 'checkbox'}
name={name}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A disabled card keeps this name, so a checked disabled card still submits <name>=on with its form (AST-085 FR3: a disabled field contributes no value). Detaching the input while disabled keeps the radio grouping: form={isDisabled ? '' : undefined}, as RadioListItem does.

/**
* The native input's `name`. Radio cards that share it form one group.
*/
name?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Every core input that forwards the native input name calls it htmlName (RadioList, CheckboxInput, Switch, TextInput and others). Calling it name here gives the same pass-through a second name (public-component-api INV2), and renaming after release would be breaking.

onChange: (value: string[]) => void;
}

export type SelectableCardGroupProps = Omit<BaseProps, 'onChange'> &

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This component composes Field, so input-fields FR10 applies: no root xstyle/className/style (a type error), and containerXstyle/containerClassName/containerStyle reach the Field box. Omit<BaseProps, 'onChange' | 'xstyle' | 'className' | 'style'> plus container* props would conform.

};

/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */
export const docsZh = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

docsZh is typed as ComponentDoc but has no usage, so pnpm -F @astryxdesign/lab typecheck:docs, and with it CI's typecheck job, fails with TS2322. Add a translated usage block, as the docsZh of InfoTip and Stat do, or type it as ComponentTranslationDoc.

@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

New Components

SelectableCardGroup (@astryxdesign/lab) · View in Storybook
Metric Value
Entry File (ESM) 205B
Lines of Code 202
Source Files 2
Complexity Medium (15)
Exports SelectableCardGroup, SelectableCardGroupItem
Props Count N/A
Has Tests Yes
Has Stories Yes

Modified Components

SelectableCard (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 108B 108B 0B
Lines of Code 305 322 -
Complexity Very High Very High (38) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/SelectableCard 12.6KB 12.8KB +117B 7.0KB 7.0KB 0B
lab/SelectableCardGroup N/A 50.5KB N/A N/A 162B N/A

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
@astryxdesign/lab 4.9KB 1.9KB 2.0KB +93B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 33.8KB 33.8KB 0B
@astryxdesign/lab 11.1KB 11.1KB 0B

Accessibility Audit

Status: 2 accessibility violation(s) found — 2 serious.

Checkbox pattern - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/3 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
SelectableCard - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/5 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Visual Regression

48 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 556,900
RichTextEditor Markdown parity: side by side probe light 538,816
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 28 more.


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

vjeux added 2 commits October 24, 2026 16:20
…electableCardGroup)

SelectableCard takes inputType="radio" and a name: its hidden input becomes a
native radio in that named group, so the set is one tab stop, the arrow keys
move and select with wrapping, and the position is spoken. A radio card
selects and never deselects itself; Enter selects as Space does. The default
stays checkbox.

The lab SelectableCardGroup wraps a set: role="radiogroup" named and
described by its Field label, with value/onChange for one value, or a named
group of checkboxes with selectionMode="multiple".
…s htmlName, the group styles its Field box

- A disabled card kept its input in the form, so a checked disabled card
  submitted `<name>=on` (spec:AST-085 FR3). Its input is now detached while
  disabled (`form=""`, as RadioListItem does); the name keeps the radio
  grouping.
- The native name prop is `htmlName`, as on RadioList, CheckboxInput and the
  other core inputs (public-component-api INV2), on SelectableCard and on
  the lab SelectableCardGroup.
- SelectableCardGroup composes Field, so it takes `containerXstyle`,
  `containerClassName` and `containerStyle` for the Field box and no root
  xstyle, className or style (input-fields FR10).
- The group's Chinese doc has its `usage` block, so the lab's
  `typecheck:docs` passes.

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

Approval waits on an engineering owner deciding SelectableCard's new stable inputType/htmlName props and the lab SelectableCardGroup built on them: no current SelectableCard record covers them, so what must be settled is whether a single-choice card set becomes a native radio group through these props, recorded in a SelectableCard component record that names the adopted radio-group parts (AST-002 DEC-5, AST-020 FR15). An engineering owner records that decision by commenting /approve-spec 96e402d37dc2977b1fbfbb5b97713afac423184f on this pull request, and the author may do so as an engineering owner. The earlier disabled-submission, styling, naming and docs-typecheck points are fixed and the radio keyboard behaves as described in Chromium; these points remain whatever the decision:

  • Inside a <form>, form="" gives a per-card disabled radio a different form owner from its siblings, so it becomes a second tab stop and ArrowRight skips it (AST-085 FR1); keeping the whole set on one form owner fixes it.
  • A form receives <htmlName>=on for every card, although the test "an enabled group submits its choice under htmlName" and the docs suggest the choice is submitted.
  • SelectableCardGroup drops root styling and applies container* in every layout without the two development warnings input-fields FR10 asks for.
  • SelectableCard.doc.mjs still tells callers to give the set one name, and that file now conflicts with main (#8057), so a rebase is needed.

[Automated review]

// Detached from its form while disabled, so it neither submits nor
// blocks submission (spec:AST-085 FR3); the name keeps the radio
// grouping.
form={isDisabled ? '' : undefined}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Inside a <form>, form="" gives a per-card disabled radio no form owner while its siblings keep the form, and HTML groups radios by name and form owner. In Chromium a set with Pro disabled then takes two tab stops (Tab from Basic lands on Pro) and ArrowRight from Basic skips Pro, against AST-085 FR1 (a disabled control keeps the focus order it has when enabled). Keeping the whole set on one form owner preserves the grouping, for example by having the group detach every radio and submit the chosen value through one hidden input.

name: 'inputType',
type: "'checkbox' | 'radio'",
description:
'The native control inside the card. A radio card is one choice of a set: it selects and never deselects itself. Give the set one `name` and put it in a role="radiogroup" with an accessible name; the browser then makes it one tab stop, arrow keys move and select, and "2 of 3" is spoken.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This still says name; the prop is htmlName. An untyped name never reaches the input, so each card would be a group of one.

if (form == null) {
throw new Error('no form');
}
expect([...new FormData(form).entries()]).toEqual([['plan', 'on']]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This asserts on, so a form cannot tell which card was chosen, while the test title says the group submits its choice. RadioListItem passes its value to the input; either carry the card's value, or retitle the test and say in the docs that htmlName only groups the radios.

const descriptionID = useId();
const generatedName = useId();
const name = htmlName ?? generatedName;
// An untyped caller's root styling never reaches the group element.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

input-fields FR10 also asks for a development warning when an untyped caller passes root xstyle/className/style, and when container* is passed where no Field box renders (a horizontal-labels FormLayout or InputGroup). Core's useFieldInputStyling does both for every core Field-composed input; useDevWarning from @astryxdesign/core/hooks would cover it here.

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

Approval waits on an engineering owner deciding SelectableCard's new stable inputType/htmlName props and the lab SelectableCardGroup built on them. No current SelectableCard record covers them, so what must be settled is whether a single-choice card set becomes a native radio group through these props, recorded in a SelectableCard component record that names the adopted radio-group parts (AST-002 DEC-5, AST-020 FR15). An engineering owner records that decision by commenting /approve-spec 9db2b390dba681804c6b0390c5c4959bbbf36545 on this pull request, and the author may do so as an engineering owner. The radio keyboard behaves as described in Chromium, and these points remain whatever the decision:

  • Inside a <form>, form="" gives a card disabled on its own a different form owner from its siblings, so it becomes a second tab stop and ArrowRight skips it (AST-085 FR1); keeping the whole set on one form owner fixes it.
  • A form receives <htmlName>=on for every card, although the test "an enabled group submits its choice under htmlName" and the docs ("as htmlName on RadioList") say the choice is submitted.
  • SelectableCardGroup drops untyped root styling and forwards container* in every layout without the two development warnings input-fields FR10 asks for.
  • SelectableCard.doc.mjs still tells callers to give the set one name, but the prop is htmlName.

[Automated review]

# Conflicts:
#	.github/workflows/ci.yml
#	packages/core/src/SelectableCard/SelectableCard.doc.mjs
#	packages/core/src/SelectableCard/SelectableCard.tsx

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

Approval waits on an engineering owner deciding SelectableCard's new stable inputType/htmlName props and the lab SelectableCardGroup built on them: no current SelectableCard record covers them, so what must be settled is whether a single-choice card set becomes a native radio group through these props, recorded in a SelectableCard component record that names the adopted radio-group parts (AST-002 DEC-5, AST-020 FR15). An engineering owner records that decision by commenting /approve-spec dbb7db18331d1a0cac190cb943e9accd5aac25b3 on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these points need fixing:

  • Inside a <form>, form="" gives a card disabled on its own a different form owner from its siblings, so it becomes a second tab stop and ArrowRight skips it (AST-085 FR1).
  • A form receives <htmlName>=on for every card, while the test "an enabled group submits its choice under htmlName" and the docs' comparison with RadioList suggest the choice is submitted.
  • SelectableCard.doc.mjs still says not to use the card for single-select, next to the new inputType: 'radio' entry.
  • SelectableCardGroup replaces a caller's aria-labelledby/aria-describedby, drops untyped root styling silently, and forwards container* in every layout, where input-fields FR10 asks it to keep the caller's ids and to warn in development.

[Automated review]

{name: 'isSelected', type: 'boolean', description: 'Controlled selection state.', required: true},
{name: 'onChange', type: '(isSelected: boolean) => void', description: 'Called when toggled.', required: true},
{name: 'onChange', type: '(isSelected: boolean) => void', description: 'Called when toggled. A radio card only calls it with true.', required: true},
{name: 'inputType', type: "'checkbox' | 'radio'", description: "The native control inside the card. 'radio' makes the card one choice of a single-select set; SelectableCardGroup (lab) builds that set and documents it.", default: "'checkbox'"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This entry makes a radio card "one choice of a single-select set", but the same doc still lists "Use for a single-select group: each card is a checkbox…" as a Don't (lines 17 and 75) and describes a card that toggles (lines 11 and 15); astryx component SelectableCard --dense prints both on one page. If the props are accepted, replace the Don't with when to use inputType="radio" or SelectableCardGroup, and say that a radio card selects without toggling off, in docs and docsDense.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:35

This branch was successfully deployed

1 active deployment
Preview — dbb7db18 Deployed Oct 25, 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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant