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-when and .fl-in.menu are <button>s, the closed note is a button.fl-in, and the rest are input/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-track picks a time from a click and is marked role="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 window in 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. ContactLogDialog is 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 a PickerMenu row, 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-visible as 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, when field.when, location field.location, note formless.note, links field.links (through EntityPicker's label + hideLabel), the availability panel formless.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 title and aria-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 title and aria-label.
  • Add-to-list dialog (acrm !4019): rows are checkbox-shaped, not menu items. Each list row is a <button> with aria-pressed reflecting membership (the .acrm-checkbox glyph is aria-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 is role="group" with aria-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 standard clearLabel/onClear row ("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"> with title and aria-label (EntityPicker.tsx:191). A span with no role maps to role="generic", which does not support naming, so the aria-label is dropped by conformant assistive technology and the RSVP state reaches a screen reader only when the neighbouring .eref-meta text happens to be rendered. Colour is then the sole carrier of the state. Fix: give the dot role="img" (or render the state as visually-hidden text).

Focus

  • Foundation ring for every control in the dialog: outline: 2px solid var(--accent) with outline-offset: 1px on :focus-visible, declared explicitly on .fl-in.menu and .fl-when because 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 --fg on --surface-100 — 17.59:1 / 14.53:1.
  • Row icon and placeholder --fg-muted on --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-muted limit, not a choice of this pattern, but it costs more here than elsewhere.
  • Link value and focus ring --accent on --surface-100 — 4.44:1 / 3.83:1. Passes the 3:1 non-text minimum.
  • Status dots on --surface-100: accepted --ic-green 3.10:1 / 5.50:1 · declined --ic-rose 3.81:1 / 4.47:1 · waiting --ic-grey 5.74:1 / 2.97:1 · tentative --ic-amber 2.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-ink 5.33:1 / 7.55:1 · .fl-hint.err and .fl-err --critical-ink 5.37:1 / 5.80:1 — both pass AA.
  • Hairline between the form and the day panel, --border on --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-label on a role="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-muted at 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-visible ring 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