Semantics and the accessibility tree.
A screen reader never looks at your page. It asks the operating system about a second, quieter tree that the browser keeps beside the DOM, where every node says what it is, what it is called and what state it is in. Write HTML that fills that tree honestly and most of accessibility follows; paint a control out of divs and the tree records nothing but text.
The idea.
Two controls can look identical on screen and be completely different things to a screen reader. What decides it is a tree you never see, built from the elements you chose.
At the bottom of Railmoor's passenger details page sits a teal bar that says Continue to seats. A sighted traveller with a mouse sees a button and clicks it. Now picture the same page for someone who is blind and uses a screen reader, someone who steers the computer by voice ("click Continue"), and someone on a single switch that steps through the controls one at a time. None of them is looking at that teal bar. Each of their tools asks the operating system: what is on this page, what can I act on, and what is each thing called?
The operating system answers from the accessibility tree, a structure the browser derives from the DOM and keeps up to date as the page changes. It is a pruned copy: wrapper divs that only exist for layout disappear, and each remaining node carries a small record. A role says what kind of thing it is (button, link, checkbox, heading). A name says what it is called (Continue to seats). States and properties say how it is right now (expanded, checked, disabled, invalid), and a value holds what the user typed or picked.
If Railmoor's developer wrote that bar as a div with a click handler, the tree records a generic node that happens to contain some text. Nothing says it can be pressed, the Tab key never stops on it, and a voice command for "Continue" finds no control to click. Written as a button, the same pixels become a node with role button and name Continue to seats, which the browser also makes focusable and activatable from the keyboard. The visual design did not change at all; the meaning did.
The same teal bar, written three ways
<div class="cta cta--primary" onclick="goToSeats()">
Continue to seats
</div>
<!-- role and tabindex make the tree say "button"…
but Enter and Space still do nothing: ARIA adds no behaviour -->
<div class="cta cta--primary" role="button" tabindex="0"
onclick="goToSeats()">
Continue to seats
</div>
<form action="/booking/seats" method="post">
…
<button class="cta cta--primary" type="submit">
Continue to seats
</button>
</form>
What the accessibility tree records for each version
| Version | Role | Name | Reached by Tab | Enter / Space | A screen reader says |
|---|---|---|---|---|---|
| div + onclick | generic | (none) | No | Nothing | Only the words, as plain text while reading the page; never "button" |
| div role=button tabindex=0 | button | "Continue to seats" | Yes | Nothing, unless you add a keydown handler | "Continue to seats, button", then the key press fails |
| button type=submit | button | "Continue to seats" | Yes | Submits the form | "Continue to seats, button" |
The four things every control must answer
The requirement behind it
Railmoor's contract says the site must meet WCAG 2.2 at level AA, the level that accessibility regulations and contracts most often name. WCAG is organised under four principles, often shortened to POUR: content must be perceivable (it can be seen, heard or felt in some form), operable (every function works with the input the person has), understandable (labels, errors and behaviour make sense) and robust (it works with today's and tomorrow's browsers and assistive technology). Under them sit 13 guidelines and, under those, testable success criteria at three levels.
Level A is the floor, AA adds the criteria most products are held to, and AAA is the strictest. Conforming at AA means meeting every A and every AA criterion on the page; the W3C itself advises against demanding AAA for an entire site, because some content cannot meet all of it. This topic owns the criteria about structure and naming: 1.1.1 text alternatives, 1.3.1 info and relationships, 2.4.6 headings and labels, 3.3.1 and 3.3.2 errors and labels, and above all 4.1.2 Name, Role, Value, which is the accessibility tree written as a rule.
POUR on Railmoor's booking journey
| Principle | Asks | Where Railmoor meets it | Topic |
|---|---|---|---|
| Perceivable | Can every user get the information in a form they can sense? | Alt text on the route map; seat status in words, not only in colour; contrast of the teal bar | This topic (text alternatives); contrast in Announcing changes and preferences |
| Operable | Can every user drive every function? | Continue reachable and pressable from the keyboard; seat map crossed with arrow keys | Keyboard access and focus management |
| Understandable | Do labels, errors and behaviour make sense? | Every field labelled; an error says what is wrong and how to fix it | This topic |
| Robust | Can browsers and assistive technology parse it reliably, now and later? | Native elements first; correct role, name and state on every custom widget (4.1.2) | This topic; old kiosk browsers in Browser and device support |
How it works.
The browser turns markup into a tree of roles, names and states; the operating system hands that tree to assistive technology; and every source of a name has a fixed rank.
From the DOM to the accessibility tree
Who reads what, from markup to speech
4 steps. Step through to see what one change does to these components.
Railmoor's passenger details page as the accessibility tree
RootWebArea "Passenger details · Railmoor"
banner
link "Railmoor home"
navigation "Booking steps"
list
listitem link "1 Journey"
listitem StaticText "2 Passengers" (current step)
listitem StaticText "3 Seats"
listitem StaticText "4 Payment"
main
form "Who is travelling?"
heading "Who is travelling?" level=1
group "Passenger 1 (adult)"
textbox "First name" required value="Amara"
textbox "Surname" required value=""
textbox "Email for your tickets" required invalid
description="Enter an email address like name@example.com"
checkbox "I need a wheelchair space" checked=false
button "Continue to seats"
contentinfo
link "Accessibility statement"
<label for="email">Email for your tickets</label>
<input id="email" type="email" name="email"
autocomplete="email" required
aria-invalid="true" aria-describedby="email-error">
<p id="email-error" class="field-error">
Enter an email address like name@example.com
</p>
What reaches the tree and what does not
| Markup or CSS | In the tree? | Why it matters |
|---|---|---|
| <div class="row"> with no role or name | Pruned (generic, ignored) | Wrappers cost nothing to assistive technology; you do not have to avoid divs, only avoid using them as controls |
| display: none, the hidden attribute | Removed with its subtree | Closed menus and inactive tabs vanish for everyone, which is usually what you want |
| visibility: hidden | Removed with its subtree | Same as display: none for assistive technology, though it still takes up space |
| aria-hidden="true" | Removed with its subtree, still visible | For decorative duplicates (an icon beside its own text). On anything focusable it creates a control that Tab reaches and the screen reader cannot name |
| .visually-hidden (clipped to 1 px) | Kept | The standard way to give a screen reader text that sighted users do not need, such as "(opens in a new tab)" |
| opacity: 0 | Kept | Invisible but still read and still focusable: a common source of ghost controls |
| <img alt=""> | Pruned (presentation) | The right choice for a purely decorative image |
| inert | Removed, and cannot be focused or clicked | The background behind a modal; see Keyboard access and focus management |
How a name is computed
A control can carry several possible names at once: a label element, an aria-label, the words inside it, a title tooltip, a placeholder. The browser does not merge them. It walks a fixed list of sources from strongest to weakest and stops at the first one that produces text. That list is defined by the Accessible Name and Description Computation spec (AccName) and, for HTML elements, by the HTML Accessibility API Mappings.
In order: aria-labelledby (text pulled from other elements by id, even hidden ones), then aria-label (a string on the element itself), then the native label the host language provides (a label element for an input, alt for an image, legend for a fieldset, caption for a table), then the element's own content, but only for roles that may be named that way (buttons, links, headings, options, cells; never text boxes or landmarks), then the title attribute, and for text inputs the placeholder as a last resort. The stronger sources override the weaker ones silently, which is how a well-meant aria-label can hide a perfectly good visible label.
Which source wins the name, control by control
- aria-labelledby, width 1, Not present
- aria-label, width 1, Not present
- label / alt / legend, width 1, Gives the name
- content, width 1, Not a source for this role
- title, width 1, Not present
- placeholder, width 1, Not present
- Gives the name
- Present but ignored
- Not present
- Not a source for this role
As it starts. 6 steps follow.
The same sources in markup
<!-- label element: the default for every form field -->
<label for="email">Email for your tickets</label>
<input id="email" type="email" placeholder="name@example.com">
<!-- content: buttons and links are named by their text -->
<button type="submit">Continue to seats</button>
<!-- aria-label: an icon-only control has no text to use -->
<button type="button" aria-label="Swap From and To">
<svg aria-hidden="true" focusable="false">…</svg>
</button>
<!-- aria-labelledby: reuse visible text that lives elsewhere -->
<dialog aria-labelledby="summary-title">
<h2 id="summary-title">Your booking</h2>
…
</dialog>
<!-- alt: the host language's label for an image -->
<img src="/maps/kellbridge-ashcombe.svg"
alt="Route map: Kellbridge Central to Ashcombe Junction, 4 stops">
Name, description and the words in between
| Property | Comes from | When the screen reader says it | Railmoor example |
|---|---|---|---|
| Name | aria-labelledby, aria-label, label, content, title | Every time the control is reached or listed | Email for your tickets |
| Role | element, or role= | Right after the name | edit text, button, checkbox |
| States | required, checked, aria-expanded, aria-invalid… | After the role, and again whenever they change | required, invalid entry |
| Description | aria-describedby (or aria-errormessage when invalid) | After a short pause, or on request; some users turn it off | Enter an email address like name@example.com |
Roles come from elements; ARIA only relabels them
What a native element gives you for nothing
| Element | Implicit role | Also built in |
|---|---|---|
| <button> | button | Focusable; Enter and Space activate; disabled takes it out of the Tab order and is announced as unavailable or dimmed; submits its form |
| <a href> | link | Focusable; Enter follows; middle-click, copy link and "list links" in screen readers. Without href it is generic |
| <input type="checkbox"> | checkbox | Space toggles; checked state announced on change; label click toggles it too |
| <select> | combobox (single) or listbox | Keyboard and type-ahead selection; the platform picker on phones and kiosks |
| <h1> … <h6> | heading, level 1 to 6 | Appears in the headings list; level drives the outline |
| <nav>, <main>, <aside> | navigation, main, complementary | Appear in the landmarks list; one keystroke jumps to each |
| <ul>, <ol> | list | "List, 3 items": the user hears how much is coming |
| <table> with <th> | table, columnheader, rowheader | Cell navigation with each cell announced with its headers |
| <dialog> via showModal() | dialog | Top layer, inert background, Escape to close (focus handling in Keyboard access and focus management) |
The rules of ARIA, in practice
Landmarks and headings are the page's table of contents
Sighted users skim: their eyes jump to the big heading, the seat map, the price box. Screen reader users skim too, through lists the screen reader builds from the tree. A landmarks list holds the page's regions (banner, navigation, main, complementary, contentinfo, plus any named form or region), and a single key moves between them. A headings list shows every heading with its level, and another key jumps heading by heading. Both lists exist only if the markup created them.
Two details trip people up. A header or footer element is the page's banner or contentinfo only when it is scoped to the body; nested inside main, article, aside, nav or section it is no longer a page landmark. A section or form element becomes a landmark only once it has an accessible name, usually through aria-labelledby pointing at its heading. For headings, keep one h1 for the page's purpose, never skip a level going down (an h2 followed by an h4 suggests a missing section), and choose the level for structure, not for font size, which CSS can set independently.
Railmoor's seat page as landmarks and headings
3 steps. Step through to see what one change does to these components.
Forms: labels, groups and errors
Railmoor's passenger details, as the screen reader hears it
Each label is a <p> above its input, not a <label>. It looks identical, but the tree has no link between them, so the email box falls back to its placeholder and the date box to DD/MM/YYYY. The placeholder stays the name after typing, but its text vanishes from view, so sighted users lose the label too.
Labels as styled text. Form “Who is travelling?”. Fields: First name = “Amara”; Surname = “Okonjo”; Email for your tickets = “name@example.com” (focused); Date of birth = “DD/MM/YYYY” (Needed for the senior railcard discount); I need a wheelchair space: unchecked. Button: “Continue to seats”. Links: Why we ask for this. Note on email: textbox "name@example.com" (placeholder used as name) Note on dob: textbox "DD/MM/YYYY"
- Label, autocomplete and error link5Passenger details form
- Native checkbox: role and state free5Passenger details form
- button "Continue to seats"5Passenger details form
The passenger form's semantics
<form aria-labelledby="who-title" novalidate>
<h1 id="who-title">Who is travelling?</h1>
<fieldset>
<legend>Passenger 1 (adult)</legend>
<label for="first">First name</label>
<input id="first" name="first" autocomplete="given-name" required>
<label for="surname">Surname</label>
<input id="surname" name="surname" autocomplete="family-name" required>
<label for="email">Email for your tickets</label>
<input id="email" name="email" type="email" autocomplete="email"
required aria-describedby="email-error">
<p id="email-error" class="field-error" hidden></p>
<label for="dob">Date of birth</label>
<input id="dob" name="dob" autocomplete="bday" inputmode="numeric"
aria-describedby="dob-hint">
<p id="dob-hint">Day, month and year, for example 27 03 1958.
Needed for the senior railcard discount.</p>
<input id="wheelchair" type="checkbox" name="wheelchair">
<label for="wheelchair">I need a wheelchair space</label>
</fieldset>
<button type="submit">Continue to seats</button>
</form>
// Tie a server-side message to its field so it is heard, not only seen.
export function showFieldError(id: string, message: string | null) {
const input = document.getElementById(id) as HTMLInputElement;
const note = document.getElementById(`${id}-error`)!;
if (message) {
note.textContent = message; // words, not a red border alone
note.hidden = false;
input.setAttribute('aria-invalid', 'true');
} else {
note.textContent = '';
note.hidden = true; // emptied too: a hidden node is still read when aria-describedby points at it
input.removeAttribute('aria-invalid');
}
}
Form semantics, field by field
| Need | Use | Why | WCAG |
|---|---|---|---|
| A name for every field | <label for> | Visible, clickable, survives typing; placeholder is a hint only | 1.3.1, 3.3.2, 4.1.2 |
| A name for a group | <fieldset> + <legend> | Radio sets and repeated passenger blocks: "Passenger 2, First name" instead of three identical "First name" fields | 1.3.1 |
| A hint or format | aria-describedby | Read after the name, kept short | 3.3.2 |
| An error on one field | aria-invalid="true" + aria-describedby (or aria-errormessage) | The field is announced as invalid with its message; aria-errormessage applies only while aria-invalid is true | 3.3.1, 3.3.3 |
| A summary of errors | heading or banner with links to fields | One place to hear every problem after submit (moving focus there is a keyboard topic) | 3.3.1 |
| Known personal data | autocomplete="email", "given-name", "bday" | Browsers fill it and assistive tools can recognise what the field is for | 1.3.5 (AA) |
Text alternatives for Railmoor's images
| Image | Kind | Markup |
|---|---|---|
| Hero photo of a train in a valley | Decorative: adds mood, no information | alt="" |
| Route map Kellbridge Central → Ashcombe Junction | Informative: carries the stops | alt="Route map: Kellbridge Central to Ashcombe Junction, 4 stops" plus the stops as a list on the page |
| Arrows icon on the swap button | Functional: the only content of a control | aria-label on the button, icon aria-hidden="true" |
| Wheelchair symbol next to "I need a wheelchair space" | Redundant: the text says the same | aria-hidden="true" (or alt="") |
| Coach C floor plan with seat numbers | Complex: a picture of interactive data | Not an image at all: a grid of real seat buttons (in practice, below) |
In practice.
Railmoor's booking flow, audited the way a screen reader meets it: what the tree says for each control, what is wrong, and the smallest fix. Then the two custom widgets, and how to bring all of this into an interview answer.
An audit of the booking flow
Ten findings on Railmoor's journey, from search to seats
| Where | The tree says | Problem | Fix | WCAG | Rule engine finds it? |
|---|---|---|---|---|---|
| Search: swap stations | button "" | Icon-only button with no name: heard as "button" | aria-label="Swap From and To"; icon aria-hidden | 4.1.2 | Yes: a missing name is mechanical |
| Search: route map | image "kb-ash-v2.svg" | No alt, so some screen readers read the file name | alt with the route and stop count; stops also as a list | 1.1.1 | Yes for a missing alt |
| Results: train cards | heading level 1, then level 4 | Card titles are h4 under the page h1: levels 2 and 3 skipped | h2 per train card | 1.3.1 | Yes: heading order is a structural check |
| Passengers: date of birth | textbox "DD/MM/YYYY" | Placeholder used as the name; its text vanishes from view once typing starts | <label for="dob">Date of birth</label>, format as a hint | 3.3.2, 4.1.2 | Partly: a name exists, so many rules pass it |
| Passengers: email error | textbox "Email for your tickets" | Red text under the field is not linked; the field is not marked invalid | aria-invalid plus aria-describedby to the message | 3.3.1 | No: the message exists, the missing link needs a human |
| Passengers: Continue | generic, text "Continue to seats" | A div with onclick: no role, no Tab stop | <button type="submit"> | 4.1.2, 2.1.1 | Rarely: nothing marks a click handler in markup |
| Seats: each seat | gridcell "3D" | Taken and free seats differ only by colour; name has no position or status | Name "Seat 3D, window, available"; aria-disabled on taken seats | 1.3.1, 1.4.1 | No: the name is present, its meaning needs judgement |
| Seats: chosen seat | gridcell "Seat 3D…" (no state) | Choosing a seat changes the colour only; no selected state | aria-selected="true" on the chosen cell | 4.1.2 | No |
| Seats: Review booking | button "Next" | aria-label="Next" overrides the visible text Review booking; voice users saying the visible words miss it | Delete the aria-label | 2.5.3 | Sometimes: label-in-name checks exist but are heuristic |
| Delay banner close | button "Close" inside aria-hidden | Close button is focusable inside an aria-hidden wrapper: a nameless Tab stop | Remove aria-hidden from the banner | 4.1.2 | Yes: focusable content inside aria-hidden |
Four of the ten are mechanical: an attribute is missing or a rule about nesting is broken, so a rule engine in CI catches them every time. The rest need someone to decide whether a name is meaningful, whether a colour carries information, or whether a visible message is actually wired to its field. That split is typical, and it is why the trade-offs section below treats automated checks as a floor rather than a verdict.
The seat map: a grid that says what each seat is
Coach C, row 3, before and after
<div class="coach">
<div class="seat-row">
<span class="seat seat--taken">3A</span>
<span class="seat seat--free" onclick="pick('3B')">3B</span>
<span class="aisle"></span>
<span class="seat seat--free" onclick="pick('3C')">3C</span>
<span class="seat seat--chosen" onclick="pick('3D')">3D</span>
</div>
…
</div>
<h2 id="coach-c">Coach C</h2>
<div role="grid" aria-labelledby="coach-c">
<div role="row">
<button role="gridcell" aria-disabled="true" tabindex="-1"
aria-label="Seat 3A, window, taken">3A</button>
<button role="gridcell" aria-selected="false" tabindex="-1"
aria-label="Seat 3B, aisle, available">3B</button>
<button role="gridcell" aria-selected="false" tabindex="-1"
aria-label="Seat 3C, aisle, available">3C</button>
<button role="gridcell" aria-selected="true" tabindex="0"
aria-label="Seat 3D, window, available">3D</button>
</div>
…
</div>
<!-- Which seat holds the single tab stop, and the arrow keys,
are covered in Keyboard access and focus management. -->
grid "Coach C" (8 rows, all in the DOM)
row (row 3)
gridcell "Seat 3A, window, taken" disabled
gridcell "Seat 3B, aisle, available" selected=false
gridcell "Seat 3C, aisle, available" selected=false
gridcell "Seat 3D, window, available" selected=true focusable
Choosing seat 3D with a screen reader
- Screen reader → Platform accessibility API: focus moved: which node?
- Browser accessibility tree → Screen reader (reply): gridcell "Seat 3D, window, available", not selected, row 3 of 8
- Note over Screen reader: speaks it; the traveller presses Space
- Screen reader → Platform accessibility API: action: press
- Platform accessibility API → Seat map (coach C): click on button 3D
- Note over Seat map (coach C): sets aria-selected="true" on 3D, false on the old seat
- Seat map (coach C) → Browser accessibility tree: attribute changed
- Browser accessibility tree → Screen reader (reply): state-change event: selected
- Note over Screen reader: "selected"
The station field: what the combobox must say
Railmoor serves about 400 stations, so From and To are text boxes that suggest stations as the traveller types. HTML has no element for that, so this is where ARIA earns its place, following the combobox pattern in the ARIA Authoring Practices Guide. The semantic part is small but every piece must stay in step with the screen: the input is a combobox, it says whether its popup is open, it points at the listbox it controls, and it names the option currently highlighted. Which keys move the highlight is in Keyboard access and focus management; dropping stale responses while the traveller types is in the search-with-autocomplete flow.
From, while the traveller has typed "ash"
<label for="from">From</label>
<input id="from" type="text" role="combobox"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="from-stations"
aria-activedescendant="from-opt-2"
aria-describedby="from-count">
<p id="from-count" class="visually-hidden">3 stations match</p>
<ul id="from-stations" role="listbox" aria-label="Stations">
<li id="from-opt-1" role="option" aria-selected="false">Ashcombe Junction</li>
<li id="from-opt-2" role="option" aria-selected="true">Ashford Lanes</li>
<li id="from-opt-3" role="option" aria-selected="false">Rashleigh Halt</li>
</ul>
combobox "From" expanded=true autocomplete=list value="ash"
controls → listbox "Stations"
activedescendant → option "Ashford Lanes"
listbox "Stations"
option "Ashcombe Junction" selected=false (1 of 3)
option "Ashford Lanes" selected=true (2 of 3)
option "Rashleigh Halt" selected=false (3 of 3)
Every attribute is a fact that can go stale
| Attribute | Must equal | If it drifts |
|---|---|---|
| aria-expanded | Whether the list is on screen | The list is open but announced collapsed, so the user never looks for it |
| aria-controls | The id of the listbox | A renamed id points at nothing; some screen readers lose the link to the list |
| aria-activedescendant | The id of the highlighted option, or absent | Arrowing moves the highlight but the screen reader keeps reading the old station |
| aria-selected on options | The highlighted option only | Two options claim to be selected after a re-render |
What does the screen reader say here?
Bringing it into a front-end system design answer
Moves that show you design for the tree, not just the pixels
Where the system flows use this
| Flow | Widget | The semantics to name |
|---|---|---|
| Pinterest: search with autocomplete | Search suggestions | combobox + listbox, aria-expanded, aria-activedescendant |
| Instagram: email and password login | Login form | label per field, autocomplete tokens, aria-invalid + describedby |
| Amazon: checkout and payment | Address and card forms, error summary | fieldset/legend, error links, named regions |
| Airbnb: date range picker | Calendar | grid of days with full-date names, selected state |
| Notion: page tree | Sidebar tree | tree, treeitem, aria-expanded, aria-level |
Trade-offs.
Native elements are cheap and correct but limited in look and behaviour; custom widgets are flexible but every role, state and key becomes your code to keep true. And no single test tells you the tree is right.
- Pro:Filtering by typing suits a list of 400
- Pro:Can show recent stations, codes and "did you mean"
- Pro:A well-documented pattern with known screen reader support
- Con:Roles, states and the keyboard contract are all Railmoor's code
- Con:Needs testing in every screen reader and browser pair Railmoor supports
Scrolling 400 entries is slow for everyone; No filtering, no recent stations, no secondary text
Matching rules and presentation differ by browser; Cannot style the suggestions or add station details
Ships from Chrome 135 (April 2025): the kiosks' frozen Chromium 128 does not have it; Still a select: no type-to-filter for 400 items
- Pro:Zero ARIA to keep in sync
- Pro:Works the same on the old kiosk browsers
- Pro:Keyboard and screen reader behaviour users already know
- Con:Limited styling of the open list in older browsers
About a dozen attributes and key handlers to get right; Every redesign risks breaking them; More to test for six static choices
How much a page full of barriers each tool caught
Data
| Result | % of barriers |
|---|---|
| Best single tool | 41 |
| Weakest single tool | 17 |
| All ten combined | 71 |
| Found by no tool | 29 |
A testing stack for semantics, cheapest first
| Layer | Catches | Misses |
|---|---|---|
| Lint rules in the editor (JSX accessibility plugins) | Missing alt, onClick on a div without a role, invalid ARIA attributes, while typing | Anything decided at runtime |
| Rule engine in CI on rendered pages | Missing names, broken references, heading order, ARIA on the wrong element | Whether a name, alt or heading is meaningful; colour-only meaning |
| Component tests that query by role and name | A control that lost its role or name in a refactor; states after an interaction | How real screen readers phrase and navigate it |
| DevTools accessibility tree | The computed name and the source that won, when a name surprises you | Nothing automatically; it is an inspection tool |
| Manual screen reader passes (NVDA or JAWS on Windows, VoiceOver on macOS and iOS, TalkBack on Android) | Wrong reading order, noisy or missing announcements, patterns that technically pass but confuse | Costs time per release; needs practice to judge |
| Sessions with disabled users | Whether the journey actually works for people | Slowest and most expensive; do it for the key flows |
A component test that fails when the tree breaks
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PassengerForm } from './PassengerForm';
test('the email error is announced with the field', async () => {
render(<PassengerForm />);
const email = screen.getByRole('textbox', { name: 'Email for your tickets' });
await userEvent.type(email, 'amara@');
await userEvent.click(screen.getByRole('button', { name: 'Continue to seats' }));
expect(email).toHaveAttribute('aria-invalid', 'true');
expect(email).toHaveAccessibleDescription(
'Enter an email address like name@example.com',
);
});
// If Continue becomes a div again, getByRole('button') throws and the test fails.
How semantics drift after launch
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| aria-expanded or aria-activedescendant not updated after a refactor7Station combobox | The list is open but announced as collapsed, or arrowing reads the wrong station | A component test that asserts the attributes after each key; manual pass on the search page | Derive every ARIA attribute from the same state that drives the visuals | Sighted keyboard users are fine; screen reader users type the full station name blind |
| A redesign changes seat colours and drops the text status6Seat map (coach C) | Taken and free seats sound identical; travellers pick seats that cannot be booked | Review checklist for colour-only meaning; tests on the seat name | Keep status in the name and aria-disabled, with the colour as decoration | Booking fails at the payment step instead of at the seat |
| A design-system Input wrapper stops forwarding id, so labels no longer point at inputs5Passenger details form | Every field falls back to its placeholder or to no name | getByRole with name fails across the suite; the CI rule engine flags unlabelled fields | The Input component owns the label-to-input link and generates the id itself | Mouse users see no change at all, so nobody files a bug |
| An aria-label added for tracking or testing overrides the visible text1Railmoor's HTML and ARIA | Screen readers hear a different name from the one on screen; voice commands miss it | Label-in-name checks; reviewing aria-label in code review | Prefer visible text; use data attributes for tests and analytics, not aria-label | The control still works by keyboard and mouse |