Skip to content

docs(radio): a11y migration docs - #6609

Open
nikkimk wants to merge 7 commits into
mainfrom
nikkimk/swc-2349-radio-a11y
Open

docs(radio): a11y migration docs#6609
nikkimk wants to merge 7 commits into
mainfrom
nikkimk/swc-2349-radio-a11y

Conversation

@nikkimk

@nikkimk nikkimk commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

In spectrum-web-components/CONTRIBUTOR-DOCS/03_project-planning/03_components/radio/accessibility-migration-analysis.md and .../radio-group/accessibility-migration-analysis.md:

  • Documented recommendations for ARIA roles, states, and properties for the 2nd-gen swc-radio and swc-radio-group, including the host-role exception (role="radio"/role="radiogroup" via ElementInternals) called out in the forms strategy RFC
  • Shadow DOM and cross-root ARIA considerations documented for both components, including the "attach ARIA to host" pattern needed because neither component has an inner shadow control to carry the role, and confirmation that 1st-gen's light-DOM help-text association does not have a cross-root violation today
  • Accessibility tree expectations documented for both components, including expected roles, names, states (checked/required/invalid/read-only/disabled), and the one-radio-checked invariant
  • Keyboard interaction model fully specified for swc-radio-group: roving tabindex, four-direction arrow movement with wraparound, Home/End, disabled-item skipping, and the "arrow movement always selects, Tab-entry never does" split, verified directly against 1st-gen's RovingTabindexController test suite and mapped onto FocusgroupNavigationController
  • Testing requirements defined for both components, including unit, aXe/Storybook, Playwright ARIA snapshot, and manual keyboard coverage
  • Known 1st-gen accessibility issues cataloged with disposition: real gaps to fix in 2nd-gen (no aria-invalid ever set on the group host; per-radio readonly that is unenforced and misplaced relative to React Spectrum's group-level isReadOnly; deprecated per-radio aria-invalid tracked by SWC-285; unconditional aria-live="assertive" on help text), plus open questions explicitly deferred to the SWC-2470 research spike (whether a dedicated RadioGroupController is needed) rather than resolved here
  • Applicable WAI-ARIA design pattern identified: the APG Radio Group pattern, including the explicit note that (unlike Tabs) it has no manual-activation variant
  • 1st-gen component analysis completed for both sp-radio and sp-radio-group, covering current ARIA implementation, keyboard handling (confirmed against source and the existing test suite), and known issues with dispositions

Also updated the AI friendly version of forms strategy to note that the POC radio controller should only be used to show how to use existing controllers into a single radio controller. It is not prescriptive of an exception to where the AIRIA role should be. Radio should not have the role on the host but instead use a native radio input in the shadow DOM if possible (or an element with a radio role in the shadow).

Motivation and context

The 2nd-gen migration is an opportunity to address known accessibility gaps, align with the latest WAI-ARIA Authoring Practices, and ensure the component meets WCAG 2.2 AA compliance. This is the accessibility-recommendations step (SWC-2349) of the Radio epic (SWC-2348), and its findings feed directly into the migration plan (SWC-2350), the RadioGroupController research spike (SWC-2470), and the forms strategy RFC's open questions on grouped selection.

Related issue(s)

  • resolves SWC-2349

Screenshots (if appropriate)


Author's checklist

  • I have read the CONTRIBUTING and PULL_REQUESTS documents.
  • I have reviewed at the Accessibility Practices for this feature, see: Aria Practices
  • I have added automated tests to cover my changes.
  • I have included a well-written changeset if my change needs to be published.
  • I have included updated documentation if my change required it.

Reviewer's checklist

  • Includes a Github Issue with appropriate flag or Jira ticket number without a link
  • Includes thoughtfully written changeset if changes suggested include patch, minor, or major features
  • Automated tests cover all use cases and follow best practices for writing
  • Validated on all supported browsers
  • All VRTs are approved before the author can update Golden Hash

Manual review test cases

Review the radio and radio group accessibility migration analyses

  • ARIA roles, states, and properties covered
  • Shadow DOM and cross-root ARIA considerations covered
  • Accessibility tree documented
  • Keyboard interaction fully specified
  • Testing requirements defined
  • Known 1st-gen issues cataloged with dispositions

Review the changes to forms-strategy-rfc.md

@nikkimk nikkimk self-assigned this Aug 12, 2026
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 82151b8

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

📚 Branch Preview Links

🔍 Gen1 Visual Regression Test Results

When a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:

Deployed to Azure Blob Storage: pr-6609

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

@coveralls

coveralls commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 31730242068

Warning

No base build found for commit 56fd6ef on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 96.239%

Details

  • Patch coverage: No coverable lines changed in this PR.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 39268
Covered Lines: 37993
Line Coverage: 96.75%
Relevant Branches: 6466
Covered Branches: 6021
Branch Coverage: 93.12%
Branches in Coverage %: Yes
Coverage Strength: 460.88 hits per line

💛 - Coveralls


- **Exception (button-like and radio-like controls):** these put the role on the **host** instead, via `ElementInternals` (`internals.role = 'button'` / `'radio'`). This is safe specifically because neither role needs to expose a *live value* to assistive technology the way a textbox or combobox trigger does; a radio's or button's full state is carried by `aria-checked` or the role itself, so there is no value-mirroring problem to solve on the host. It also collapses the focusable element, the role element, and (for radio) the roving-tabindex participant the group's focus controller drives into a single node, instead of splitting them across host and shadow root. Do not extend this exception to controls that carry a live value (textbox, combobox); see [Why not put the value-bearing role on the host?](#why-not-put-the-value-bearing-role-on-the-host) for why that case does not work the same way. Even with the role on the host, name these controls through `accessible-label` / `accessible-labelledby`, not raw `aria-label` (see [§3.3](#33-idref-strategy-label-help-text-and-errors)).
- **Consequence:** because a value-bearing field's host has no role, an axe-core scan of the host alone reports a false positive; this is expected and handled by the axe policy in [§3.4](#34-axe-core-policy).
- **Exception (button-like controls, and container-only roles with no native HTML equivalent, such as `radiogroup`):** these put the role on the **host** instead, via `ElementInternals` (`internals.role = 'button'` / `'radiogroup'`). This is safe specifically because neither role needs to expose a *live value* to assistive technology the way a textbox or combobox trigger does, and neither has a native HTML element it would otherwise be giving up by moving to the host; there is no `<button>` or `<radiogroup>` element being bypassed. Do not extend this exception to controls that carry a live value (textbox, combobox); see [Why not put the value-bearing role on the host?](#why-not-put-the-value-bearing-role-on-the-host) for why that case does not work the same way. Even with the role on the host, name these controls through `accessible-label` / `accessible-labelledby`, not raw `aria-label` (see [§3.3](#33-idref-strategy-label-help-text-and-errors)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixing a misconception. The radio used in the radio controller demo is not the example to follow for how to build a radio and where to put the role. It was merely to demo how a single controller could bundle several other related controllers that a radio group would need. The checkbox in this hybrid demo is what we should follow: https://nikkimk.github.io/web-component-form-strategy-demos/demo-hybrid.html

@nikkimk nikkimk added a11y Issues or PRs related to accessibility Status:Ready for review PR ready for review or re-review. High priority PR review PR is a high priority and should be reviewed ASAP labels Aug 13, 2026
@nikkimk
nikkimk marked this pull request as ready for review August 13, 2026 14:59
@nikkimk
nikkimk requested a review from a team as a code owner August 13, 2026 14:59

@cdransf cdransf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks great! ✨

### What it is

- The container for two or more mutually exclusive `swc-radio` items. It owns sibling discovery, enforces that exactly one item is checked, moves roving keyboard focus between items, and is the level at which group-wide concerns (label, description, error message, required, invalid, read-only, disabled) live, rather than any one item.
- Sets `role="radiogroup"` on its own host via `ElementInternals` (`internals.role = 'radiogroup'`), for the same reason `swc-radio` sets `role="radio"` on its own host: the group's state is fully described by which child has `aria-checked="true"`, so there is no separate live value that needs a different node to carry it.

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 section of the comment ( "for the same reason swc-radio sets role="radio" on its own host:" ) seems to contradict what it says in the radio analysis.

Radio says:

Renders a real, native <input type="radio"> inside its own shadow DOM to carry role="radio", checked, and keyboard activation natively, the same shadow-DOM-first pattern swc-text-field uses for its <input type="text">. The host itself sets no role. This follows the forms strategy RFC's general rule that the role element defaults to the shadow DOM (see the RFC's §3.2); unlike swc-radio-group (which has no native container element to render and so uses the RFC's host-role exception instead), swc-radio has a native equivalent and does not need that exception.

| --- | --- |
| [Radio Group pattern (APG)](https://www.w3.org/WAI/ARIA/apg/patterns/radio/) | `radiogroup` containing `radio` children; roving `tabindex`; arrow keys move focus and selection together; no manual-activation variant. |
| [Labels or instructions (WCAG 3.3.2)](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) | The group as a whole needs a visible, programmatically associated label, not just labels on the individual radios. [SWC-1178](https://jira.corp.adobe.com/browse/SWC-1178) tracks a report of a missing visible group label in 1st-gen's anatomy documentation; verify against the live `sp-radio-group` API (which does support a `label` property/attribute) rather than assuming the underlying capability is missing, but treat the report as a signal to double-check every 2nd-gen anatomy and Storybook example actually supplies one. |
| [Info and relationships (WCAG 1.3.1)](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | Grouping, label, description, and error message must all be programmatic (role containment plus `aria-describedby`/`aria-errormessage`), not conveyed by layout alone. |

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.

I'm wondering if we will have the same issue that came up on the Divider component, when it came to dev-warnings. Our solution was to create a ticket to update Divider to use an internal element which could trigger a warning on a property change. But it sounds like for these forms we can't go with that pattern.

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

Looks good besides what @miwha-adobe noted in the radio group plan re: the radio role living on host or using an internal native radio.

| **Host role** | None. The host sets no `role`. A real, native `<input type="radio">` inside `swc-radio`'s own shadow DOM supplies the implicit `radio` role. This follows the forms strategy RFC's [§3.2](../../05_strategies/forms-strategy-rfc.md#32-where-aria-roles-live) general rule (role element defaults to the shadow DOM), not the host-role exception reserved for controls with no native equivalent, such as `swc-radio-group`'s `radiogroup` container (see the [radio group doc](../radio-group/accessibility-migration-analysis.md#aria-roles-states-and-properties)). Attach the shadow root with `delegatesFocus: true` so Tab and programmatic focus land on the inner `<input>` directly, the same pattern `swc-text-field` uses. |
| **`checked` / `aria-checked`** | Comes from the browser for free: the native `<input type="radio">`'s `checked` IDL property drives its own implicit `aria-checked`, so `swc-radio` does not need to write `aria-checked` manually the way 1st-gen `sp-radio` does on itself in `updated()`. Keep `swc-radio`'s own `checked` (or `selected`) property in sync with the inner input's `checked`. Radio's `aria-checked` only ever takes `true`/`false`, never `"mixed"` (unlike checkbox's tri-state support). |
| **Accessible name** | Rendered as a hidden-until-populated label element inside `swc-radio`'s own shadow root (matching the shape of the PoC's [`checkbox-hybrid.js`](https://github.com/nikkimk/web-component-form-strategy-demos/blob/main/checkbox-hybrid.js)), wired by the **`LabellingController`** ([SWC-2466](https://jira.corp.adobe.com/browse/SWC-2466), *pending research*), which points the inner `<input type="radio">`'s (the role-bearing element's) `ariaLabelledByElements` at that span. `ariaLabelledByElements` is a general ARIAMixin element-reference property available directly on any element, including a plain native `<input>`; it does not require `ElementInternals`. In precedence order: (1) `accessible-labelledby`, for the rare case a radio's name is composed from elements it doesn't own, resolved by the controller into the same `ariaLabelledByElements` array; (2) `accessible-label`, for a radio with no visible text label of its own; (3) the slotted label content, the normal case. See [Shadow DOM and cross-root ARIA Issues](#shadow-dom-and-cross-root-aria-issues) for why this is one mechanism for both the slotted and light-DOM-sibling cases, not two. |
| **Per-item description (open scope question)** | [React Spectrum's `Radio`](https://react-spectrum.adobe.com/RadioGroup) accepts an optional per-radio `description` (used for enriched option text, for example "Delivers in 5–7 business days" under a shipping-method label). 1st-gen `sp-radio` has no equivalent, and the reviewed Figma files show only a group-level description and error message, matching 1st-gen's `sp-radio-group` help-text slots rather than a per-radio one. Whether `swc-radio` adds a per-radio `description` in this migration wave, or defers it, is an open API-scope question for the migration plan (SWC-2350) and Phase 3 (API), not decided here. If it is added, it can use a plain same-root `aria-describedby` pointing at an element in the input's own shadow root, the same pattern `swc-text-field` uses for its description; no element-reference API is needed for this case. |

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.

Note: Design very recently confirmed they will be supporting per-item descriptions, so we should plan on it as well (holler if you want the Slack link to that).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y Issues or PRs related to accessibility High priority PR review PR is a high priority and should be reviewed ASAP Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants