Multi-step dialog — accessibility

Everything in dialog.a11y.md holds for every step (Escape order, focus trap, inert background, scroll lock). This page covers what the steps add.

Keyboard

  • Search step: the query field has focus on open. ↓ / ↑ move the cursor over the rows (wrapping), Enter picks the row under the cursor — a record or Create person "…". The cursor resets to the first row for a new query. Moving the mouse over a row also moves the cursor there (filtered against synthetic mousemove events that arrow-key scrolling triggers, so keyboard use never gets fought by the mouse).
  • The footer's primary button (Select record / Create person, MorphText) is a normal tab stop after the query field and is disabled when nothing is active — it duplicates Enter for anyone who tabs to it instead of pressing Enter in the field.
  • Escape closes the whole flow from any step (it is one window). ⌘↵ creates the deal only in the detail step.
  • After a pick, focus lands in the detail's title field (prefilled with the picked name); after Create person, in the New person name field.

ARIA

  • The query field is role="combobox" with aria-haspopup="listbox", aria-expanded when there are rows, and aria-activedescendant pointing at the row under the cursor; each section is an ItemList with role="listbox" and aria-label = the section name, rows are role="option".
  • Each row's accessible name includes its content (company/domain) and meta (Person/*Company*) slots — both render as plain text, not aria-hidden, so the type is announced along with the row, not only conveyed visually.
  • The footer's ↑ ↓ Navigate hint (span.lead) is aria-hidden="true" — keyboard legends aren't read, same convention as kbd hints inside Button. The cursor itself is announced through aria-activedescendant, not through this hint.
  • The window keeps role="dialog"; the search step's <Dialog bare> carries the flow's title as its accessible name even though no header renders visually. The handed-over New person step is its own dialog with its own name.

Focus

  • The panel is not unmounted between in-window steps, so focus never falls to <body> there; the incoming step's autoFocus (the query field) takes it.
  • Hand-over between dialogs: the leaving dialog returns focus to the trigger only when focus fell to <body>; the arriving dialog moves focus into itself on mount, so the user lands in the new step.
  • Focus ring: the foundation :focus-visible ring on every control (query field, footer button); the cursor row uses the ItemList cursor state, not a focus ring — the input keeps DOM focus throughout keyboard navigation of the list.

Contrast

  • Rows, section labels, the content and meta slots use the palette's tokens (--fg, --label / --fg-secondary) on --surface-100 — same values as the ⌘K palette.
  • The motion carries no information: nothing is conveyed by the animation alone (the spring is the same physical curve for every reshape in the system, RULES.md §11).

Touch

  • On phone the footer's ↑ ↓ Navigate hint is hidden and the primary ("Select record" ↔ "Create person") is <Button touch>: 44px high, full width (acrm !4007, 27 Sep 2026). Any other footer button gets the 44px footer floor from responsive.css. See Button › Touch target on phone.

Known limits

  • The step change is not announced by a live region; screen-reader users learn about it from the focus move into the new step.
  • prefers-reduced-motion: the window swaps size without a transition (springAnimate returns null) and the content appears without a fade; the footer's MorphText label swaps with no cross-fade either.
  • The search step lists at most 8 rows (recentSearch/search limit); there is no "load more" in the picker yet.

Source: docs/dialog/multi-step-dialog.a11y.md · GitLab