Components › Overlays & dialogs › Dialog › Form-less dialog

Form-less dialog

canon

Creates or edits one record without form fields: an icon carries each row and the placeholder names it. Shape A writes text plus properties as chips in the body; shape B asks what · when · with whom · how · where.

Promoted to a rule by the product owner on 9 September 2026 ("promote form-less to a rule and review the other form dialogs with the aim of unifying them"); the pattern itself was approved by the AD on 7 September and shape B shipped to dev on 8 September (ACRM-132, MRs !3163–!3166). The code is the source of truth. THE RULE: form-less when the dialog creates or edits one record whose properties can be named by a placeholder; a form when it is data entry with many typed values and the label carries the meaning — the test is whether the label can be replaced by a placeholder without losing the field identity. Shape B (`.fl-*`) is the documented engine and the only one with a shared sheet. Shape A still runs as three private copies — `.tk-*` (task-dialog.css), `.dl-*` (deal-dialog.css), `.nd-*` + `.dlg-title` (note-dialog.css, dialog-doc.css) — with MetaChip declared twice in TSX; merging them onto `.fl-*` is wave 0 and until it lands shape A is a named shape, not a catalogued one. Adoption measured at main 06fb6b236: 2 dialogs on shape B, 3 on shape A, 43 forms — of which 12 are correctly forms (accounting document 13 fields, Spravovat segment 37, payroll relation, VAT period, mailbox) and 12 are conversion candidates with their questions listed in docs/REVIEW-form-dialogy.md. No stories yet — the pattern lives in apps/web, which packages/ui must not import, so they will be ported markup like Filter bar and Command palette. Three defects open in the shipped code (all in the review): the RSVP dot has no accessible name because aria-label sits on a role="generic" span (EntityPicker.tsx:191); --ic-amber 2.14:1 light and --ic-grey 2.97:1 dark fail the 3:1 non-text minimum while colour is the state’s only carrier; the availability day is mouse-only and role="presentation" (MeetingAvailability.tsx:233). A row may carry several values stacked in `.fl-vals` with the icon on the first: the video-call link sits under the meeting type rather than asking its own question (AD 9 September 2026), which also ended the `link` glyph naming two different rows; the in-person meeting type took `coffee` the same day, so `users` names only the attendees and no glyph in the dialog carries two meanings. The rule is one meaning per AXIS, not per app: the *Typy událostí* filter keeps `users` for its Schůzky row because it switches categories of event rather than describing one meeting (AD, 9 September 2026). The EDIT footer deliberately has no Zrušit — over an existing record the word reads as "cancel the meeting", and the close control already closes the window (product owner, 8 September 2026). The CREATE dialog still has it, because with nothing saved yet the word is unambiguous; whether create should drop it too is open. Delivered 13 Sep 2026 (FL-5; ACRM-223/224/226; acrm !3466–!3476): shape A in all of task, deal and note; header = primary record (EntityRef) then classification, separated by --sp-5 rhythm without a slash, classification and ID in --fg-muted (.head-muted); links chip names records instead of counting them and never repeats the primary; the title is a growing textarea. The note keeps its 1128 × 828 work window. Person dialog (shape B, on main since 15 September 2026, acrm !3561 — `apps/web/src/components/person/NewContactDialog.tsx`, channel editor draft mode in acrm !3548, wiring into the people list and palette in !3575): reuses the record rail's own multi-value channel editor in draft mode (`local-channels.ts`, `draft`/`plainValues`) for e-mail and phone, adds `.fl-dup`/`.fl-chan`/`.fl-chan-v`, and is the first shape-B dialog to use `masked-date-input` for a typed field. `.fl-dup`/`.fl-chan`/`.fl-chan-v` ship in `dialog-formless.css`. See the "person dialog" subsection in formless-dialog.usage.md. Person dialog, custom fields (acrm !3699, 16 Sep 2026, ACRM-277): behind the same "More details" chip, after the tag row, the create dialog draws the tenant's custom fields of a person from the SAME registry the record rail reads (`api.customFields('contact')`) — no drift, a deactivated definition is not drawn. The definition's type picks the input: date → dd.mm.rrrr mask, select and yes/no → PickerMenu (free text would put a value outside the code list into the jsonb, where no filter finds it), number → numeric keyboard but not type="number". Two items per row (like title prefix/suffix), leading glyph `clipboard-list` as in the rail. Values are written only after the record exists, via `setCustomValues` (the only path that merges jsonb; PATCH would overwrite keys from import, enrichment and the agent) and only the filled ones. Task dialog, suggestions from the typed title (acrm !3771, ACRM-213, approved 17 Sep 2026 — Linear "Apply all quick suggestions"): in a NEW task the existing `.flc-navrhy` group also offers what the title says — due date, priority, assignee only, only for an empty property (the default "Ty" assignee counts as empty), assignee only when the name fits exactly one colleague. Chip = canonical `.tk-chip` with the glyph of the property it sets (calendar · flag · user) and the sentence's own word ("zítra", "v pátek"); a quiet `IconButton` bolt (sm, 24px) BEFORE the chips applies all, drawn from two suggestions up. No new CSS. Computed in the browser (`lib/task-suggest.ts`), no model, no server call. ACRM-355 (acrm !3775): the note `.fl-note` is drawn by MentionInput (`.mi-editor.mi-multiline` = form-field frame, 88px); the form-less selectors carry `.mi-editor.mi-multiline.fl-note` so the note stays borderless at rest, on focus and while typing, whatever the CSS order. Relation dialog (shape B, acrm !4011, 27 Sep 2026 — `apps/web/src/components/relations/RelationDialog.tsx`, data/rules in `relations-data.ts`): the „New relation"/„Edit relation" dialog on a person or company card. The anchor (the card's own subject) is fixed as the `LeadEntity` in the header and is never repicked — a relation is an edge between two subjects, and `PATCH /relations/:id` does not accept `from`/`to` — so on edit the anchor never shows as an editable row. Icon+value rows carry no labels. The other side is a **generic entity** — company OR person, one `EntityPicker` (`types={['organization','contact']}`, `max={1}`) — because the product owner asked for the picker to stay general ("vztah můžu mít i s osobou", 27 Sep 2026) rather than splitting into a company dialog and a person dialog. Relation type is a `PickerMenu` grouped by the chosen entity's kind, with a `sep: true` separator row between the two groups; before an entity is picked, both groups show at once (the entity picked after the type discards a type that belongs to the other kind). "Valid from" is optional and typed into the `dd.mm.rrrr` mask (`MaskedDateInput`): the placeholder reads `field.validFromToday` while creating with nothing chosen (empty submits today) and `field.validFrom` on edit (empty keeps the stored date — clearing the field is never a silent delete of the start date). Position and share % rows draw only when the selected relation type's `allowsPosition`/`allowsShare` say so; a field outside the allowed set is always sent as `null` on submit, so switching type (e.g. „Společník" → „Zaměstnanec") clears it server-side instead of leaving a stale value. Footer: create = Cancel `Esc` + „Add relation" `⌘↵`; edit = „Save" only, no Cancel (the family rule — over an existing record `Zrušit` reads as "cancel the relation", and the close control already closes the window). Relationship type catalogue, person↔person (acrm !4010, migration `0416_relation_types_chot_prarodic.sql`, same 27 Sep 2026 change): „Manžel/ka" renamed to „Choť" (one word for both spouses), „Partner/ka" renamed to „Partner", and a new „Prarodič / Vnouče" (Grandparent/Grandchild) type was added after Parent/Child. The migration only rewrites rows still holding the old default name — an admin rename made in Nastavení → Data → Vztahy is left alone. Shape B — the add-to-list dialog (acrm !4019, 27 Sep 2026): `AddToListDialog` (`apps/web/src/components/segment/AddToListDialog.tsx`) is a multi-select-from-search instance of the pattern — "Multi-select from search in a dialog". The card's own record (company or person) is the `LeadEntity` anchor in the header, unpickable, same as the relation dialog's anchor. The body is ONE field, "Hledat nebo založit seznam" (search or create a list), filtering the tenant's manual lists (`segment kind='manual'`) by normalized, diacritic-insensitive match. Each list is a row with the anatomy of `check-menu` — `.acrm-popover-item` holding the list's own segment tile (`.sg`, 18px, reused from the left menu — see "Segment tile debt" below) then the name and member count, with `.acrm-checkbox` on the right (`aria-pressed` on the row) — and a click toggles membership immediately, so the footer carries only "Hotovo". A "Založit seznam „…"" row is appended only when nothing in the list matches the typed text exactly (no row for a name that already exists, so re-adding it is a toggle, not a duplicate). Enter is overloaded on the match count, not just an exact-match shortcut: with exactly one row still shown — even from a partial match like "fin" → "Fintech — sledovat" — Enter toggles that row; otherwise it creates the typed name (verified in `add-to-list-dialog.test.tsx`, case "Enter při jediné ČÁSTEČNÉ shodě list přepne"). Dynamic (filter) segments are excluded from this dialog on purpose — membership in them can't be set by hand, so an earlier version that listed them read-only was pure noise; the old `.atl-row`/`.atl-ic` row shape this dialog used before 27 Sep 2026 is now only used by the Trash page and the duplicates panel, not by any form-less dialog. "Change role" on the company card's person row (acrm !4020, 27 Sep 2026) now opens this same `RelationDialog` with `presetOther` set to the person — not a separate dialog: the older `PersonRoleDialog` component is REMOVED (`company/PersonRoleRelationDialog.tsx` is the new, thin wrapper that resolves the existing relation row, or passes `presetOther` when the person has no relation yet, and renders `RelationDialog`). `presetOther` (a `Mention`) fixes the picked-but-not-yet-saved other side of a NEW relation the same way `anchor` fixes the card's own side, so the entity picker does not show when the context already names both ends. Contact log dialog (shape A, `apps/web/src/components/timeline/ContactLogDialog.tsx`) — ONE dialog for „Zaznamenat kontakt“ on person, company, deal AND project (product owner 27 Sep 2026: „to je ten stejný dialog… použij ten samý prvek z DS“). Project as the main entity (acrm !4084, 28 Sep 2026): `entityType="project"` writes the 4th slot via `POST /timeline/entry` (`projectId`, from !4048) rather than a picked link; the old, project-only `ProjectEntryDialog` is REMOVED (279-line component + its test deleted, `ProjectMeetingsTab` now opens `ContactLogDialog`). Recording chip (acrm !4072, 28 Sep 2026, approved „doplň možnost přidání nahrávky všude“): a `MetaChip` (glyph `microphone`) driven by `PickerMenu` in the chip row — items „Z Plaudu“ (only when `plaudQ.data?.configured`) and „Nahrát soubor“, plus a clear row „Odebrat nahrávku“ once something is picked. The chip's VALUE is the SOURCE (`t('contactLog.recordingPlaud')` / „N z Plaudu“ / „Soubor“), never the file or dictaphone item's name — those run long and the chip must not truncate (source: `apps/web/src/components/meetings/recording-sources.ts`, `RECORDING_SOURCE_ICON`). A log with a recording is submitted as a proběhlá SCHŮZKA (only a meeting can hold a recording): `POST /timeline/entry` returns `meetingId`, then the binary/Plaud import goes as a second call; a failed second call closes the dialog like a success with a warning toast rather than reopening a half-filled form (review !4072). `.fl-note.fl-note-open`: the writing area is the dialog's CORE (REVIEW-form-dialogy.md §3.1) and must hold its three-line height even after focus leaves it for a chip — before this the area collapsed on every chip click and the dialog jumped 38 px (28 Sep 2026). Pitfall, already hit once: the fix must NOT be a bare `.open` class on `.fl-note` — `ds:sync-check` then reads `.x.open` elsewhere in the DS sheet as adopted-and-missing and broke the person-record golden; the CSS/TSX carry the dialog-specific `.fl-note-open` name instead. Header centring: `.acrm-dialog-lead .flc { align-items: center }` plus, in a row with more than the entity alone, `margin-top: -1px` on the wrapper around the entity's `.eref-chip` (its 1px dashed name underline extends the box 1px down, which without the correction put the entity's optical centre 2.8px above the classification chip and the × — product owner 28 Sep 2026: „položky v záhlaví nejsou zarovnané vertikálně na střed“). The single-entity header next to the title alone (ACRM-319) is untouched — the rule only fires with a second flex child. 14 Oct 2026 (owner "26 souhlas", proto/schuzka-ticho@19b777808, acrm !5041): Nová schůzka is the quiet reference — row placeholders are one or two words (Účastníci · Místo nebo odkaz · Cíl · Poznámka pro účastníky), a property of a row (internal goal) is an icon with tooltip, never a sentence in the placeholder. Dev check 14 Oct 1440 + 390: rows single-line (40 / 44 px). Registry repaired 17 Oct 2026 (review F2): removed classes with no CSS definition and no TSX use on main: .fl-copy, .tk-att-w, .tk-att-x. Registry repaired 17 Oct 2026 (review F4/F11): attached css share-dialog-formless.css.

Used in 29 files · 17 areas · 31× — components/communication, components/company, components/deal, components/meetings, components/notes, components/person …

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