Skip to content

a11y

#include "gbui/a11y/role.hpp", accessibility.hpp, tree.hpp

What a node is, to somebody who cannot see it. A Style says how a node is drawn and a Role says what it is — and until this existed, a screen reader was handed one blank rectangle where an application should have been.

This is stages 1 to 4 of the accessibility plan: a role and a name on every control, the state and value that go with it, the relations that tie a caption to its field and an error to its input, and the pruned tree those are read into. What it is not yet is a platform bridge — nothing is sent to UIA, AT-SPI or NSAccessibility. That is stage 5, and it is a decision before it is a task because AccessKit would be the library's second dependency. Everything here is in the arena and testable with no window, no GPU and no screen reader, which is exactly how it should be built.

The rule

Every component that is added or changed in this repository carries working accessibility in the same commit. It is rule 7 in CONTRIBUTING, and two gates hold it:

  • tests/accessibilityTest.cpp covers the library — its last case walks a form and fails on any Tab stop with no role, or with nothing to announce.
  • gbui_demo --a11y covers the call sites, walking the tree of every demo screen and every catalogue example. It exists because the names the toolkit cannot invent — an icon-only button, a chart, a table — are the application's to supply, and it found sixteen missing the first time it ran.

Setting it

cpp
ui.tag(id).focusable(!options.disabled).accessible({
    .role = Role::Checkbox,
    .name = options.label,
    .state = {.checked = flag(checked), .disabled = flag(options.disabled)},
});

Two shorthands exist for the nodes that need nothing else — ui.role(Role::Row) and ui.name("Commits") — and an overload takes a NodeId, for the component that only learns something after its own subtree is closed.

Calls merge, they do not replace. A field that was not given is "I had nothing to say about this", never "set it back to the default", so a component can state its role and a wrapper can add the relation that names it. Every string is interned, so std::string(id) + ".error" is safe to pass.

It is not stored on the node. A Node is a trivial aggregate so clearing the arena is a size reset, and most nodes have nothing to say — a row that spaces two things out is not a thing. So the record lives in a side table the arena owns and a node names by index, exactly as vector art already does: four bytes on every node, the full record only on the ones that have one. arena.accessibility(id) reads it back, and returns null for the majority that never set one.

Roles

The names are ARIA's, and ARIA's are also what AccessKit's tree model uses — picking the same vocabulary now makes stage 5 a lookup table rather than a translation with opinions in it. roleName() gives the ARIA spelling, which is what a test asserts against and what the bridge will send.

