Record select

Search-driven choice from a long list or from records (people, companies, users, deals, projects). React <RecordSelect> (packages/ui/src/components/RecordSelect.tsx) renders a 32px trigger .acrm-recordselect-trigger and a portalled panel .acrm-recordselect-pop (apps/web/src/ds/inputs.css §7) with a sticky search row, a list scrolling inside 264px and rows mark · name · secondary · indicator. multi=false is dropdown type B, multi is type D. Filtering is in memory over label, sub and keywords, diacritics folded.

It absorbed Rail picker on 12 September 2026 (AD, RP-1; acrm !3425–!3443). The rail no longer has a picker of its own: with anchorRef the panel hangs under the row's value instead of under a framed trigger, and RailPickerMenu is deleted. The same component now serves the form field, the rail row and the chip in a dialog.

When to use

  • More than ~10 items, or any list of records where the value is an id: owner, assignee, company, contact.
  • A closed code list that is long but static (cities, countries, currencies) — rows with shape: 'none' draw no mark.
  • Multi choice from a long list (multi): participants, watchers, tags.
  • A field that can be cleared to "unassigned" (clearable).
  • A rail row or a chip in a dialog — pass anchorRef (and onClose); the value stays visible as the anchor, no framed trigger appears.
  • A list that mixes kinds — give rows a section and they separate under named hairlines.

When not to use

  • ≤ ~10 closed values — use Select / Multi-select.
  • Free text that need not resolve to a record — use Combobox.
  • Server-side typeahead over thousands of records — RecordSelect filters in memory; onQueryChange lets the host fetch, but the deduplication of RecordSearchSelect / EntityPicker is still open (see the family overview).

Anatomy

  • Wrapper .acrm-recordselect: width: 280px, max-width: 100%; .acrm-recordselect--full = 100% (fullWidth); .is-open while the panel shows. With anchorRef no wrapper or trigger is drawn at all — the caller's element is the anchor.
  • Trigger button.acrm-recordselect-trigger: 32px, padding: 0 var(--sp-3), --surface-100, 1px --border-strong, --radius-sm, font: var(--p), cursor: text, --fg-muted at rest, .is-filled = --ink-900; open = --accent border + 3px 16% halo; :disabled = --surface-75, --border, --fg-muted.
  • Trigger contents: .acrm-recordselect-val (ellipsis; "A, B + N" above two values), optional .acrm-recordselect-clear 20px "×" (clearable), 16px chevron.
  • Panel .acrm-recordselect-pop (portal, position: fixed, z-index: 101, flips up at the viewport bottom via useAnchoredMenu, width = trigger or anchor, at least minWidth): overflow: hidden, --surface-100, 1px --border-strong, --radius-md, --shadow-pop; aria-busy while busy.
  • Search row .acrm-recordselect-search: padding: var(--sp-3) var(--sp-4), 18px search icon --fg-muted, input 400 14px with aria-label, border-bottom: 1px solid var(--border) (dropped when it is the last child).
  • List .acrm-recordselect-list[role=listbox]: max-height: 264px, overflow-y: auto, padding: var(--sp-1), gap: 1px — the LIST scrolls, the header stands (F257 rule).
  • Row button.acrm-recordselect-opt[role=option]: gap: var(--sp-3), padding: var(--sp-2) var(--sp-3) → 32px, --radius-sm, hover --hover-surface; the mark is 16px and comes from the canon — UserAvatar for a colleague (entityType: 'user'), EntityMark for a typed record, Avatar for a person or company, nothing for shape: 'none'; .acrm-recordselect-tx = .nm 500 13px/20px --ink-900 + .em 300 12px --fg-secondary inline (F260); indicator on the right.
  • Indicator: .acrm-recordselect-ck accent check for one value (F261), .acrm-checkbox 16px for multi. A single-value field never shows a checkbox — it would promise a choice that does not exist.
  • Order: what was selected when the panel opened is pinned on top and does not jump while the user clicks; a hairline follows, then the rest.
  • Sections .acrm-recordselect-sec: the name sits in the hairline (::after continues the line to the right), var(--label) in --fg-muted, sentence case. Drawn only when there are at least two named sections — a single name would become a heading above the whole menu, which is forbidden. The pinned block never carries a name.
  • Footer .acrm-recordselect-foot with .acrm-recordselect-create: the quick-create row ("Založit …") behind a hairline; Enter in the search fires it.
  • Empty .acrm-recordselect-empty: padding: var(--sp-4) var(--sp-3), centered, 400 13px --fg-muted, text "Žádné výsledky"; loading shows the same slot while the host fetches.

