Dialog — accessibility

Covers all three variants: plain, confirm and window states.

Keyboard

  • Escape closes the modal window. Several open dialogs share one window listener (escStack in Dialog.tsx) and the topmost answers, so Escape never closes the window underneath. In the confirm variant Escape answers false.
  • 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 keydown trap on window, not sentinel elements).
  • On open, focus moves into the panel: the first [autofocus], input, textarea or select, 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šit and 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 no kbd chip.
  • 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-wbtn are 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> gets inert, so neither the mouse nor a screen reader reaches the page underneath. A modal opened on top of another modal leaves the inner one's inert in 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 title and aria-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 a role="region" container in <body>; each bar is a pair of buttons with title and aria-label.
  • Nothing announces a window state change, and nothing announces that a confirm closed.

Focus

  • Foundation ring from ds.css applies to every control in the dialog, the window controls and the dock bars: outline: 2px solid var(--accent); outline-offset: 1px on :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-100 with body text --fg = 17.59:1 light, 14.53:1 dark. Confirm question text and dock bar title --ink-900 the same.
  • Zrušit default button text --ink-700 on --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 .lead and window control glyphs --fg-muted on --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-scrim is a translucent ink layer; it carries no text.
  • Focus ring --accent on --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 — inert is applied along its own path to <body> — so a host with transform, filter or contain changes where a fixed dialog 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.tsx draws its own scrim instead of <Dialog>, so it has none of the above guarantees — flagged for clean-up.
  • prefers-reduced-motion is honoured for the dock and the scrim animation.

Source: docs/dialog/dialog.a11y.md · GitLab