Repository navigation
feat: a single-choice set of selectable cards is a radio group (lab SelectableCardGroup) - #8260
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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>=onwith its form, so detach the input while disabled (form="", asRadioListItemdoes) to meet AST-085 FR3. SelectableCardGroupcomposesField, so input-fields FR10 asks forcontainer*styling props in place of rootxstyle/className/style.- The native input name should be
htmlName, the prop RadioList, CheckboxInput and the other core inputs use, rather thanname. pnpm -F @astryxdesign/lab typecheck:docsfails becausedocsZhinSelectableCardGroup.doc.mjshas nousageblock.
[Automated review]
| ref={interactiveRef} | ||
| type="checkbox" | ||
| type={isRadio ? 'radio' : 'checkbox'} | ||
| name={name} |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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'> & |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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.
PR Analysis Report🔎 Preview ·
|
…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.
There was a problem hiding this comment.
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>=onfor every card, although the test "an enabled group submits its choice under htmlName" and the docs suggest the choice is submitted. SelectableCardGroupdrops root styling and appliescontainer*in every layout without the two development warnings input-fields FR10 asks for.SelectableCard.doc.mjsstill tells callers to give the set onename, and that file now conflicts withmain(#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} |
There was a problem hiding this comment.
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.', |
There was a problem hiding this comment.
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']]); |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
96e402d to
9db2b39
Compare
There was a problem hiding this comment.
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>=onfor every card, although the test "an enabled group submits its choice under htmlName" and the docs ("ashtmlNameon RadioList") say the choice is submitted. SelectableCardGroupdrops untyped root styling and forwardscontainer*in every layout without the two development warnings input-fields FR10 asks for.SelectableCard.doc.mjsstill tells callers to give the set onename, but the prop ishtmlName.
[Automated review]
# Conflicts: # .github/workflows/ci.yml # packages/core/src/SelectableCard/SelectableCard.doc.mjs # packages/core/src/SelectableCard/SelectableCard.tsx
There was a problem hiding this comment.
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>=onfor 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.mjsstill says not to use the card for single-select, next to the newinputType: 'radio'entry.SelectableCardGroupreplaces a caller'saria-labelledby/aria-describedby, drops untyped root styling silently, and forwardscontainer*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'"}, |
There was a problem hiding this comment.
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.
…h card's input through an internal context Per review and the owner decision: SelectableCard's public props stay unchanged. Lab's SelectableCardGroup gives each card its input type, name and value through SelectableCardInputContext, which Core exposes only on the internal entry @astryxdesign/core/internal/selectable-card (with useFieldInputStyling). Fixes from review: - A radio card disabled on its own keeps its group's form owner, so the group stays one tab stop; a disabled checkbox card, or every card of a disabled group, leaves the form and submits nothing. - Each input carries its card's value, so a form submits the choice, not "on". - The group keeps a caller's aria-labelledby and aria-describedby beside its own, and uses useFieldInputStyling: untyped root styling is ignored with a development warning, and container* reaches the Field box only when one renders. - SelectableCard's doc and stories are unchanged; the radio keyboard spec moves to Lab against the SelectableCardGroup story. - The Core changeset goes: Core's public API does not change, and Lab is not released. Co-authored-by: vjeux <vjeux@users.noreply.github.com>
There was a problem hiding this comment.
Approval waits on an engineering owner deciding whether Core's SelectableCard may render its hidden input as a radio when Lab's SelectableCardGroup drives it through the internal context. The current SelectableCard record keeps the card a toggling checkbox and puts single-select groups out of scope (FR1, FR2, AR1 and its Intent). To accept the mode, the record is amended to name the group-driven radio input and the radio-group parts it adopts. An engineering owner approves that change by commenting /approve-spec followed by the full head SHA, and the author may do so as an engineering owner. The keyboard behaviour checks out in Chromium: one tab stop on the chosen card, and arrows that move and select. These need fixing whatever the decision:
- In a single-choice group inside a form, a chosen card that is disabled on its own still submits its value (
plan=Team), where a disabled field must submit nothing (AST-085 FR3). Detaching every radio and having the chosen card submit through a hidden input only while it is enabled keeps the group one tab stop. SelectableCardGroupadds its ownjoinIdsinstead of calling Core'sjoinAriaIDs(utils/inputAria.ts), which RadioList and CheckboxList use. Re-export it from the new internal entry and delete the copy.
[Automated review]
| // A disabled card submits nothing, so it leaves the form. A radio | ||
| // that is disabled on its own stays in the form: leaving it would | ||
| // split it from its group and make it an extra tab stop. | ||
| form={ |
There was a problem hiding this comment.
A radio card disabled on its own keeps its form owner here, so when it is the chosen card a form still receives its value (plan=Team in Chromium), where AST-085 FR3 says a disabled field submits nothing. A RadioList in the same state submits nothing. Detaching only the checked radio would split the group, because radios group by name and form owner. One way to keep FR1 and FR3: in single mode give every radio form="", and let the chosen item render a hidden input with the group's name and its value only while that card is enabled. The comment above ("A disabled card submits nothing") is not true for this case.
| ); | ||
|
|
||
| /** Space-separated ids, or undefined when there are none. */ | ||
| function joinIds(...ids: (string | undefined)[]): string | undefined { |
There was a problem hiding this comment.
This copies joinAriaIDs from packages/core/src/utils/inputAria.ts, which RadioList and CheckboxList use for the same job. The copy also does not split space-separated ids or remove duplicates. Re-exporting joinAriaIDs from @astryxdesign/core/internal/selectable-card, as this PR does for useFieldInputStyling, keeps one shared joiner.
| /** | ||
| * @file SelectableCardGroup.tsx | ||
| * @input Uses core SelectableCard (its input set through Core's internal | ||
| * SelectableCardInputContext), Field (the group label and description), | ||
| * useFieldInputStyling, useId, StyleX and the spacing tokens | ||
| * @output Exports SelectableCardGroup, SelectableCardGroupItem and their props | ||
| * @position Lab: a set of selectable cards with the semantics of its selection | ||
| * | ||
| * A single-choice set is a radio group: role="radiogroup", named by its label, | ||
| * holding cards whose inputs are native radios sharing one name. The browser | ||
| * then makes the set one tab stop, moves and selects with the arrow keys, and | ||
| * speaks the position ("2 of 3"). A multiple-choice set is a named group of | ||
| * checkboxes, each its own tab stop. The cards keep SelectableCard's look, | ||
| * press and selection mark. | ||
| * | ||
| * SYNC: When modified, update these files to stay in sync: | ||
| * - ./SelectableCardGroup.doc.mjs (props, usage) | ||
| * - ./index.ts (exports) | ||
| * - /apps/storybook/stories/SelectableCardGroup.stories.tsx (stories) | ||
| */ |
There was a problem hiding this comment.
Non-blocking (INV29): this header is 20 lines and its middle paragraph repeats the component JSDoc. Keeping @file, @input, @output, @position and the SYNC: list fits the file-header shape.
| 'aria-labelledby'?: string; | ||
| 'aria-describedby'?: string; | ||
| }; | ||
| // The caller's own ids join the group's label and description. |
There was a problem hiding this comment.
Non-blocking (INV29): this comment restates the next two lines. The names already say it.
| // The focusable control is a native checkbox or radio, which reacts to | ||
| // Space but ignores Enter. Add Enter as an extra key; Space keeps its native | ||
| // handling, so we deliberately do not handle Space here (would double). |
There was a problem hiding this comment.
Non-blocking (INV29), one topic per sentence: "A native checkbox or radio reacts to Space but not to Enter. This handler adds Enter. Space is not handled here, because it would act twice."
…ts nothing; reuse joinAriaIDs Per review: - Every radio card leaves the form, so the group keeps one form owner and one tab stop, and the chosen, enabled card submits the group's value through a hidden input. A chosen card that is disabled on its own now submits nothing (spec:AST-085 FR3); a disabled checkbox card still leaves the form. - The group joins aria ids with Core's joinAriaIDs, re-exported from the internal entry, instead of its own copy. - SelectableCard's record gets the internal note the owner decision asked for: Lab's group can render the hidden input as a radio through an internal channel; the public contract does not change. - Shorter comments and file header. Co-authored-by: vjeux <vjeux@users.noreply.github.com>
|
/approve-spec a163c34 |
There was a problem hiding this comment.
Changes requested: one fix remains. The engineering owner's approval of this revision settles the spec direction, and the earlier form-submission and shared-helper findings are fixed.
- Two single-choice
SelectableCardGroups that share anhtmlNamein separate forms become one native radio group, because every radio card getsform=""and uses the caller'shtmlNameas its grouping name (SelectableCard.tsx:551,SelectableCardGroup.tsx:175); in Chromium, an arrow key on one form's chosen card moves into the other form and changes its submitted value, and that form's chosen card reads as not checked. Group the radios by a per-group generated name, keephtmlNamefor the hidden input that submits, and add a Chromium test with two forms that sharehtmlName.
Two small comment suggestions are inline and non-blocking.
[Automated review]
| const inputID = useId(); | ||
| const descriptionID = useId(); | ||
| const generatedName = useId(); | ||
| const name = htmlName ?? generatedName; |
There was a problem hiding this comment.
Every radio card gets form="" and this name, so two groups that share an htmlName in separate forms (one plan form per subscription, say) become one native radio group across the page. In Chromium, form A's chosen card then reads as not checked, Tab skips form A, and ArrowRight on form B's chosen card moves into form A and changes its submitted value from plan=Pro to plan=Basic. Two RadioLists in the same layout stay separate. Suggest grouping the radios by generatedName always and using htmlName ?? generatedName only for the hidden input below, plus a Chromium case with two forms that share htmlName (jsdom does not reproduce the merge).
| // Root styling never reaches the group element, and a development build | ||
| // warns about it (the input-fields family). `container*` reaches the Field box | ||
| // only when one renders. |
There was a problem hiding this comment.
Non-blocking (comment style): this restates what useFieldInputStyling does on the next line. Suggest removing it.
| selectionMode: 'multiple'; | ||
| /** The chosen cards' values. */ | ||
| value: ReadonlyArray<string>; | ||
| /** Called with the new list of chosen values, in card order of choice. */ |
There was a problem hiding this comment.
Non-blocking (comment style): "in card order of choice" reads as card order, but the list appends the newest choice last. Suggested: "Called with the new list of chosen values. The newest choice is last."
Per review: the radio cards leave the form, so two groups in separate forms that shared an htmlName became one native radio group across the page. The radios now group by the group's generated name, and htmlName names only the hidden input that submits the choice (and the checkbox cards). A Chromium case with two forms sharing htmlName checks that an arrow stays in its own group and each form submits its own choice; a story shows the two forms. A comment is shorter. Co-authored-by: vjeux <vjeux@users.noreply.github.com>
|
/approve-spec f3e79e4 |
There was a problem hiding this comment.
Changes requested: one documentation fix remains. The engineering owner's approval of this revision settles the spec direction, and two SelectableCardGroups that share an htmlName in separate forms now stay separate radio groups.
- The
htmlNameJSDoc (SelectableCardGroup.tsx:98–:99) and both docs entries (SelectableCardGroup.doc.mjs:65,:191) still say the prop groups the radios like RadioList's, but the radios now group by a generated name andhtmlNameonly names the submitted value; reword all three and drop the RadioList comparison.
Two small non-blocking suggestions are inline.
[Automated review]
| * The native inputs' shared `name`, which groups the radios, as | ||
| * `htmlName` on RadioList. Defaults to a generated one. |
There was a problem hiding this comment.
This still says htmlName groups the radios, as on RadioList. Since the radios now group by generatedName (line 221), htmlName only names the hidden input that submits the choice and the checkbox cards (lines 178, 204, 317). Suggested: "The name the group's value is submitted under. The radios group by a generated name. Defaults to a generated one."
| name: 'htmlName', | ||
| type: 'string', | ||
| description: | ||
| "The native inputs' shared name, which groups the radios (as `htmlName` on RadioList). Defaults to a generated one.", |
There was a problem hiding this comment.
Same as the prop's JSDoc: htmlName no longer groups the radios, and the RadioList comparison no longer holds. Please reword this and the Chinese entry at line 191 to say it names the submitted value.
| /** The inputs' grouping name. */ | ||
| name: string; | ||
| /** The name the group's value is submitted under. */ | ||
| submitName: string; |
There was a problem hiding this comment.
Non-blocking (comment style): these two comments restate the field names. Renaming name to groupName beside submitName says the same thing without them.
| type: 'radio' as const, | ||
| name: 'plan', | ||
| value, | ||
| isGroupDisabled: false, |
There was a problem hiding this comment.
Non-blocking: SelectableCardInput has no isGroupDisabled field, so this line can go.
Per review: the radios group by a generated name, so htmlName's JSDoc and both doc entries say it names the value the group submits, without the RadioList comparison. The context's names say which is which (groupName, submitName); a stale test field goes. Co-authored-by: vjeux <vjeux@users.noreply.github.com>
|
/approve-spec 65ea605 |
There was a problem hiding this comment.
Recommending approval. The engineering owner's approval of this revision settles the spec direction for the group-driven radio, and the htmlName docs now match the code.
- In Chromium on this revision, a single-choice group is one tab stop with arrow-key selection, two forms that share
htmlNamestay separate, and a disabled group keeps focus without changing its value. - Optional: the
@positionsentence inpackages/core/src/internal/selectable-card.tsis long; a shorter form is suggested inline.
[Automated review]
| * @position An internal entry reserved for Astryx's own packages: | ||
| * undocumented, outside the public API and its compatibility promise, | ||
| * versioned in lockstep with Core, and allowed only in Core and Lab by the | ||
| * `@astryx/no-core-internal-import` lint rule. Core components import the | ||
| * same modules by relative path. Named re-exports only. |
There was a problem hiding this comment.
Non-blocking (comment style): this @position sentence runs to 34 words over four topics, and lines 8–9 split one phrase in two. A shorter form keeps the constraint:
* @position Internal entry for Core and Lab only, outside the compatibility
* promise. The `@astryx/no-core-internal-import` lint rule enforces this.
Main is merged into this branch; the diff is only this change.
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.
Evidence
iOS 26.2 Mobile Safari: a tap moves the choice from Pro to Team.
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:
Problem and solution fit
SelectableCardInputContext, on Core's internal entry@astryxdesign/core/internal/selectable-card). A card in a single-choice group renders its hidden input as a radio in the group, so the browser supplies the tab stop, arrow keys and position. A radio card never deselects itself; Enter selects as Space does. Each input carries its card's value, so a form submits the choice.htmlNamein separate forms stay separate;htmlNamenames the submitted value. A chosen card that is disabled on its own, or any card of a disabled group, submits nothing (FR3). A disabled checkbox card leaves the form.role="radiogroup"named by a visible Field label, withvalue/onChange. It keeps a caller'saria-labelledby/aria-describedbybeside its own (joined with Core'sjoinAriaIDs), and styling follows input-fields FR10 through the shareduseFieldInputStyling: untyped root styling is ignored with a development warning, andcontainer*reaches the Field box only when one renders.selectionMode="multiple"gives a namedgroupof checkboxes.Current specification
component:SelectableCardOwnership gains one internal note: "Internal note: through an internal channel, Lab's SelectableCardGroup can render the card's hidden input as a radio in a native radio group. No public prop exposes this; single-select stays outside the public contract until the group is promoted to Core." FR1, FR2, AR1 and the Intent are unchanged.Writing rubric results
SelectableCard.spec.md, Ownership boundary (one bullet added):Open until promotion (decided when SelectableCardGroup is promoted to Core, not in this pull request):
htmlNameagainstname).Public delta and caller burden
internal/selectable-card, for Core and Lab only); a[chore] Internal:changeset.SelectableCardGroupandSelectableCardGroupItem. Callers pass a value per card and own the state.Design and alternatives
role="radio"on the card div (duplicating the input's focus and keyboard).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.Scope
Testing
After the maintainer update (main merged, the internal channel, the review fixes):
pnpm vitest runSelectableCard and lab SelectableCardGroup: 31 pass. New or changed: a card's radio carries its value; every radio card leaves the form together; the group submitsplan=Pro; a chosen card disabled on its own submits nothing while the radios keep one form owner; the caller's aria ids join the group's; untyped root styling is ignored and warns.SelectableCardGroupRadio.a11y.chromium.spec.ts: 4/4 against this head's Storybook, including a new case with two forms that sharehtmlName(an arrow stays in its own group; each form submits its own choice).check:changesets,sync:exports:check, core typecheck, strict ESLint and Prettier are clean, andpnpm review:prereports no findings.Earlier results, from the first version:
Main 8c3f3be (with #8419) merged in; the results below are from the merged head.
pnpm vitest runSelectableCard, lab SelectableCardGroup and Card: 44 pass;build-css12/12. New: a disabled group's form data is empty while its radios keep one name; an enabled group submits its choice underhtmlName;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;multipleis a named group of checkboxes that toggle; an item outside a group throws. Full lab suite: 840 pass.pnpm test:a11y-contract-style Chromium specSelectableCardRadio.a11y.chromium.spec.ts(found bya11y-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).typecheck:docspasses (it failed before the second commit: the Chinese doc had nousage).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.