# yaml-language-server: $schema=https://opencomponents.dev/schemas/contract.json
# The Radio Group contract, with every rule from its checklist.
# The page explains why each one exists: https://opencomponents.dev/raw/docs/components/radio-group.md

component: RadioGroup
summary: Asks one question with a few answers that all fit on the screen, and lets people pick one. Its legend names it, and each Radio inside it is an answer.
standard: https://opencomponents.dev/docs/components/radio-group
element: fieldset # with role=radiogroup, around one Radio for each option
role: radiogroup # each radio is an <input type="radio">, with the radio role
props:
  name: { type: string } # required. Every radio submits under it, and the legend's, hint's and error's IDs start with it
  legend: { type: string } # required. The question, which names the group
  hint: { type: string }
  error: { type: string } # while it's set, the group is invalid
  size: { type: [xs, sm, md, lg, xl], default: md }
  orientation: { type: [vertical, horizontal], default: vertical } # horizontal only for two short options, and never with aria-orientation
  disabled: { type: boolean, default: false }
  required: { type: boolean, default: false }
  value: { type: string } # the checked radio's value, when it's bound. Without it, the group keeps its own
slots:
  default: The radios, one Radio for each option.
events:
  change: Event, from the radio people checked. It bubbles up to the group.
states:
  hover: ":hover on a radio, in @media (hover: hover)"
  focus: "input:focus-visible"
  checked: ":has(> input:checked) on a radio"
  disabled: ":has(> input:disabled) on a radio, which every radio in a disabled group matches"
  invalid: "[aria-invalid=true] on the group, never :invalid"
keyboard:
  Tab: Moves focus into the group, onto the checked radio or the first one, and out of it again.
  ArrowDown: Moves focus to the next radio and checks it, skipping disabled ones. Wraps from the last to the first. ArrowRight too.
  ArrowUp: Moves focus to the previous radio and checks it. Wraps from the first to the last. ArrowLeft too.
  Space: Checks the radio with focus.
parts: [legend, hint, error, options] # data-slot, in this order. The options hold one radio for each option. Each radio is a Radio, a <label> with the parts [control, label], the props value, disabled and checked, and the variables --radio--height, --radio--gap, --radio--font-size, --radio--indicator--size, --radio--indicator--border-color, --radio--indicator--fill and --radio--dot--color
tokens:
  theme: ["--color--neutral-text", "--color--primary", "--color--primary-contrast", "--color--error-text", "--color--focus"]
  variables: [--radio-group--gap, --radio-group--font-size, --radio-group--options--column-gap]
  layer: components # in Tailwind's order: theme, base, components, utilities
rules:
  - id: radio-group/contrast
    layer: ui
    level: must
    scope: component
    requirement: "The legend, labels, hint and error message have a contrast of 4.5:1, and the focus ring has 3:1 against the page"
    basis: "WCAG 1.4.3, 1.4.11 (AA)"
    check: "axe `color-contrast`, contrast checker"
  - id: radio-group/indicator-contrast
    layer: ui
    level: must
    scope: component
    requirement: "The ring of an unchecked radio and the fill of a checked one have a contrast of 3:1 against the page"
    basis: "WCAG 1.4.11 (AA)"
    check: "Contrast checker"
  - id: radio-group/checked-shape
    layer: ui
    level: must
    scope: component
    requirement: "A checked radio differs from an unchecked one by its shape, like a dot, and not by its colour alone"
    basis: "WCAG 1.4.1 (A)"
    check: "Review"
  - id: radio-group/focus-visible
    layer: ui
    level: must
    scope: component
    requirement: "Focus shows an `outline` at least 2px thick around the indicator, outside it"
    basis: "WCAG 2.4.7 (AA), 2.4.13 (AAA)"
    check: "Keyboard"
  - id: radio-group/target-size
    layer: ui
    level: must
    scope: component
    requirement: "Each radio's label is part of its target, which is at least 24 × 24px, and each row is at least 44px tall on touch screens"
    basis: "WCAG 2.5.8 (AA), 2.5.5 (AAA)"
    check: "Unit test, emulation"
  - id: radio-group/input-heights
    layer: ui
    level: should
    scope: component
    requirement: "Each row is as tall as an Input of the same size, so a radio lines up with an input beside it"
    basis: "Open Components"
    check: "Visual regression test"
  - id: radio-group/resizable
    layer: ui
    level: must
    scope: component
    requirement: "Rows are sized with `min-height` in `rem`, so they grow with their text, and long labels wrap beside the indicator"
    basis: "Open Components, beyond WCAG 1.4.4, 1.4.12 (AA)"
    check: "200% zoom"
  - id: radio-group/stable-size
    layer: ui
    level: must
    scope: component
    requirement: "No state changes a radio's size or position"
    basis: "Open Components"
    check: "Visual regression test"
  - id: radio-group/state-selectors
    layer: ui
    level: should
    scope: component
    requirement: "States are styled from the attributes that expose them, on the `<input>` or the group, and never from `:invalid`"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/hover-capable
    layer: ui
    level: should
    scope: component
    requirement: "Hover styles only apply inside `@media (hover: hover)`"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/forced-colors
    layer: ui
    level: must
    scope: component
    requirement: "Every radio, its checked state and focus stay visible in forced colours mode"
    basis: "Open Components"
    check: "Emulation"
  - id: radio-group/more-contrast
    layer: ui
    level: should
    scope: component
    requirement: "The rings are drawn at full strength when `prefers-contrast: more` is set"
    basis: "Open Components"
    check: "Emulation"
  - id: radio-group/stacked
    layer: ui
    level: should
    scope: usage
    requirement: "Radios are stacked one per line, in an order people can predict, apart from two short options, which may sit side by side"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/native-radios
    layer: ux
    level: must
    scope: component
    requirement: "Each radio is a native `<input type=\"radio\">`, and never an element with `role=\"radio\"`"
    basis: "Open Components, beyond WCAG 2.1.1, 4.1.2 (A)"
    check: "Unit test"
  - id: radio-group/fieldset-legend
    layer: ux
    level: must
    scope: component
    requirement: "The radios sit in a `<fieldset>` that its `<legend>` names"
    basis: "WCAG 1.3.1, 3.3.2 (A)"
    check: "Unit test"
  - id: radio-group/question-legend
    layer: ux
    level: should
    scope: usage
    requirement: "The legend asks the question the radios answer, briefly, with anything longer in the hint"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/labelled-radios
    layer: ux
    level: must
    scope: both
    requirement: "Every radio has a visible label, which names it and checks it when pressed"
    basis: "WCAG 1.3.1, 3.3.2, 4.1.2 (A)"
    check: "Unit test, axe `label`"
  - id: radio-group/shared-name
    layer: ux
    level: must
    scope: both
    requirement: "Every radio in a group shares its `name`, and every group on the page has a name of its own"
    basis: "HTML"
    check: "Unit test"
  - id: radio-group/described
    layer: ux
    level: should
    scope: both
    requirement: "A hint for the whole group is linked to it with `aria-describedby`"
    basis: "WCAG 1.3.1 (A)"
    check: "Unit test"
  - id: radio-group/keyboard
    layer: ux
    level: must
    scope: component
    requirement: "`Tab` stops once per group, on the checked radio or the first one, the arrow keys move focus and check, wrapping round, and `Space` checks"
    basis: "APG, WCAG 2.1.1 (A)"
    check: "Unit test, keyboard"
  - id: radio-group/skip-disabled
    layer: ux
    level: must
    scope: component
    requirement: "The arrow keys skip a disabled radio, which can't be checked, and its form leaves it out"
    basis: "HTML"
    check: "Unit test"
  - id: radio-group/no-change-on-input
    layer: ux
    level: must
    scope: usage
    requirement: "Checking a radio never submits its form, opens another page or moves focus"
    basis: "WCAG 3.2.2 (A)"
    check: "Keyboard"
  - id: radio-group/careful-default
    layer: ux
    level: should
    scope: usage
    requirement: "A radio is only checked by default when that answer is safe and most people pick it, and otherwise the group is required"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/distinct-options
    layer: ux
    level: should
    scope: usage
    requirement: "Options are short, don't overlap and cover every answer, with one like \"Other\" when they don't"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/error-message
    layer: ux
    level: must
    scope: usage
    requirement: "An invalid group has an error message, in text, that says what to do"
    basis: "WCAG 1.4.1, 3.3.1 (A), 3.3.3 (AA)"
    check: "Screen reader"
  - id: radio-group/invalid-state
    layer: ux
    level: must
    scope: component
    requirement: "While it has an error message, the group has `role=\"radiogroup\"` and `aria-invalid=\"true\"`, is described by the message and only looks invalid then"
    basis: "WCAG 4.1.2 (A), WAI-ARIA"
    check: "Unit test"
  - id: radio-group/validate-on-submit
    layer: ux
    level: should
    scope: usage
    requirement: "Errors show when people submit, never as they pass through the group, and clear as soon as they check a radio"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/focus-first-error
    layer: ux
    level: should
    scope: usage
    requirement: "When a submit fails, focus moves to the first radio of the first invalid group, or to a summary of the errors, once the error messages are in the page"
    basis: "Open Components"
    check: "Keyboard, screen reader"
  - id: radio-group/disabled-inert
    layer: ux
    level: must
    scope: component
    requirement: "A disabled group disables every radio, which can't be focused, checked or submitted"
    basis: "HTML"
    check: "Unit test"
  - id: radio-group/required
    layer: ux
    level: should
    scope: component
    requirement: "A required group makes every radio required, so its form can't be sent without an answer"
    basis: "HTML"
    check: "Unit test"
  - id: radio-group/keep-focus
    layer: ux
    level: must
    scope: usage
    requirement: "A radio is never disabled while it has focus"
    basis: "Open Components"
    check: "Keyboard"
  - id: radio-group/focus-not-obscured
    layer: ux
    level: must
    scope: usage
    requirement: "Sticky headers and footers never cover a focused radio, for example thanks to `scroll-padding`"
    basis: "WCAG 2.4.11 (AA), 2.4.12 (AAA)"
    check: "Keyboard"
  - id: radio-group/mirrors-in-rtl
    layer: ux
    level: should
    scope: component
    requirement: "It uses logical properties, so each indicator sits before its label in either direction"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/shared-vocabulary
    layer: dx
    level: must
    scope: component
    requirement: "It uses the shared names: `size`, `disabled`, `required`, `name` and `value`"
    basis: "Open Components"
    check: "Review"
  - id: radio-group/typed
    layer: dx
    level: must
    scope: component
    requirement: "Props, events and slots are typed, with unions rather than `string`, and `name`, `legend` and each radio's `value` are required"
    basis: "Open Components"
    check: "Type check"
  - id: radio-group/control-attributes
    layer: dx
    level: must
    scope: component
    requirement: "The group's attributes land on its `<fieldset>`, and a radio's attributes, listeners and refs on its `<input>`, while its classes and styles land on its root"
    basis: "Open Components"
    check: "Unit test"
  - id: radio-group/form-control
    layer: dx
    level: must
    scope: component
    requirement: "Its form sends the checked radio's value under the group's `name`"
    basis: "HTML"
    check: "Unit test"
  - id: radio-group/controllable
    layer: dx
    level: should
    scope: component
    requirement: "Its value can be bound both ways or left to the browser, and every other state is a prop"
    basis: "Open Components"
    check: "Unit test"
  - id: radio-group/role-and-name
    layer: ax
    level: must
    scope: component
    requirement: "It can be found by its role and legend alone, with `getByRole(\"radiogroup\", { name })`, and each radio with `getByRole(\"radio\", { name })`"
    basis: "WCAG 4.1.2 (A)"
    check: "Unit test"
  - id: radio-group/deterministic-dom
    layer: ax
    level: must
    scope: component
    requirement: "The same props render the same markup, with no generated IDs, and states never replace the radios"
    basis: "Open Components"
    check: "Unit test"
  - id: radio-group/data-attributes
    layer: ax
    level: should
    scope: component
    requirement: "The size is mirrored as `data-size` on the group and every radio, the orientation as `data-orientation` on the group, and parts are marked with `data-slot`"
    basis: "Open Components"
    check: "Unit test"
  - id: radio-group/dev-warnings
    layer: ax
    level: should
    scope: component
    requirement: "It warns during development when a group has no legend, or a radio is outside a group"
    basis: "Open Components"
    check: "Unit test"
