← All stories

COMPONENT · ui-checkbox

ui-checkbox

Component Tier 1 (primitive) Used wherever a user toggles a binary or tri-state value

The smallest interaction primitive that still hides real failure modes: clicking the label should toggle the box, the focus ring must never disappear, indeterminate state has its own keyboard semantics, and copy-options checkboxes need labels with counts that don't break when the count is zero.

Component contract

Every checkbox in the admin is <UiCheckbox>. The linter scans the React source and flags any <input type="checkbox"> not rendered through it.

  • checked: boolean | "indeterminate" — controlled.
  • onChange: (next) => void — fires with the new value (boolean; never "indeterminate" — that's a display-only state).
  • label: ReactNode — required, always rendered. Clicking the label toggles the box.
  • helperText?: string — appears below the row.
  • disabled?: boolean
  • required?: boolean — adds visible asterisk + aria-required.
  • error?: string — when set, renders inline below the row.

Interaction surface

  1. Render: box left, label right, both clickable.

    The label is associated via <label> wrapper or htmlFor. Clicking anywhere in the row (box or label text) toggles. Helper text and error are NOT clickable for toggle — they're informational.

  2. Click: toggles state.

    From unchecked → checked (or indeterminate → checked). From checked → unchecked. Indeterminate is set externally only; the user cannot click TO indeterminate.

  3. Keyboard: Space toggles; Enter does not.

    Space is the standard checkbox toggle. Enter typically submits a parent form; if the checkbox steals Enter, that's a regression. Tab moves focus through.

  4. Focus ring is always visible on keyboard focus.

    2px ring with contrast ≥ 3:1 against the field background. Never removed via outline:none without a replacement.

  5. Indeterminate state.

    Visually rendered as a horizontal bar (—) inside the box, distinct from the checkmark. aria-checked="mixed". Used for "select all" controls when some-but-not-all children are selected.

Failure modes

Label click does not toggle

Trigger: rendered label uses a non-associated <span>; clicking it does nothing.

Click handlers must be on the <label> element with proper htmlFor, OR the label must wrap the input. The harness asserts: click on label text, observe checked state changed.

Focus ring removed via outline:none

Trigger: a designer wanted "cleaner" checkboxes and removed the focus outline.

Focus ring is mandatory; replacement (box-shadow or pseudo-element) is required if outline is suppressed. Linter scans component CSS for outline:none rules without a sibling focus-state replacement.

Enter inside checkbox submits something unexpected

Trigger: focus is on a checkbox inside a form, user presses Enter intending to submit.

Default browser behavior: Enter on a focused checkbox submits the form (assuming there's an implicit submit button). Component preserves this default — it does NOT consume Enter. The harness asserts: Enter on focused checkbox triggers form submit.

Indeterminate not visually distinct from unchecked

Trigger: indeterminate state renders as the unchecked box with no marker.

Indeterminate must show a visible marker (typically a horizontal dash). The harness asserts: render indeterminate, screenshot the box, assert visual delta from unchecked.

Disabled checkbox is still clickable

Trigger: disabled=true is rendered but clicks still toggle the state.

Both the underlying <input> has disabled AND the wrapping label has aria-disabled="true" AND pointer-events: none on the entire row (so label click is also blocked). Harness clicks the disabled checkbox and asserts state did NOT change.

Empty count breaks the label

Trigger: a copy-option label like "Copy & access types" expects to be appended with " (3 access types)" but the count is 0.

Convention: callers should suppress the count parenthetical when count is 0 OR show it as "(none)". Component does NOT make this decision; it renders the label as given. But the linter checks for templates like "$\{label\} (${count} ${noun})" and recommends a guarded form. Soft check, not enforced.

Accessibility

  • Underlying element is <input type="checkbox"> with id and a wrapping or htmlFor-linked <label>.
  • aria-checked reflects state including "mixed" for indeterminate.
  • aria-required="true" when required.
  • aria-invalid="true" + aria-describedby when error is set.
  • Color contrast: label text ≥ 4.5:1, focus ring ≥ 3:1, checkmark ≥ 3:1 against the box's checked background.
  • axe-clean at severity ≥ serious.

Stable test attributes

Visibility teeth. Each attribute must be present AND effectively visible when the checkbox is rendered. Hiding without removal is a Ratchet violation.

data-testWherePurpose
ui-checkboxWrapping <label>Component identity marker; covers entire clickable row
ui-checkbox-inputThe underlying <input type="checkbox">The actual input element
ui-checkbox-labelInside ui-checkboxThe label text content
ui-checkbox-helperBelow the rowHelper text region
ui-checkbox-errorBelow the rowError region; visible only in error state

Agent test plan

Standalone probes against /admin-test/ui-checkbox-fixture with variants: unchecked, checked, indeterminate, disabled, required, with error.

Probe list
- click-box-toggles: click ui-checkbox-input, assert checked changed
- click-label-toggles: click ui-checkbox-label, assert checked changed (validates label association)
- space-toggles: focus, press Space, assert checked changed
- enter-does-not-toggle: focus, press Enter, assert checked unchanged AND parent form submitted
- focus-ring-visible: focus the checkbox, screenshot, assert visual delta from unfocused
- indeterminate-visual-distinct: render indeterminate, assert distinct from unchecked
- aria-checked-mixed: render indeterminate, assert input has aria-checked="mixed"
- disabled-no-toggle: render disabled, click box AND click label, assert checked unchanged
- error-aria-invalid: render with error, assert aria-invalid="true" AND aria-describedby points to ui-checkbox-error
- required-asterisk-and-aria: render required, assert label contains visible asterisk AND aria-required="true"
- color-contrast-label: assert label text ≥ 4.5:1
- color-contrast-focus-ring: assert focus ring ≥ 3:1