Skip to content
academia.sh

Lesson 03 / 25

Selection Controls

The separate problems solved by the checkbox, radio button, and switch; naming the group, declaring the mixed state, and testing two different keyboard contracts.

Contents

In the text field, the name’s source was the field’s own label. The “Notification method” section in the borrowing form, by contrast, consists of three separate controls — “Email,” “SMS,” “No notification” — and the question the user is answering is not written in any of the three names. The question belongs to the group itself.

This lesson specifies three selection controls: the checkbox, the radio button, and the switch. All three look similar, but the problems they solve and their keyboard contracts differ. Setting up the distinction incorrectly produces an interface that keeps working with the keyboard but behaves differently than expected.

Three Controls, Three Separate Problems

The checkbox is an independent on/off choice. In the catalog’s filter panel, “Only items on the shelf,” “Only e-books,” and “Periodicals” are independent of one another; none of them turns another off. Zero of them can be selected, or all of them can.

The radio button is a mutually exclusive choice, and it always sits inside a group. The borrow duration is 14, 21, or 28 days; when one is selected, the other turns off. A single radio button on its own is meaningless — once the user selects it, they cannot back out of the selection.

The switch is an on/off setting, and it takes effect the instant it is changed. The “Send return reminders” setting in the user’s account is like this: it is changed, it is saved, and it does not wait for a submit button on the page.

The distinction follows from this: a switch is not used inside a form that will be confirmed with a submit button; a checkbox is used instead. The promise a switch makes is “this took effect now”; a switch waiting for the form to be submitted breaks that promise.

The Native Element First

The checkbox and the radio button have native counterparts: the checkbox and radio types of the input element. Both bring role, checked state, keyboard contract, and inclusion in the submission list together. What ties radio buttons into a group is their name attribute being the same; the group arises from this name, not from adjacency in the markup.

The switch has no native counterpart. The way it is met is a button carrying a role="switch" declaration; its state is written with aria-checked. Writing role="switch" onto a checkbox is also defined, and in that case the element’s native checked state passes into the tree as the switch state.

The Group’s Name

When the group has no name, the user hears the three options but not the question. The native counterpart is the fieldset and legend elements: the fieldset element produces a group role in the tree, and the legend element’s text becomes the group’s name.

<fieldset>
  <legend>Notification method</legend>
  <p><label><input type="radio" name="notification" value="email" checked> Email</label></p>
  <p><label><input type="radio" name="notification" value="sms"> SMS</label></p>
  <p><label><input type="radio" name="notification" value="none"> No notification</label></p>
</fieldset>

<fieldset>
  <legend>Filters</legend>
  <p><label><input type="checkbox" name="shelf" value="on-shelf"> Only items on the shelf</label></p>
  <p><label><input type="checkbox" name="type" value="ebook"> Only e-books</label></p>
</fieldset>

Two details are binding. First, each control’s own label is built with the label element; the label wrapping the control (an implicit binding) gives the same result without writing a for/id pair, and it also adds the label text to the clickable area. Second, the radio buttons’ name value is the same; when it is written differently, three separate single-option groups form, and the user can select all three at once.

When the group element cannot be used, the counterpart is a role="group" declaration, or role="radiogroup" for radio buttons; the group’s name is tied to a heading element with aria-labelledby. This route is chosen only when the fieldset element’s layout constraints cause a problem.

Role, Name, and State

All three controls’ names come from their own labels; the group’s name does not replace the control’s name, it becomes its context.

The state declaration is checkedness. In native elements it passes to the tree from the checked property; in a hand-built control, it is written with the aria-checked value and updated on every change.

The checkbox has a third state: the mixed state (mixed). The “Select all” checkbox in the filter panel is in this state while some subset of the child checkboxes is selected. In the native element, the mixed state is set not with an attribute but with the indeterminate property, and it is declared to the tree as mixed. The mixed state is not a third option: when the user clicks it, the control either selects all or clears all, and it does not return to mixed.

The switch has no mixed state. Whether the switch’s state is also conveyed in text is a design decision: “On”/“Off” text is not part of the control’s name, because the name does not change; the state is declared only through aria-checked, and the text becomes an additional indicator.

Two Keyboard Contracts

Radio group:

Key Behavior
Tab Brings focus to the group; the entire group is a single tab stop, and focus lands on the selected button.
Arrow keys Move focus and selection together; wrap to the start at the ends.
Space Selects the focused button.
Tab (while inside the group) Exits the entire group, moves to the next stop.

Checkbox and switch:

Key Behavior
Tab Each control is its own tab stop.
Space Toggles the state.
Arrow keys Have no effect; focus and selection do not change.

The difference between the two contracts is that the radio group uses the roving tabindex arrangement: only one item in the group is in the tab order, and the rest are reached with the arrow keys. A group of fifteen options thus occupies one stop, not fifteen. Checkboxes cannot use this arrangement because they are independent; the user has to reach each one separately.

The test below builds both contracts as a state machine and checks them with key sequences.

// selection.test.mjs — the radio group's and checkbox group's keyboard contract
// Run: node --test selection.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";

const ITEMS = ["Email", "SMS", "No notification"];
const FORWARD = new Set(["ArrowDown", "ArrowRight"]);
const BACK = new Set(["ArrowUp", "ArrowLeft"]);

// focus: null means focus is outside the group
const radio = (selected = null) => ({ kind: "radio", items: ITEMS, selected, focus: null });
const checkbox = () => ({ kind: "checkbox", items: ITEMS, selected: new Set(), focus: null });

// Number of tab stops the group occupies
function tabStops(d) {
  return d.kind === "radio" ? 1 : d.items.length;
}

function key(d, t) {
  const n = d.items.length;
  if (d.kind === "radio") {
    if (d.focus === null) return t === "Tab" ? { ...d, focus: d.selected ?? 0 } : d;
    if (t === "Tab") return { ...d, focus: null };
    if (FORWARD.has(t)) { const i = (d.focus + 1) % n; return { ...d, focus: i, selected: i }; }
    if (BACK.has(t)) { const i = (d.focus + n - 1) % n; return { ...d, focus: i, selected: i }; }
    if (t === "Space") return { ...d, selected: d.focus };
    return d;
  }
  if (d.focus === null) return t === "Tab" ? { ...d, focus: 0 } : d;
  if (t === "Tab") return d.focus === n - 1 ? { ...d, focus: null } : { ...d, focus: d.focus + 1 };
  if (t === "Space") {
    const s = new Set(d.selected);
    if (s.has(d.focus)) s.delete(d.focus); else s.add(d.focus);
    return { ...d, selected: s };
  }
  return d;   // arrow keys do not affect the checkbox group
}

const apply = (d, keys) => keys.reduce(key, d);

test("the radio group is a single tab stop, checkboxes occupy one stop per item", () => {
  assert.equal(tabStops(radio()), 1);
  assert.equal(tabStops(checkbox()), 3);
});

test("focus lands on the selected item when entering the group", () => {
  assert.equal(apply(radio(2), ["Tab"]).focus, 2);
});

test("with no selection, focus lands on the first item on entry and no selection is made", () => {
  const d = apply(radio(), ["Tab"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, null);
});

test("arrow keys move focus and selection together, wrapping to the start at the end", () => {
  const d = apply(radio(0), ["Tab", "ArrowDown", "ArrowDown", "ArrowDown"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected, 0);
  const g = apply(radio(0), ["Tab", "ArrowUp"]);
  assert.equal(g.selected, 2);
});

test("Tab exits the entire group in a single press", () => {
  const d = apply(radio(1), ["Tab", "ArrowDown", "Tab"]);
  assert.equal(d.focus, null);
  assert.equal(d.selected, 2);
});

test("arrow keys do nothing on a checkbox", () => {
  const d = apply(checkbox(), ["Tab", "ArrowDown", "ArrowRight"]);
  assert.equal(d.focus, 0);
  assert.equal(d.selected.size, 0);
});

test("checkboxes toggle independently of one another", () => {
  const d = apply(checkbox(), ["Tab", "Space", "Tab", "Tab", "Space"]);
  assert.deepEqual([...d.selected].sort(), [0, 2]);
  const k = apply(d, ["Space"]);
  assert.deepEqual([...k.selected], [0]);
});

test("Space selects the focused item in a radio group", () => {
  const d = apply(radio(0), ["Tab", "Space"]);
  assert.equal(d.selected, 0);
});
✔ the radio group is a single tab stop, checkboxes occupy one stop per item (0.301542ms)
✔ focus lands on the selected item when entering the group (0.070375ms)
✔ with no selection, focus lands on the first item on entry and no selection is made (0.042ms)
✔ arrow keys move focus and selection together, wrapping to the start at the end (0.048625ms)
✔ Tab exits the entire group in a single press (0.305208ms)
✔ arrow keys do nothing on a checkbox (0.063833ms)
✔ checkboxes toggle independently of one another (0.412541ms)
✔ Space selects the focused item in a radio group (0.055291ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 34.528375

The durations in parentheses and the total duration vary from run to run; the test’s result does not change.

The third test catches the contract’s most commonly skipped point: when the group is entered while no option is checked, focus lands on the first item, but no selection is made. The rule that focus drags the selection along applies only to the arrow keys; the moment of entry is exempt from it. When this distinction is missed in hand-built groups, the user ends up selecting a borrow duration they did not want, just by pressing Tab.

The fifth test is the direct consequence of the roving tabindex: even though two steps were taken with the arrow keys inside the group, Tab exits the group with a single press.

Measurable Constraints

Target size. A checkbox’s visual box is typically between 16 and 20 pixels and does not satisfy the 2.5.8 criterion on its own. The label text being clickable together with the control — the label element provides this on its own — enlarges the target up to the text’s width. The vertical spacing between boxes in a filter list also has to be opened enough to satisfy 2.5.8’s spacing exception.

1.4.11 Non-text Contrast. The border of the box and the button has to separate from the page surface by at least 3:1. The same measure applies to the checked indicator: the mark has to be distinguishable from the fill inside the box.

1.4.1 Use of Color. The checked state cannot be conveyed by fill color alone; a checkmark, a dot, or the switch’s shifted position is the second channel.

3.2.2 On Input. Changing a checkbox must not submit the form on its own or change the page. Refreshing the results table when a box is checked in the filter panel is acceptable behavior, because the context does not change; the page address changing and focus being lost is not acceptable.

Common Mistake

Using a checkbox for a mutually exclusive choice. Catching it is behavioral: if the first box turns off on its own when the user checks the second one, the wrong component has been chosen. For the user, this means the reason for the turn-off is announced nowhere.

Leaving the group nameless. Catching it is done with a presence rule: every group carrying radio buttons has to have a name. A heading text sitting above the group on screen is not a name unless a binding is established.

Placing a switch in a form that waits for a submit button. Catching it means asking whether a saving step is required after the switch is changed. If it is, the component is converted to a checkbox.

Declaring the mixed state as false. If the “Select all” checkbox appears off while some subset of the child checkboxes is selected, the user is given wrong information. Catching it means comparing the count of the child checkboxes against the parent checkbox’s declared state.

Summary

  • The checkbox is an independent on/off choice, the radio button is a mutually exclusive choice, and the switch is a setting that takes effect the instant it is changed; a switch is not used in a form that waits to be submitted.
  • The checkbox and radio button have native counterparts; the switch does not and is built with role="switch". The radio group is formed by the name attribute being identical.
  • The group’s name is given with fieldset and legend; the group name does not replace the control’s name, it becomes its context.
  • The radio group uses roving tabindex: the group occupies a single stop, and the arrow keys move focus and selection together; checkboxes, by contrast, are separate stops and do not respond to the arrow keys.
  • On first entry into the group, if no option is selected, focus lands on the first item but no selection is made; this is the rule most often skipped in hand-built groups.
  • The mixed state is not a third option, it is a summary of the child selections; when the user clicks, either all are selected or all are cleared.

Next Step

A radio group works when the options are enumerable and few in number. The “Subject” field in the catalog’s filter panel, though, carries two hundred fifty values, and not all of them can be shown on screen. This requires handing the selection work off to a closable list: the user opens the list, navigates it, narrows it by typing, and selects a value. The next lesson specifies this component as two separate patterns — the native selection element and the hand-built combobox — and shows why the second is the pattern that requires the most declarations.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close