Components / forms
RadioGroup
One of two components that look like they need a binding and do not.
Summary
A one-of-many choice where an option is always selected.
| APG pattern | Radio Group |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
<RadioGroup id="visibility" name="visibility" legend="Who can see this portfolio?"
description="You can change this at any time." defaultValue="public" options={…} />
Anatomy
A <fieldset data-se="radio-group"> with a <legend>, holding native radios.
| Part | Element | Required |
|---|---|---|
description | <p> | no |
error | <p> | no |
legend | <legend> | yes |
options | <div> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
name | string | required | Shared by every input — this is what gives the group arrow keys for free. |
legend | React.ReactNode | required | The question. Rendered as a <legend> so it is announced with each option. |
description | React.ReactNode | none | A line under the legend, before the options. Announced with the group. |
options | RadioOption[] | required | The choices, in display order. One is always selected, so there is no empty state. |
value | string | none | The selected value. Passing this makes the group controlled. |
defaultValue | string | none | Which option starts selected. Falls back to the first enabled one, because a radio group with nothing selected has no honest resting state. |
onChange | (value: string) => void | none | Called with the newly selected value. |
orientation | vertical · horizontal | vertical | Stacks the options or lays them in a row. |
inToolbar | boolean | none | Set when the group sits inside a Toolbar. APG gives it a different arrow contract there: Right and Left move within the group but hand off to the toolbar at the ends, while Down and Up stay inside and wrap. The group takes over roving focus, so it costs the toolbar one tab stop, not three. |
error | React.ReactNode | none | Shown and wired to aria-describedby. Never rely on color alone. |
id | string | required | Required — used to build the option, description and error ids. |
States
_None. Rest is the only condition it has._
Behavior
It needs no script. Arrow keys, roving focus and the one-of-many constraint come from the platform once every input shares a name. The onKeyDown returns immediately unless the group sits in a toolbar, where the APG gives it a different arrow contract.
Accessibility
The legend is the question, and it is announced with every option. A heading above a plain div is not, which leaves the reader hearing Only you with no idea what it answers. That is the whole reason this is a fieldset rather than a styled stack of Choice.
One tab stop for the group. Proving it needs a real Tab press from outside the group — every radio reports tabIndex === 0, and focusing an unchecked one makes it the stop, so the property cannot answer the question.
Keyboard
| Key | Result |
|---|---|
Tab | Enters the group once and leaves it once — the whole group is one stop |
Arrow keys | Move to the next or previous radio and select it |
Space | Selects the focused radio |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-describedby | set at render |
aria-invalid | set at render |
aria-labelledby | set at render |
role | always radiogroup |
Focus
Focus enters on the checked radio, or the first when none is checked. Proving it is one stop needs a real Tab from outside the group — every radio reports tabIndex === 0, and focusing an unchecked one makes it the stop, so no property can answer the question.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It sets no target size of its own; anything interactive inside it brings its own.
Tested
test/apg/batch-c.test.jsxtest/apg/radio.test.jsx
Known gaps
- 1 upstream ref — an SVG star rating built from g[role="radio"]
- 2 upstream refs — an aria-activedescendant radio group; ours uses native inputs
- 1 upstream ref — APG's own page or markup, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-leading-body | line-height |
--se-leading-ui | line-height |
--se-negative | color |
--se-space-1 | gap, margin-block-start |
--se-space-2 | margin-block-end, margin-block-start |
--se-space-4 | gap |
--se-text | color |
--se-text-muted | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
Motion
None beyond the control's own.
Content
The legend is a question the options answer. Who can see this portfolio? Each option is the answer in the reader's words — Anyone with the link, not Public (unlisted).
Usage
An option is always selected. If none may be, the question is really a checkbox.
When not to use it. For several checkboxes answering one question — that is CheckboxGroup. For a single on/off setting — that is Choice.
It renders its own options, and that is the point. The options array becomes Choice controls this component owns — the fieldset, the legend and the wiring between them are the reason it exists, and they are the parts most often got wrong by hand. Reach for Choice directly only for a lone control that answers no group's question.
When not to use it. For any of many, and it also carries the tri-state parent that reports mixed — that is CheckboxGroup.
Notes
One of two components that look like they need a binding and do not.