Dialog — accessibility
Covers all three variants: plain, confirm and window states.
Keyboard
- Escape closes the modal window. Several open dialogs share one
windowlistener (escStackinDialog.tsx) and the topmost answers, so Escape never closes the window underneath. In the confirm variant Escape answersfalse. - One Escape closes one layer. A picker panel open inside a dialog (
PickerPanel— date, date-time, menus) takes the first Escape and marks it handled (preventDefault); the dialog listener ignores a handled event (defaultPrevented), so the second Escape closes the dialog. Never two layers at once — the user would lose what they typed (acrm !4454, 2 Oct 2026). - Focus trap: Tab and Shift+Tab stay inside the modal panel (a
keydowntrap onwindow, not sentinel elements). - On open, focus moves into the panel: the first
[autofocus],input,textareaorselect, otherwise the panel itself (tabIndex={-1}). - On close, focus returns to the element that opened the window — but only when focus had fallen to
<body>; if the screen moved it elsewhere, the dialog does not take it back. - Confirm: with no field in the body focus lands on the panel, and Tab reaches
Zrušitand the confirming button. There is no Enter shortcut for the confirming action and the confirming button is not autofocused — a destructive answer must not be one keypress away. That is why confirm carries nokbdchip. - Window states — compact: Escape does not close the window (the modeless contract); the window stops the key from bubbling so it cannot close a drawer underneath, and lets it through when a modal is open. Min: the dock bar is a
<button>in the tab order; Enter restores the window,✕closes it. Window controls.acrm-dialog-wbtnare buttons reachable by Tab.
ARIA
- Panel:
role="dialog",aria-modal="true"in the modal state only (dropped for a compact window, so it is not announced as modal),aria-label= the title. - Background is inert: while a modal is open, every sibling on the path from the scrim up to
<body>getsinert, so neither the mouse nor a screen reader reaches the page underneath. A modal opened on top of another modal leaves the inner one'sinertin place. - The header icon is decorative; the accessible name is the title text, which is why an icon never replaces it. In confirm, destructiveness is carried by the words and the button label, never by the icon alone.
- The close button has both
titleandaria-label(Zavřít). It and the window controls are 24 px IconButtons whose hit area is widened by a pseudo-element to 32 px (mouse) and 44 px (touch) — the WCAG 2.5.8 target minimum holds even though the drawn box is small. - A minimised window keeps its content mounted and merely
hidden, so assistive technology sees the dock bar instead of a duplicate. The dock is arole="region"container in<body>; each bar is a pair of buttons withtitleandaria-label. - Nothing announces a window state change, and nothing announces that a confirm closed.
Focus
- Foundation ring from
ds.cssapplies to every control in the dialog, the window controls and the dock bars:outline: 2px solid var(--accent); outline-offset: 1pxon:focus-visible. - The panel itself takes focus without a visible ring (
tabIndex={-1}) — deliberate: it is a target for the initial focus move, not a control. - Autofocus goes to the first field, so a form can be typed into straight away.
- Minimising does not move focus: it stays on the control inside the now-hidden panel and the browser drops it to
<body>. Restoring re-runs the focus move into the panel. The dock sits at the end of<body>, so Tab reaches a minimised window only after the whole page.
Contrast
Computed from packages/ui/src/tokens/tokens.css:
- Panel
--surface-100with body text--fg= 17.59:1 light, 14.53:1 dark. Confirm question text and dock bar title--ink-900the same. Zrušitdefault button text--ink-700on--surface-100= 12.99:1 light. The destructive button's label sits on the critical fill, not on the panel.- Header and footer hairlines
--border— below the 3:1 non-text minimum (system-wide limit); on the dock the shadow does the separating. - Footer status
.leadand window control glyphs--fg-mutedon--surface-100= 2.85:1 light, 3.63:1 dark — below AA for text and below 3:1 non-text in light theme. - Scrim
--ov-scrimis a translucent ink layer; it carries no text. - Focus ring
--accenton--surface-100= 4.44:1 light, 3.83:1 dark — passes the 3:1 non-text minimum.
Known limits
- Scroll lock is on
<html>, not<body>(iOS Safari ignores it on body) — a page that scrolls an inner container instead of the document is not locked. - The panel is deliberately not portalled —
inertis applied along its own path to<body>— so a host withtransform,filterorcontainchanges where afixeddialog is placed. - Window state changes are silent (no live region), so a screen-reader user can lose a minimised window; focus is dropped to
<body>on minimise. - A dismissed confirm announces nothing — a screen-reader user who pressed Escape gets no confirmation that nothing happened.
- The confirm labels are app-supplied; an app that forgets to translate them ships English or empty buttons, there is no fallback in the component.
- Footer status text and window control glyphs sit below AA in the light theme.
GlobalSearch.tsxdraws its own scrim instead of<Dialog>, so it has none of the above guarantees — flagged for clean-up.prefers-reduced-motionis honoured for the dock and the scrim animation.
Source: docs/dialog/dialog.a11y.md · GitLab