Form-less dialog — accessibility
A label-less dialog puts its whole accessibility burden on the accessible name of each control. The icon is decoration to assistive technology; what a screen reader hears is the aria-label, so every row must carry one and it must say what the icon says.
Keyboard
⌘↵/Ctrl+↵runs the primary action from anywhere in the body — the handler sits on.fl-body, and it is guarded by the same conditions as the button (a non-empty subject, no recurrence error, a save in flight blocks it), so the shortcut can never do something the button would refuse.- Escape closes the window through the shell's
escStack, and only the topmost dialog answers. - Every row is a real control, so the body is fully reachable by Tab:
.fl-whenand.fl-in.menuare<button>s, the closed note is abutton.fl-in, and the rest areinput/textarea. Rows are not divs with click handlers. - The value inputs are borderless but focusable, and
.fl-row:has(input.fl-text:focus)tints the whole row, so the caret's position is visible even without a field box. - Missing: the availability panel is mouse-only.
.fla-trackpicks a time from a click and is markedrole="presentation", so there is no keyboard path to choose a slot from the day and the axis is hidden from assistive technology altogether. The same time can still be typed into the when-row, which is why this is a limit and not a blocker. - Enter in the name moves to the description (shape A) — a field change, like Tab, so the reader keeps both hands on the keyboard. Only a bare Enter:
⌘/Ctrl+↵still creates the record and Shift/Alt stay free, otherwise the record could not be created from the keyboard at all. When the description already has text, the caret lands at its end, not before it. - Escape belongs to the layer that is on top. A menu opened from a chip consumes Escape in the capture phase, so it closes the menu and the half-written record stays. Without that the dialog's own handler answered first — both listen on
windowin the same phase and the browser calls them in registration order. - Add-to-list dialog (acrm !4019): Enter is overloaded on the visible match count, not just on an exact match — with exactly one list row shown (including from a partial match) Enter toggles it, otherwise it creates the typed name. There is no separate confirm step for either path, so a keyboard-only reader reaches every outcome (toggle an existing list, or create one) from the same field without ever touching a mouse.
- Contact log dialog: one keyboard path for all four entities.
ContactLogDialogis the same component instance for person, company, deal and project (acrm !4084) — there is no separate project variant to fall out of sync, so a keyboard fix made once (Tab order,⌘↵, Escape) holds on every entity screen that opens it. The recording chip's "Nahrát soubor" item drives a hidden native<input type="file">(fileRef.current.click()) from aPickerMenurow, so the file picker opens the same way from Enter/Space as from a mouse click. - The overflow
⋯is a real button (.acrm-icobtn,aria-haspopup="menu",aria-expanded), and choosing a property from it opens that property's menu, so the keyboard path is one decision, not two. - Both halves of a dual chip are separate buttons with their own menus; the wrapper is a
role="group"with the name of the pair. A divider is a border, not a separate element. - The attachment's remove button is a sibling of the tile, not its child — a button inside a link cannot be reached predictably by keyboard. It is revealed on
:focus-visibleas well as on hover, so it is not mouse-only. - Missing: no focus trap — inherited from
dialog. Tab can walk out of the panel into the page behind the scrim.
ARIA
- Panel:
role="dialog",aria-modal="true",aria-label= the title — all from the shell. - Each row's control names itself, because there is no label to point at: subject
field.subject, whenfield.when, locationfield.location, noteformless.note, linksfield.links(throughEntityPicker'slabel+hideLabel), the availability panelformless.availability. - A select trigger names the field and its value together — `
${t('field.type')}: ${value}` — because a trigger whose name is only "Type" tells the reader nothing about what is currently chosen, and a trigger whose name is only the value tells them nothing about what it is. - Icon-only controls inside a row carry both
titleandaria-label: copy (common:copy), open in maps (formless.openMap), the footer kebab (common:moreActions). - The long "add" prompt is truncated visually with an ellipsis, and the full sentence stays in the button's
titleandaria-label. - Add-to-list dialog (acrm !4019): rows are checkbox-shaped, not menu items. Each list row is a
<button>witharia-pressedreflecting membership (the.acrm-checkboxglyph isaria-hidden, purely visual) — so the row's own accessible name is just the list's name, and its pressed state is what says whether the record is a member, not a separate label. The list block isrole="group"witharia-label="Seznamy"("Lists"). - Contact log dialog: the recording chip names the SOURCE, not the file.
MetaChip label= {t('contactLog.recording')} value={recordingValue}follows the standard select-trigger naming above (field + value together — "Nahrávka: Z Plaudu" / "Nahrávka: Soubor"), never the picked file's or Plaud item's own name; those run long and a truncated accessible name would be worse than a short, stable one. The clear item is the family's standardclearLabel/onClearrow ("Odebrat nahrávku"), reachable and announced the same way as every other chip's clear action. - Broken: the participant's status dot has no accessible name. It is a bare
<span class="eref-dot">withtitleandaria-label(EntityPicker.tsx:191). Aspanwith no role maps torole="generic", which does not support naming, so thearia-labelis dropped by conformant assistive technology and the RSVP state reaches a screen reader only when the neighbouring.eref-metatext happens to be rendered. Colour is then the sole carrier of the state. Fix: give the dotrole="img"(or render the state as visually-hidden text).
Focus
- Foundation ring for every control in the dialog:
outline: 2px solid var(--accent)withoutline-offset: 1pxon:focus-visible, declared explicitly on.fl-in.menuand.fl-whenbecause they have no border of their own to thicken. - The borderless text inputs suppress the ring (
.fl-in > input.fl-text:focus { outline: none }) and rely on the row tint instead — deliberate, so a focused row does not look like a box has appeared. The tint is a background change, so it is not exposed to a high-contrast or forced-colors mode; the caret remains the only indicator there. - The note's writing surface grows from one line to three on focus, which is a visible focus cue in its own right, and it is suppressed under
prefers-reduced-motion. - On open, focus moves to the first field — the subject line — so the record can be named immediately.
Contrast
Computed from packages/ui/src/tokens/tokens.css on origin/main with tools/ds-contrast.mjs, light / dark:
- Row text
--fgon--surface-100— 17.59:1 / 14.53:1. - Row icon and placeholder
--fg-mutedon--surface-100— 2.85:1 / 3.63:1. Below AA for text in the light theme, and it applies to every icon and every placeholder in the pattern — which is where the meaning of a label-less row lives. This is the system-wide--fg-mutedlimit, not a choice of this pattern, but it costs more here than elsewhere. - Link value and focus ring
--accenton--surface-100— 4.44:1 / 3.83:1. Passes the 3:1 non-text minimum. - Status dots on
--surface-100: accepted--ic-green3.10:1 / 5.50:1 · declined--ic-rose3.81:1 / 4.47:1 · waiting--ic-grey5.74:1 / 2.97:1 · tentative--ic-amber2.14:1 / 7.96:1. Amber in the light theme and grey in the dark theme fall below the 3:1 minimum for a meaningful non-text mark. - Row qualifier
.fl-hint.ok--positive-ink5.33:1 / 7.55:1 ·.fl-hint.errand.fl-err--critical-ink5.37:1 / 5.80:1 — both pass AA. - Hairline between the form and the day panel,
--borderon--surface-100— 1.15:1 / 1.22:1; a hairline, below the non-text minimum (system-wide limit).
Known limits
- The status dot's name is dropped (
aria-labelon arole="generic"span), so RSVP state is colour-only for a screen reader — and two of its four tones also fail 3:1. - The availability day cannot be operated from the keyboard and is
role="presentation". --fg-mutedat 2.85:1 carries the row icons and placeholders, which is the pattern's only labelling.- No focus trap and no inert background — inherited from
dialog. - The focused-row tint is a background colour, so it disappears in forced-colors mode.
- The focused-row tint is
--hover-surface(rgb 251,251,251 on white) — practically invisible (measured 26 Sep 2026, session DS consolidation). For text fields (.fl-text,.fl-mask-in) the blinking caret is the focus indicator; any NON-text control inside a row (chip, icon button, picker trigger) must therefore keep its own:focus-visiblering and never rely on the row tint. The owner decided on 27 Sep 2026 to KEEP the quiet tint (the caret marks text focus; non-text controls carry their own ring). - Touch targets are 44px on phones but 40px rows on desktop pointers, and the icon-only controls inside a row are 24px — under the 44px guidance, in line with the rest of the app's row actions.
Source: docs/dialog/formless-dialog.a11y.md · GitLab