# accessibility

> Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a widget a tag cannot name, `checked:`/`selected:`/`expanded:`/`sort:` for the state it is in, `label:` for what it is called, and `decorative:` for what should not be announced at all.

<!-- id: ui-accessibility · area: ui · stability: stable · html: https://osysharp.com/reference/ui/accessibility/ -->

## Summary        {#summary}

A control has to be usable by someone who cannot see it. Most of that you get for free; the rest is one small
vocabulary of props.

```osy syntax
Pressable(onClick: Toggle, role: UiRole.Checkbox, checked: on, label: "Email me") {
  Row(align: Align.Center, gap: 2) { Box(w: "18px", h: "18px"); Text("Email me"); }
}
```

Rendered, that is a real `<button role="checkbox" aria-checked="true" aria-label="Email me">`. Without the three
props it is a button that draws a filled square — which says everything to someone looking at it and nothing to
anyone else.

## Signature      {#signature}

```osy syntax
<element>(role: <UiRole>, label: <string>,
          checked: <bool>, selected: <bool>, expanded: <bool>, current: <bool>, invalid: <bool>,
          sort: <UiSort>, decorative: <bool>)

// …and the ASSOCIATION props, whose value is a compile-scoped HANDLE, not a value:
<element>(id: <handle>, labelFor: <handle>, labelledBy: <handle>, describedBy: <handle>)
```

Every one is **ambient**: it works on any element, because any element can be a control. A `Box` can be a dialog and
a `Row` can be a tab.

## Description    {#description}

### What you already have, free   {#built-in}

Start here, because it is more than it sounds. The renderer picks real HTML tags, and the tag carries the semantics:

| you write | you get | which means |
|---|---|---|
| `Pressable` / `Button` | `<button>` | announced as a button, focusable, activates on Enter **and** Space |
| `Link(href:)` | `<a>` | announced as a link, in the page's link list |
| `Input(value:)` | `<input>` | announced as an entry field, works with every assistive technology |
| `disabled: true` | the native `disabled` attribute | announced as unavailable, and it stops its own events |
| `keys: [Left, Right]` | `tabindex` + key ownership | a custom surface reachable by keyboard at all — see [keys](https://osysharp.com/reference/ui/keys/) |

So a page built from these is already keyboard-operable and already announces its structure. **You do not write ARIA
for any of that**, and adding it would only be a second, staler copy of what the tag already says.

### What a tag cannot say   {#gaps}

Three things, and they are the three that go missing:

**STATE.** A tag cannot say a checkbox is ticked, a tab is the current one, a dropdown is open, or a column is the
one being sorted. Apps draw those — a fill, an underline, a chevron, an arrow — and drawing is not saying.

**NAME.** A button whose face is an icon has no text to be announced by. The icon itself is hidden from assistive
technology (correctly — it is a picture of the meaning, not the meaning), so the control is announced as "button",
with nothing to distinguish it from every other button on the page.

**ASSOCIATION.** A label rendered above an input is, to anyone reading visually, obviously that input's label. To
anyone else it is a piece of text that happens to be nearby — and clicking the words does nothing, where in an
ordinary form it focuses the field. Same for the red message under a rejected value: it says why, to whoever can see
that it belongs to that field.

### Which prop says what — role, state, label   {#props}

#### `role:` — what this element IS   {#role}

Takes a member of the closed `UiRole` vocabulary. It is not a string, so a typo is a compile error naming the
members that exist rather than an attribute every browser silently ignores.

**Write it qualified — `role: UiRole.Tab`, never a bare `Tab`.** In an argument slot a bare capitalised name could be
a theme token, an enum member or a style keyword, so the group name is what says which vocabulary you meant. The
member tables below name the members; the value you write is always `UiRole.` + the member. The same holds for
`sort:`, whose vocabulary is `UiSort`.

```osy title="say which vocabulary you meant — UiRole.Tab, never a bare Tab" syntax
Row(role: UiRole.TabList) { … }
Pressable(role: UiRole.Tab, selected: isCurrent, onClick: Show) { … }
Box(role: UiRole.Dialog, label: title) { … }
```

The members, grouped by what they are for:

| group | members |
|---|---|
| toggles and choice | `Checkbox` `Switch` `Radio` `RadioGroup` `Tab` `TabList` `TabPanel` `Option` `ListBox` `ComboBox` `Menu` `MenuItem` |
| overlays and feedback | `Dialog` `Alert` `Status` `Progress` `Tooltip` |
| grouping | `Toolbar` `Group` `Separator` `List` `ListItem` |
| data grid | `Grid` `GridRow` `ColumnHeader` `GridCell` |
| landmarks | `Navigation` `Search` `Banner` `Main` `ContentInfo` `Region` |

`Alert` interrupts a reader immediately; `Status` waits until they are next idle. That is the whole difference
between "your session has expired" and "saved", and it is worth choosing deliberately.

**Landmarks are the cheapest large win on any page shell.** They are the regions a reader jumps *between*, which is
how most people using a screen reader navigate a page they have seen before. A shell that declares them is
skippable; one that does not has to be read from the top every time.

#### Saying it is open, checked or selected   {#state}

| prop | says | goes with |
|---|---|---|
| `checked:` | is it on | `role: UiRole.Checkbox` · `UiRole.Switch` · `UiRole.Radio` |
| `selected:` | is it the chosen one | `role: UiRole.Tab` · `UiRole.Option` |
| `expanded:` | is it open | `role: UiRole.ComboBox` · `UiRole.Menu` |
| `current:` | is this the page you are on | a nav `Link` |
| `invalid:` | is this value rejected | an `Input` |
| `value:` | how far along it is (a **number**) | `role: UiRole.Progress` — the one thing about a bar nobody can see |
| `sort:` | which way is it sorted (`UiSort.Ascending` · `UiSort.Descending` · `UiSort.None`) | `role: UiRole.ColumnHeader` |

**A `role:` without its state is worse than neither.** `role: UiRole.Checkbox` promises a state the element then never
reports, which is invalid and leaves the control unannounced. Ship the pair.

⚠ **`checked:`, `selected:` and `expanded:` are emitted in BOTH directions** — `aria-checked="false"` is the required
way to say "an unticked checkbox". Omitting it says something else entirely: that the element has no checked state at
all. This is the opposite of how `disabled` works, where absence *is* false. You do not have to remember which is
which — write the bool and the platform emits the right thing.

`current:`, `invalid:` and `decorative:` are absent when false, because for those absence is already what the
specification means.

#### `label:` — what it is CALLED   {#label}

A string, and the accessible name. Reach for it whenever the control's face is a glyph, an icon, or content that
came from its caller.

```osy title="an icon has no text to be announced by, so name the button" syntax
IconButton(onPress: Remove, label: "Remove line") { Icon(Icons.Trash); }
```

When an element also renders text, `label:` overrides it — which is what you want for a checkbox whose contents are
a tick *and* a word, and not what you want when the visible text is already the right name.

⚠ **A visible label and an accessible name that disagree break voice control**, where someone says the words they
can see. If both exist, make them the same string.

#### `decorative:` — do not announce this at all   {#decorative}

For an element that carries no information: a marker that repeats a state already reported, or a full-viewport
click-catcher behind a popover.

```osy title="a glyph that only repeats a state already reported" syntax
if (sorted) { Text(desc ? "▼" : "▲", decorative: true); }
```

**This is the right answer for a backdrop, not a dodge.** A dismiss-on-click backdrop is a real `<button>` the size
of the screen; announced, it is an unnamed control in everyone's way, and *naming* it would be worse, because the
accessible way to dismiss an overlay is Escape.

### Writing a control other people will use   {#authoring-a-control}

This is the part that is easy to get half-right, because the two duties fail differently.

**CARRY what the control knows.** A `Checkbox` knows whether it is ticked; a `Tab` knows whether it is selected. The
caller already passed that in, so making them repeat it as an accessibility argument is exactly the drift the control
exists to remove.

**EXPOSE what only the CALLER knows** — which is almost always the name. `IconButton(icon: save)` cannot know whether
it means "Save" or "Save draft". A theme switcher cannot know whether your app says "Theme", "Appearance" or "Dark
mode".

⚑ **A control that hard-codes its accessibility denies its caller the ability to be correct.** A control is a black
box: if it takes no `label`, no app using it can supply one, however much it wants to. That is worse than the gap
itself, because the app cannot route around it.

```osy title="✗ hard-coded accessibility the caller cannot fix, and the ✓ shape" syntax
// ✗ the caller cannot fix this from outside
[Composable] component IconButton(Action onPress) {
  render { Pressable(onClick: onPress) { Slot; } }
}

// ✓ carries what it knows, takes what it cannot know
[Composable] component IconButton(Action onPress, string label = "") {
  render { Pressable(onClick: onPress, label: label) { Slot; } }
}
```

#### The association props — one element POINTING AT another   {#association}

`label:` gives a control a NAME. These give it a RELATIONSHIP: which words label it, and which message describes it.

```osy title="which words label the field, and which message describes it" syntax
Text("Email", labelFor: box);
Input(value: email, id: box, describedBy: note, invalid: error != "");
if (error != "") { Text(error, id: note); }
```

| prop | says |
|---|---|
| `id:` | **declares** a handle — "this element is the one called `box`" |
| `labelFor:` | these words are the label for that element — a real `<label for>` |
| `labelledBy:` | this element is named by that one, when the namer cannot be a `<label>` |
| `describedBy:` | this element is described by that one — a hint, or a validation message |

**The value is a HANDLE, not a string, and never an id.** You write a bare name; the platform mints the actual id.
The handle is scoped to the component, so two components may both use `box`, and one component rendered twenty times
gets twenty distinct ids. A handle declared twice, or a reference naming one that does not exist, is a compile error.

That is the whole reason it is not a string. A mistyped `for="emailFeild"` is **silent**: it renders, it validates,
and clicking the words focuses nothing at all. And an id you write yourself has to be unique across a page you cannot
see all of at once — which is a promise no component can keep about itself.

⚑ **`labelFor:` and `labelledBy:` are not two spellings of one thing.** `aria-labelledby` gives the accessible NAME.
`<label for>` gives the name **and the click**: pressing the words focuses the input, which on a form of small
controls is most of the hit area, and which people use without thinking about it. Reach for `labelFor:` whenever the
target is a real form control; `labelledBy:` is for the rest — a `Box` acting as a dialog, named by its heading.

An element carrying `labelFor:` **is** a `<label>`, whatever it would otherwise render as. `for` on a `<span>` would
render, validate, and focus nothing.

⭐ **Inside a `foreach`, or a per-item block, each row gets its OWN pair.** A handle is scoped to one *iteration*, so
writing the pair straight into the loop is right — and it is the best answer there, because the name is already on
screen and does not have to be printed twice:

```osy title="inside a loop, each row gets its own handle pair" syntax
foreach (var m in members) {
  Row { Text(m.Name, labelFor: score); Input(value: m.Score, id: score); }
}
```

The two ends must be in the **same** row, though, and that is checked. A pair split across the loop boundary —
`labelFor:` outside it and `id:` inside, or the reverse — names one row's element from a place where there is no one
row, so the compiler refuses it and says which end to move.

⭐ **A wrapper names the control its caller hands it — `Slot(id: field)`.** This is what a form component *is*, and
until it existed a `Field` could not name its own field: the caption lives in the wrapper's tree and the input arrives
from the caller's, and a handle does not cross that boundary on its own.

```osy title="a wrapper naming the control its caller hands it" syntax
[Composable] component Field(string label) {
  render { Stack(gap: 1) { Text(label, labelFor: field); Slot(id: field); } }
}
Field("Name") { Input(value: draft.Name); }     // ← the caller writes nothing
```

The caption is written once and the pair is a real `<label for>`. `label: "Name"` on the `Input` is the fallback where
there is no caption to point at; here it would write the same words twice and give up the click. See [Slot (child content)](https://osysharp.com/reference/ui/slots/).

⚠ **A reference whose target did not render points at nothing**, and contributes nothing — which is right when there
is nothing to say. A `describedBy:` naming a message inside an `if` that did not run simply adds no description. What
cannot happen is a reference to a handle that does not exist at all: that is the compile error above.

### `osy lint` finds the ones you missed   {#lint}

Two rules, over every component you write:

- **`ui-control-state-not-announced`** — this component has a `checked`/`selected`/`open`/`sorted` parameter or field
  and sets no semantic prop anywhere, so it draws a state it never says.
- **`ui-control-without-an-accessible-name`** — this control's face is a glyph or a caller-filled slot and it takes no
  name.

```bash
osy lint
```

Both are `CONSIDER` rather than errors: a `bool open` might genuinely drive nothing a reader needs. In practice they
are nearly always real. Run over the platform's own kit when the rules were written, they returned ten controls —
every one of which is fixed, and the kit is now held to the same rule it ships you.

## Examples       {#examples}

A tab strip that says what it is. `Tabs` is the container, `Tab` carries its own selected state:

```osy title="a tab strip" test app=ui-accessibility
[Composable] component Tabs() {
  render { Row(role: UiRole.TabList, align: Align.End, gap: 1) { Slot; } }
}

[Composable] component Tab(string label, bool selected = false, Action onPress = null) {
  render { Pressable(label, role: UiRole.Tab, selected: selected, onClick: onPress); }
}
```

An icon-only button that its caller can name — the parameter is the whole point:

```osy title="carry what you know, take what you cannot" test app=ui-accessibility
[Composable] component IconAction(Action onPress, string label = "") {
  render { Pressable(onClick: onPress, label: label) { Slot; } }
}
```

A page shell with landmarks, so a reader can jump straight past the chrome instead of reading it every time:

```osy title="landmarks on a shell" test app=ui-accessibility
[Page("/shell")]
[AllowAnonymous]
component Shell() {
  render {
    Stack {
      Row(role: UiRole.Banner) { Text("Acme"); }
      Row {
        Stack(role: UiRole.Navigation, label: "Sections") { Text("Orders"); }
        Stack(role: UiRole.Main) { Text("…"); }
      }
      Row(role: UiRole.ContentInfo) { Text("(c) Acme"); }
    }
  }
}
```

A sortable column header. The arrow is decoration, because `sort:` has already said which way it goes:

```osy title="a sortable header" test app=ui-accessibility
[Composable] component SortHeader(string label, bool sorted = false, bool desc = false, Action onSort = null) {
  render {
    Row(role: UiRole.ColumnHeader, sort: sorted ? (desc ? UiSort.Descending : UiSort.Ascending) : UiSort.None,
        align: Align.Center, gap: 1) {
      Pressable(onClick: onSort) { Text(label); }
      if (sorted) { Text(desc ? "v" : "^", decorative: true); }
    }
  }
}
```

A checkbox that reports its state — the shape the platform's own `Checkbox` uses:

```osy title="a checkbox anyone can read" test app=ui-accessibility
[Composable] component Tickbox(bool checked, string label, Action onToggle = null) {
  render {
    Pressable(onClick: onToggle, role: UiRole.Checkbox, checked: checked, label: label) {
      Row(align: Align.Center, gap: 2) {
        Box(w: "18px", h: "18px", borderW: 1);
        Text(label);
      }
    }
  }
}
```

A field whose label points at its input and whose message describes it — the shape every form is made of:

```osy title="a field that is wired together" test app=ui-accessibility
[Composable] component LabelledField(string label, Binding<string> value, string error = "") {
  render {
    Stack(gap: 1) {
      Text(label, labelFor: box);
      Input(value: value, id: box, describedBy: note, invalid: error != "");
      if (error != "") { Text(error, id: note); }
    }
  }
}
```

Clicking the words focuses the input, and a screen reader reads the field and then the reason it was rejected, as one
thing. Neither is expressible with `label:` alone.

## See also       {#see-also}
- [Osysharp.Ui (the UI kit)](https://osysharp.com/reference/ui/kit/) — the controls the platform ships, and the accessibility parameters each one takes
- [keys](https://osysharp.com/reference/ui/keys/) — making a custom surface keyboard-operable at all
- [Validation](https://osysharp.com/reference/ui/validation/) — where `invalid:` comes from on a field
- [Ui — drive the app's UI from a test](https://osysharp.com/reference/testing/ui/) — a UI test is the first non-visual consumer of your controls, and finds the same gaps
- [component](https://osysharp.com/reference/ui/component/) — declaring a component and its parameters