Best practices

Do

  • Give every row a sub (e-mail, ICO, city) — it is searched too and separates namesakes on one line.
  • Use entityType so the row wears the same mark the record wears everywhere else; a colleague gets their face, not initials.
  • Use clearable on owner/assignee fields where "nobody" is a valid state; otherwise keep the last value.
  • Use fullWidth inside form rows; keep 280px in rails, dialogs' side columns and filters, and pass minWidth when the anchor is narrower than the content deserves.
  • Name sections by what the reader is choosing between (Obchody · Projekty), never by the mechanism.

Don't

  • Put a heading above the list. A section name lives in the hairline between groups, and only when there are two or more; anything above the first row is the forbidden menu heading.
  • A scrolling panel with a sticky header — the list scrolls, the search row stands.
  • A <RecordSelect> for 5 fixed values — that is a Select without the search it does not need.
  • Remove the search row to "simplify" — a Record select without search is a Select.
  • Draw your own panel around these rows. The validator rule recordselect-shell-handmade blocks it: the rail, the chip and the field share this one component.

Content

  • Trigger placeholder "Nastavit hodnotu…" (default) or the field's own ("Vybrat vlastníka…"); search placeholder "Hledat záznamy…" or the record kind ("Hledat osobu…").
  • Selected summary: names joined with ", " up to two, then "A, B + 3" (code); no "a dalších".
  • .em holds the raw identifier (e-mail, ICO "12345678", city) — no labels, no parentheses.
  • Empty state is the single line "Žádné výsledky" — no hint how to search; loading says "Načítám…".
  • Quick-create repeats what the user typed: Založit „Part".
  • entity-picker, product-picker — app pickers reusing these rows with their own shell (consolidation candidates; deprecated).
  • combobox — free text with suggestions.
  • avatar, entity-mark, user-avatar (family Identity marks) — the marks in the rows.
  • entity-ref (family Entity reference) — what a picked record becomes.
  • record-rail — the row whose value is the anchor.
  • Stories: components--record-select--default, --variants (single / multi / clearable / person / company / sections), --sizes (280px vs fullWidth), --states, --composition.
  • error={{ text, onRetry }}: one row with the message and „Zkusit znovu“ instead of the list; no grey explanation.
  • „Propojit“ in Zaznamenat kontakt uses single selection because the schema holds one link per slot: tick on the selected row, tap = pick and close, tap on the selected row = remove. Multi-select only where the data holds N links (task „Propojit“, meeting „Provázání“). One „Propojit“ pattern in the whole app: RecordSelect, no × on the chip, no bare Popover.

Muted option and warning sub (acrm !4348)

  • RecordOption.muted: an option that stays selectable but recedes (e.g. a colleague on leave). Only the mark is dimmed (50 %); the name keeps --fg so it stays readable (4.5:1), and the reason is the second value in --fg-secondary („dovolená do 6. 10.“). Without a second value only the mark is dimmed. Never grey out the name of a selectable row.
  • subTone: 'warn': the second value in --warning-ink (5.58:1 light / 7.96:1 dark) for a fact that affects the choice (e.g. „Nemá připojenou schránku“). The row stays selectable.
  • These are options of the canonical component; a page never re-styles .acrm-recordselect-opt in its own CSS.

Server search, free text and adding mode (acrm !4356)

  • filter={false}: options are already filtered by the server (onQueryChange); the panel does not filter them again on the client, so matches found by phone or IČO stay. Use it only with server search.
  • createLabel(text) + createIcon: wording and glyph of the onCreate row — e.g. „Použít text „…““ with glyph writing, „Nový kontakt: [email protected]“ with glyph mail. No explanation after a dash; the glyph tells the difference from „Založit „…““.
  • When the onCreate row is offered, the empty list is not drawn at all — no „Žádné výsledky“, no empty strip. The action is the answer.
  • keepOpen (adding mode): a pick calls onChange, clears the query and keeps the panel open with focus in search; the picked item disappears from the list and becomes a chip next to the trigger. No checkboxes — it is adding, not a multi-select set. In adding mode Enter takes the first result before the free-text row. On the phone (InSheet) the panel ends with Done; Escape / tap outside closes.
  • emptyText with server search: the state below the minimum length („Napište alespoň 3 znaky“), no ellipsis; „Hledám…“ comes from loading.
  • Product status, record type etc. go into the second value (sub), not into a pill.
  • createPosition: 'top' | 'bottom' (acrm !4367, default bottom): where the onCreate row sits. top when the typed text is the most likely intent — a typed e-mail address shows „Nový kontakt: [email protected]“ (glyph mail, role option) as the FIRST row and Enter takes it; a record with exactly that e-mail replaces it and stands first, so no duplicate is created. Click outside keeps the typed text, Escape drops it. Record type goes into the second value („[email protected] · Kontakt“).
  • EntityPicker and ProductPicker are now thin wrappers on RecordSelect; RecordSearchSelect is gone (acrm !4475, see below); AddRefMenu / useAddRefPopover are gone (acrm !4356, !4367). Participants of a meeting, products of a deal, references in forms all use the adding mode.

Server search with useRecordSearch (acrm !4475, 3 Oct 2026)

When the list is too big to load into the browser (contacts, companies — tens of thousands of records), the options come from the server while the user types. It is still the same RecordSelect: same trigger, panel, rows and keyboard. Only the source of the options changes. In the app the hook useRecordSearch({ kind, value, valueLabel }) (apps/web/src/lib/use-record-search.ts) returns the props to spread into the picker, plus change(cb), which reports both the id and its label.

  • The search waits 180 ms after the last keystroke and does not ask the server below MENTION_MIN_QUERY characters. The panel then says „Napište alespoň N znaky“ — a state, not an explanation.
  • The first request shows „Načítám…“ instead of the list. A query with no match shows „Žádné výsledky“.
  • A failed request shows the error row .acrm-recordselect-error („Hledání se nepovedlo“ + „Zkusit znovu“), never a silently empty list.
  • The server has already filtered the options, so the picker does not filter them again (filter={false}).
  • The server cannot turn a bare id into a name, so the caller keeps the label of the selected value (valueLabel) and stores the one change reports. The selected value stays pinned at the top of the open panel.
  • Do not build a second picker for server search. Use RecordSelect with this hook; stories show the four states under Record select › States.

Field mode — type-ahead in a text field (acrm !4381, verified on dev 1. 10. 2026)

  • searchField={false} + fieldRef: the caller's own input is the search; the panel has no search row. The field is role="combobox", focus stays in it, ↑↓ move the active row via aria-activedescendant, Enter/Tab pick, Esc closes. Query is controlled (query / onQueryChange, shared hook useControlledQuery).
  • maxWidth caps the panel when the field spans a whole row (Komu: 380 px instead of 1021 px).
  • Used by Komu / Kopie / Skrytá (recipients), Řešitel / Firma when assigning a message, Přidat produkt in deal items. Combobox is removed (acrm !4383).
  • Second value = [e-mail, recordTypeLabel(type)].join(' · ') → „[email protected] · Kolega“, „[email protected] · Kontakt“ — one helper in apps/web/src/lib/entity-nav.ts, never a page's own map.

Source: docs/select/record-select.usage.md · GitLab