Skip to content

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

Merged
cixzhang merged 9 commits into
meta:mainfrom
vjeux:uikit/c06-card-radio
Nov 3, 2026
Merged

cixzhang merged 9 commits into
meta:mainfrom
vjeux:uikit/c06-card-radio

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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.

  1. Why: SelectableCard always renders a checkbox, and has no public way to render a radio.
  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's public props do not change. Lab's SelectableCardGroup gives each card its input type, name and value through an internal context (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.
  • Form and disabled cards (AST-085): every radio card leaves the form together, so the group keeps one form owner and one tab stop (FR1), and the chosen, enabled card submits the group's value through a hidden input. The radios group by the group's own generated name, so two groups that share an htmlName in separate forms stay separate; htmlName names 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.
  • SelectableCardGroup (lab) wraps the set: role="radiogroup" named by a visible Field label, with value/onChange. It keeps a caller's aria-labelledby/aria-describedby beside its own (joined with Core's joinAriaIDs), and styling follows input-fields FR10 through the shared useFieldInputStyling: untyped root styling is ignored with a development warning, and container* reaches the Field box only when one renders. selectionMode="multiple" gives a named group of checkboxes.

Current specification

  • docs(SelectableCard): stop recommending it for single-select groups #8419 deferred single-select card groups to a separate API decision; this is that API. SelectableCard's docs stay as docs(SelectableCard): stop recommending it for single-select groups #8419 wrote them.
  • WAI-ARIA APG radio group; WCAG 4.1.2 (role and state) and 1.3.1 (grouping).
  • Owner decision: Core's public API stays unchanged, and the group drives each card through an internal channel. SelectableCardGroup is a Lab component, so it merges in Lab.
  • component:SelectableCard Ownership 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):

  • R1, narration: none.
  • R2, present tense: yes.
  • R3, evidence naming a change: none.
  • R4, one owner per fact: SelectableCard owns its hidden input; the group's semantics stay with Lab until promotion.
  • R5, exact ids: yes.
  • R6, verification names its case: not a requirement row; the unit and Chromium tests below cover it.

Open until promotion (decided when SelectableCardGroup is promoted to Core, not in this pull request):

  1. The group's API, reviewed with the vetting checklist, naming included (for example htmlName against name).
  2. Whether SelectableCard gains public props for a radio input. If they are still wanted then, that is an owner decision of its own.

Public delta and caller burden

  • Core: no public change. One internal context and one internal entry (internal/selectable-card, for Core and Lab only); a [chore] Internal: changeset.
  • 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.
  • 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

After the maintainer update (main merged, the internal channel, the review fixes):

  • pnpm vitest run SelectableCard and lab SelectableCardGroup: 31 pass. New or changed: a card's radio carries its value; every radio card leaves the form together; the group submits plan=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.
  • The Chromium spec, moved to Lab as SelectableCardGroupRadio.a11y.chromium.spec.ts: 4/4 against this head's Storybook, including a new case with two forms that share htmlName (an arrow stays in its own group; each form submits its own choice).
  • CLI discover and ShadCN registry node tests: 164 pass. check:changesets, sync:exports:check, core typecheck, strict ESLint and Prettier are clean, and pnpm review:pre reports no findings.

Earlier results, from the first version:

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 Nov 3, 2026 5:24pm 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
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 6:17 PM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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

🔎 Preview · 65ea605

Storybook · Sandbox · built from this PR's head

Current run: The current CI run concluded failure. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
Contributor

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
cixzhang and others added 3 commits November 3, 2026 07:19
…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>

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 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.
  • SelectableCardGroup adds its own joinIds instead of calling Core's joinAriaIDs (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={

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +5 to +24
/**
* @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)
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this comment restates the next two lines. The names already say it.

Comment on lines +440 to +442
// 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).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec a163c34

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 an htmlName in separate forms become one native radio group, because every radio card gets form="" and uses the caller's htmlName as 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, keep htmlName for the hidden input that submits, and add a Chromium test with two forms that share htmlName.

Two small comment suggestions are inline and non-blocking.

[Automated review]

const inputID = useId();
const descriptionID = useId();
const generatedName = useId();
const name = htmlName ?? generatedName;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment on lines +176 to +178
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec f3e79e4

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 htmlName JSDoc (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 and htmlName only names the submitted value; reword all three and drop the RadioList comparison.

Two small non-blocking suggestions are inline.

[Automated review]

Comment on lines +98 to +99
* The native inputs' shared `name`, which groups the radios, as
* `htmlName` on RadioList. Defaults to a generated one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +126 to +129
/** The inputs' grouping name. */
name: string;
/** The name the group's value is submitted under. */
submitName: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 65ea605

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 htmlName stay separate, and a disabled group keeps focus without changing its value.
  • Optional: the @position sentence in packages/core/src/internal/selectable-card.ts is long; a shorter form is suggested inline.

[Automated review]

Comment on lines +10 to +14
* @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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 18:18
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 3, 2026
@cixzhang
cixzhang marked this pull request as draft November 3, 2026 18:21
@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 18:22
@cixzhang
cixzhang merged commit 9136e28 into meta:main Nov 3, 2026
154 of 159 checks passed

This branch was successfully deployed

1 active deployment
Preview — 65ea6050 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

2 participants