Three deviate, and each says so in the header: Label is a run of static text (AccessKit's word; ARIA leaves it roleless), TextInput is ARIA's textbox spelled after the component it comes from, and ScrollView is AccessKit's, because ARIA has no word for "this region moves".

There is no role for anything this toolkit cannot build. A role no component ever sets is a role nothing can test.

ContentLabel, Heading, Paragraph, Image, Link, Figure
ControlsButton, Checkbox, Radio, RadioGroup, Switch, Slider, SpinButton, TextInput, ComboBox, ListBox, Option, ProgressBar
StructureGroup, Form, Toolbar, Separator, ScrollView, List, ListItem, Table, Row, Cell, ColumnHeader, Tree, TreeItem, TabList, Tab, TabPanel
OverlaysMenu, MenuBar, MenuItem, MenuItemCheckbox, MenuItemRadio, Dialog, AlertDialog, Tooltip
LiveStatus, Alert

Role::None is the default and the right answer for most nodes: presentational, collapsed away by the tree.

State

cpp
enum class Flag { Unset, False, True, Mixed };
constexpr Flag flag(bool);

Unset is not False. A checkbox that is not checked is announced as "not checked"; a button, which has no checked state, is announced as a button. A bool defaulting to false would give every button in the tree a state it does not have. Mixed is the third value a tri-state checkbox has, and the reason this is not std::optional<bool>.

The flags are checked, expanded, selected, pressed, disabled, readOnly, invalid, busy and required, plus sorted for a column header — Sort::None there means "sortable, not sorted by", which is what the permanent arrow says visually.

Value

cpp
struct AccessibilityValue {
    bool present;
    double now, minimum, maximum;
    std::string_view text;   // the value in words
};

text is the whole point of this being more than a number. A slider that announces "70" is a slider nobody can use; "70 percent" is one they can, and only the caller knows which of the two it is — the number carries no unit and the toolkit has no locale to invent one from. That is why SliderOptions::valueText exists.

minimum == maximum says there is no range, which is the honest answer for a text box: it has a value and no bounds. A number box sets all three, and textInput reports the clamped value even while the text is not — typing 500 into a box that stops at 60 shows 500 and returns 60.

A password box reports no value at all. The bullets exist so the string is not on offer, and a tree that carried it would hand back what the screen refuses to show.

Relations

Tags, never NodeIds: the tree is rebuilt every frame and only a tag survives that.

labelledBy, describedBy, controls, owns, activeDescendant — and then two more, pointed the other way:

cpp
std::string_view labels;      // the control this node names
std::string_view describes;   // the control this node describes

They exist because the end that knows is not the end that carries it. A caption is built before the input it names; a field's error is built after it; and a component never reaches into another component's node. So the node that knows says which way round it goes, and the accessibility tree turns it over when it is built. This is <label for> exactly — the attribute is on the label and the browser resolves it onto the control.

activeDescendant is what makes an open select usable: focus stays on the box — deliberately, so Tab cannot fall into the popup — and this is the only way to say which row the arrow keys are on.

Sets

cpp
std::size_t positionInSet;   // one-based
std::size_t setSize;

For one reason, and it is a good one: a virtualised list builds only the rows on screen. Without these, a reader walking fifty thousand commits is told "row 3 of 14" and then told it again for the rest of the list, because fourteen is all that was ever in the tree. virtualList sets them from count.

Hiding

hidden is aria-hidden: this node and everything under it is not in the tree. Not the same as Role::None, which means "I am not a thing, but my children might be". It is for content drawn twice on purpose — a marquee draws its text a second time to hide the seam, and a reader given both would be read the same sentence twice with nothing to say why.

The tree

cpp
AccessibilityTree tree = buildAccessibilityTree(arena, root, interaction);
AccessibilityUpdate update = diffAccessibility(previous, tree);

Built after layout, because a node's bounds are part of what it says. Three jobs, and each is why the stage exists:

It prunes. A frame is hundreds of nodes and a dozen things. Every box that exists for layout is collapsed away and its children are re-parented, so a button wrapped in three containers is one node and not four.

It resolves. labels and describes are turned into the labelledBy and describedBy that belong on the control, and a control with no name of its own takes the caption's — which is what makes field work without every call site repeating the label. A node with no name and nothing naming it takes the text inside it, stopping at anything that is a node of its own: a listRow announces its contents, and a table does not announce every cell it holds.

It diffs. Pushing a whole tree at a screen reader sixty times a second is how an application becomes unusable with accessibility turned on. changed holds every node that is new or different — whole, because half a node is not something that can be sent — and removed the ids that have gone. Focus is reported separately, because it moves between two nodes that are otherwise identical and "the keyboard is here now" is the one message a reader must never miss.

Identity

A screen reader holds on to a node between frames and the arena does not: a NodeId names a different node next frame, or none. So an AccessibilityId is a hash of the tag — the identity scheme focus, hit testing and the animation clock already run on. Untagged nodes get one from their parent's id and their position among its accessible children, which is stable while the shape of the tree is and no weaker than the guarantee the tag scheme gives.

The consequence is the one worth having: an unchanged frame diffs to nothing, even though every node in the arena is new.

What is still missing

Named rather than discovered, which is this project's habit:

  • The platform bridge. Stage 5: AccessKit behind a single tree-and-diff model that already matches the one above, in platform/ beside SDL2 and optional the same way. Until it exists this is a tested data model, not something a screen reader can read.
  • The rest of the keyboard audit. Two are fixed — a modal confines Tab to itself and hands the keyboard back when it closes, and the colour picker's square and rails answer the arrows — but checked is a different claim from right, and only those two have been checked. Stage 6 is a conformance case per widget against the WAI-ARIA pattern for it.
  • richEditor does not report the marks under the caret — bold, the heading level, which list a block is in. That is a property of a position rather than of a node, and it wants a text-range interface that comes after stage 4.
  • Nothing reads the system's reduced-motion or font-size settings. Stages 9 and 10.

Released under the LGPL-3.0-or-later licence.