Accessibility
Status: ✅ Implemented
Intent
Section titled “Intent”In a model-driven framework the developer does not write the HTML. That is usually the problem with accessibility — there is nowhere to fix it — but here it is the lever: the generator is the only place it has to be right, and fixing it fixes every app at once, including the ones already written, without a line of user code changing.
So none of what follows is opt-in. You get it by using Mateu.
What you get without doing anything
Section titled “What you get without doing anything”Field labels, required state and help text are wired by the design system’s own components, so a screen reader announces each control properly.
Validation is announced. When the server rejects a save, the message is set ON the control (invalid + errorMessage), which is what makes the design system emit aria-invalid and aria-describedby inside its shadow root — something no markup outside that boundary could do. The error is also announced through a live region, and the focus moves to the first rejected field, so the user is taken to the problem instead of being told there is one somewhere.
Before this, errors rendered as a detached
<ul>beside the field. Visible, but with no relationship to the input: the control still saidaria-invalid="false", nothing pointed at the message, and a screen-reader user pressing Save heard nothing at all.
Overlays
Section titled “Overlays”Dialog and Drawer are real modals: role="dialog", aria-modal, and they own the focus while open — the focus moves in, Tab and Shift+Tab cycle inside, and closing returns it to the control that opened it. A modeless or layout drawer deliberately does none of this, because it sits alongside the page rather than over it.
Keyboard
Section titled “Keyboard”Every widget Mateu makes clickable is also operable: task-queue and kanban cards, calendar events, gantt bars, tree nodes, FAQ questions, timeline entries and the rest carry a role, a tab stop, Enter/Space activation and a visible focus ring.
Focus rings matter as much as reachability: an element you can Tab to but cannot see is barely better than one you cannot reach. Style them yourself if you want — every such element carries a role you can target:
[role="button"]:focus-visible { outline: 2px solid var(--brand-accent); }Orientation
Section titled “Orientation”- Skip to content (WCAG 2.4.1) is the first thing Tab reaches, so the navigation is not re-read on every screen.
- The content area is a
mainlandmark. - Navigating announces where you landed — the page title goes into a live region, so a single-page route change is not silent.
- After navigating, the focus moves to the new content’s heading instead of staying on the menu link you just clicked. Only on real navigations: a re-render never steals the focus out of a field you are editing.
Announcements
Section titled “Announcements”Two live regions exist from boot (created up front on purpose — a region filled in the same tick it is created is frequently never announced). Error toasts are assertive and interrupt; everything else is polite and waits for a pause.
Verifying it
Section titled “Verifying it”Two halves, because no single tool sees both:
cd e2e && npx playwright test tests/shared/accessibility.spec.tscd e2e && npm run a11y # ad-hoc audit over a set of routes- axe-core checks the markup rules (labels, contrast, roles, names) on eight representative routes covering every shape Mateu generates. It runs as part of the shared e2e suite, so every framework adapter gets the same guarantee and a regression fails the build.
- The rest checks behaviour — focus trapping, keyboard operability, live regions, focus-on-error — which axe cannot evaluate at all. A
<div @click>is invisible to axe: it has no way to know the div was meant to be a button. This is exactly where a hand-rolled widget fails, so it is exactly what is pinned.
One known exclusion
Section titled “One known exclusion”vaadin-tabs is role="tablist" and puts a <div part="tabs" tabindex="-1"> inside its own shadow root; axe counts that as a child the role does not allow. Nothing in the wire model or in any Mateu renderer can change it — the fix belongs upstream. The carve-out is scoped to that one rule on that one element, so a real tablist mistake still fails the build.
Native renderers
Section titled “Native renderers”The same guarantees, expressed in each platform’s own accessibility API rather than in ARIA.
React Native (iOS & Android)
Section titled “React Native (iOS & Android)”Mobile screen readers read an accessibility tree that React Native builds only from explicit props — there is nothing to infer it from. Two differences from the web matter:
- A
TextInputcannot be associated with a nearby label. There is nolabelFor, noaria-labelledby. So the field’s name is handed to the control itself:"Name, required", and"Name, required, invalid: Cannot be empty"once the server rejects it. The error goes in the NAME, not the hint — hints are announced last and can be switched off entirely in the OS settings, so a message the user must hear cannot live there. - A tappable is accessible by default but is not announced AS a button. Every one now carries a role; icon-only ones (”✕”, ”➤”, ”💬”) carry an explicit label, since otherwise the glyph is all the user hears.
Overlays are marked modal, headings are marked as headings, and a refused save or a completed
navigation is spoken through AccessibilityInfo.announceForAccessibility — the mobile equivalent
of the web renderers’ live region.
Verify it with the probe (React Native for Web maps the props onto their DOM equivalents, which is what makes this checkable without a device farm):
cd frontend/app/react-native && EXPO_PUBLIC_MATEU_BACKEND_PORT=8080 npx expo start --web --port 19006cd e2e && RN_SUBMIT=1 node rn-a11y-probe.mjsIntelliJ plugin (desktop)
Section titled “IntelliJ plugin (desktop)”Desktop screen readers read Swing’s AccessibleContext, which Swing populates almost not at all.
- The caption is bound to the control it names (
setLabelFor) — the Swing analogue of<label for>. Without it the field is announced as an unnamed edit box and the label reads as stray text. - Composite fields name their inner control too. A date field is a text box plus a button, a stepper is a spinner wrapping a formatted field; the screen reader follows the FOCUS, which lands inside, so a name on the wrapper alone is never read.
- Required state and
@Helpare spoken, not just drawn as an asterisk and a grey caption, and a validation error is folded into the control’s own description. - Icon-only buttons carry names, and the date picker’s trigger is now focusable — browsing months used to be mouse-only.
Verify it with the render probe, which prints the accessibility tree beside the visual one:
cd frontend/app/intellij-plugin./gradlew renderProbe -Pprobe.json=<increment.json> -Pprobe.baseUrl=http://localhost:8080Each component prints a11yName, a11yDesc and labelFor — a control with no accessible name is
indistinguishable from a correct one in a screenshot, which is exactly why the probe reports it.
Redwood / Visual Builder
Section titled “Redwood / Visual Builder”This renderer shares no core with the web ones, so the guarantees are implemented again in
apps/redwood/poc/a11y.mjs (bundled into the AMD bridge like the rest of the core).
Measured before writing anything: the oj-sp-* composition comes out almost clean, for the same
reason Vaadin did — those components carry their own accessibility. Two real defects turned up
and were fixed:
- The header actions were unreadable.
AppActionsSupplierbuttons inherited the primary text colour (near-black) on Redwood’s dark global header — a contrast ratio of 1.33 where WCAG AA asks for 4.5. These are business labels, not decoration. - The menu’s expand/collapse icons had no name.
oj-navigation-listemits them as empty<a role="button">; a screen reader announced “button” without saying what for. The app names them from the group they belong to, after the list refreshes.
The SPA gaps axe cannot see are covered too: live regions created at boot, a skip link, a main
landmark, and navigation that announces the destination and moves the focus there — except on
the first load, where moving it would leave the skip link behind the starting point.
cd frontend/web/monorepo/apps/redwood && npm run serve # :9006, backend demo-vb en :9005cd e2e && node vb-a11y-probe.mjsOne scoped exclusion, same shape as the Vaadin one: oj-navigation-list puts its own
role="button" collapse icon inside the list, which axe counts as a disallowed child of the
list’s role. Only upstream can change it.
Writing accessible screens yourself
Section titled “Writing accessible screens yourself”The framework covers the generated UI. Two things are still yours:
- Labels carry meaning.
@Label("Name")is what a screen reader reads;@Helpbecomes the description. A field left to its derived name is announced by that name. - Custom fluent components. If you build a
Componenttree by hand with clickable divs, the framework cannot know they are interactive. Use the same helpers it uses —onActivatefor Enter/Space, arole, and a tab stop.
Related
Section titled “Related”- Keyboard shortcuts — action and tab shortcuts
- Slow connections — busy states also set
aria-busy