Multi-step dialog
canonOne window that walks through steps and reshapes itself between them (width and height, on the canonical spring) instead of closing and opening another window. First case: New deal — search for the person or company first (Attio "Choose record" anatomy, with a persistent footer), then the form-less detail.
27 September 2026: the Attio rework merged (feat/acrm-322-vyber-zaznamu, main 40402b85) — DealSubjectStep gained a persistent footer (↑↓ Navigate + MorphText primary action "Select record"/"Create person"), Records/Actions sections, record type in the meta slot, and paletteWidth(phone). The window reshape moved from the 0.29s cubic-bezier curve to the canonical spring (packages/ui/src/lib/spring.ts, merged as !3957) — see the `spring` and `morph-text` subcomponents. 25 September 2026: the AD had rejected the search step as shipped (palette anatomy without header/footer) — it did not follow the brief (Attio "Choose record"); rework then went to proto/obchod-vyber-attio, now merged as above. AD approved 18 September 2026 on the prototype :3777/preview/obchod-vicekrokovy.html (ACRM-322, ACRM-323). Code: acrm !3777 (<Dialog morphKey>, dialog-morph.css, story + baseline) and !3779 (new-deal flow: DealSubjectStep, Založit osobu → NewContactDialog). Merged 18 September 2026 (!3775, !3777, !3779) — canon. 14 Oct 2026 (owner: the big palette is intentional — „v malém inline dialogu se blbě hledá“): the palette shell (PickPalette, extracted from DealSubjectStep) is the RULES §21 pattern for assigning a record — chat Přiřadit ke kontaktu, Komunikace Přiřadit k projektu, Přidat produkt, Vztahy › Přidat (acrm !5039, 0c0f21ee1). Dev check 14 Oct: 1440 = 800 px, focus in search; 390 = full height 0–844 for the §21 places; Nový obchod on phone 0–844 too (re-measured after deploy). Registry repaired 17 Oct 2026 (review F2): removed classes with no CSS definition and no TSX use on main: .gsp-cancel. Registry repaired 17 Oct 2026 (review F5): DealDialog, DealSubjectStep, NewContactDialog live in apps/web (see files), not exported by @acrm/ui; dropped from react[].
Used in 104 files · 53 areas · 120× — components/CallbackTakeoverModal, components/FilePreview, components/GlobalSearch, components/IdentityImageDialog, components/accounting, components/acl …
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),Enterpicks 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 syntheticmousemoveevents 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 duplicatesEnterfor 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"witharia-haspopup="listbox",aria-expandedwhen there are rows, andaria-activedescendantpointing at the row under the cursor; each section is anItemListwithrole="listbox"andaria-label= the section name, rows arerole="option". - Each row's accessible name includes its
content(company/domain) andmeta(Person/*Company*) slots — both render as plain text, notaria-hidden, so the type is announced along with the row, not only conveyed visually. - The footer's
↑ ↓ Navigatehint (span.lead) isaria-hidden="true"— keyboard legends aren't read, same convention askbdhints insideButton. The cursor itself is announced througharia-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'sautoFocus(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-visiblering on every control (query field, footer button); the cursor row uses theItemListcursor state, not a focus ring — the input keeps DOM focus throughout keyboard navigation of the list.
Contrast
- Rows, section labels, the
contentandmetaslots 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
↑ ↓ Navigatehint 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 fromresponsive.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 (springAnimatereturnsnull) and the content appears without a fade; the footer'sMorphTextlabel swaps with no cross-fade either.- The search step lists at most 8 rows (
recentSearch/searchlimit); there is no "load more" in the picker yet.
Source: docs/dialog/multi-step-dialog.a11y.md · GitLab