Radio group
A set of radios in one fieldset, with one legend and one error for the whole group.
Code
The three render the same class names, the same ARIA and the same data attributes. That is checked when this site builds: a class in any of these samples that neither the markup contract nor the shipped stylesheet defines fails the build.
<fieldset class="wsu-field" id="delivery" tabindex="-1">
<legend class="wsu-field__label">Delivery</legend>
<label class="wsu-radio"><input type="radio" name="delivery" value="in-person" checked><span>In person</span></label>
<label class="wsu-radio"><input type="radio" name="delivery" value="online"><span>Online</span></label>
<label class="wsu-radio"><input type="radio" name="delivery" value="hybrid"><span>Hybrid</span></label>
</fieldset><x-wsu::form.radio
name="delivery"
label="Delivery"
:options="['in-person' => 'In person', 'online' => 'Online', 'hybrid' => 'Hybrid']"
value="in-person" /><RadioGroup
v-model="form.delivery"
name="delivery"
label="Delivery"
:options="[
{ value: 'in-person', label: 'In person' },
{ value: 'online', label: 'Online' },
{ value: 'hybrid', label: 'Hybrid' },
]"
/>Why it works this way
- A single radio is a control that can be turned on and never off, which is not something anyone wants. The unit that makes sense is the choice, so this component is the choice, not the option.
- fieldset and legend, not a div and a styled paragraph above the options: without it a screen reader user hears "Winter 2027, radio button, 1 of 3" and never hears what is being chosen (1.3.1, 3.3.2). A div role="radiogroup" would work too, but a fieldset needs no ARIA to do the same job and cannot be got wrong the way a role can be forgotten.
- The fieldset itself carries the id and a tabindex="-1", the same reasoning ErrorSummary and Sidebar's main use: an error summary link points at an id, and for every other field that id sits on a naturally focusable element. A fieldset is not one, so without the negative tabindex the link would scroll the group into view and leave focus on body, a dead link for anyone who cannot see where the page moved. Landing on the fieldset is also the right place regardless: the failure is "you did not answer", which belongs to the question, and focusing the fieldset announces the legend rather than one arbitrary option.
- Neither canonical template contains a radio group, so the markup contract has nothing recorded for one; wsu-field's own tracked entry describes the input and select wrapper, a div, not this fieldset. .wsu-field still sits on the fieldset here rather than on a div inside it, because a wrapper would put the legend outside the flex column it is meant to sit in.
- One error message for the whole group, referenced by every radio in it through aria-describedby, because the failure belongs to the question rather than to any single option.
- The two paths put aria-invalid in different places for the same failure: Blade sets it once, on the fieldset. Vue sets it on each radio input instead, since Radio.vue is a real component in its own right and takes its invalid state as a prop rather than reading a shared ancestor. Both are conformant; a screen reader is told the same thing either way, just attached to a different element.
Accessibility
- A fieldset and legend, so a screen reader announces the question before the options (1.3.1, 3.3.2).
- id and tabindex="-1" on the fieldset itself, so an error summary entry moves focus to the group rather than only scrolling to it (2.4.3, 3.3.1).
- One aria-describedby, shared by every radio in the set, rather than one error repeated per input.
Success criteria in brackets refer to WCAG 2.2. Every one of them is checked by axe-core in both themes at three viewport widths before this page can be published. See Accessibility for what the gates are.
Props
Blade
| Attribute | Default | What it does |
|---|---|---|
name | required | Also the default id and error-bag key. |
label | null | Rendered as the fieldset's legend. |
options | [] | Values, or value and label pairs. |
value | old input | Falls back to the old input value on a failed submit. |
hint | null | Above the options, wired with aria-describedby. |
error | the error bag | One message for the whole group. Sets aria-invalid on the fieldset itself. |
required | false | Adds the required attribute to each radio and "(required)" to the legend. |
id | a slug of name | On the fieldset. The landing point for an error summary link, since a fieldset needs a tabindex to take focus. |
bag | 'default' | Named error bag. |
Vue
| Prop | Type and default | What it does |
|---|---|---|
modelValue | String, Number or Boolean, '' | v-model. |
options | Array, [] | [{ value, label }] or plain strings. |
label / hint / error | String, '' | error renders the message and sets aria-invalid on each radio input, not on the fieldset (the Blade path sets it on the fieldset instead; see notes). |