Record select
canonSearch-driven choice from records: framed field, or panel anchored to a rail row or a chip. Sections separated by a named hairline, one value = accent check.
Absorbed Rail picker on 12 September 2026 (AD, RP-1; acrm !3425–!3443): with anchorRef the panel hangs under a rail row or a chip instead of a framed trigger, so the rail has no picker of its own and RailPickerMenu is deleted. Also new: sections whose name sits IN the hairline (only when there are two or more; a single name would be the forbidden heading above a menu), selected pinned on top, accent check for one value (F261), diacritics-folded search over sub and keywords, quick-create footer, minWidth, loading/empty/busy, UserAvatar for a colleague. Known limit: the section name is var(--label) in --fg-muted = 2.85:1 light / 3.63:1 dark, below AA in both themes — the lighter tone is the AD's explicit choice of 12 September 2026 (ACRM-227, acrm !3460), made with the numbers in front of him; the group is announced from role=group + aria-label, not from this text. The hand-made panel shell is blocked by the validator rule recordselect-shell-handmade. Slot wrapOption(option, row) (acrm !3913, AD 26 Sep 2026): the host may WRAP an option row in an element that clicks nothing and does not change its size (hover preview EntityPreviewChip); selection, keys and look stay with RecordSelect. Used by RecordLinksChip — the one links chip of shape-A dialogs (task, deal, meeting, note), selected records show the record preview on hover.
Used in 46 files · 31 areas · 52× — components/RecordLinksChip, components/ReservationControl, components/TagsEditor, components/accounting, components/acl, components/clen-tymu …
@acrm/ui <RecordSelect>.acrm-recordselect .acrm-recordselect-trigger .acrm-recordselect-pop .acrm-recordselect-search +11SourceStoryLegacy card Inputs.htmlSearch-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(andonClose); the value stays visible as the anchor, no framed trigger appears. - A list that mixes kinds — give rows a
sectionand 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 —
RecordSelectfilters in memory;onQueryChangelets the host fetch, but the deduplication ofRecordSearchSelect/EntityPickeris still open (see the family overview).
Anatomy
- Wrapper
.acrm-recordselect:width: 280px,max-width: 100%;.acrm-recordselect--full= 100% (fullWidth);.is-openwhile the panel shows. WithanchorRefno 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-mutedat rest,.is-filled=--ink-900; open =--accentborder + 3px 16% halo;:disabled=--surface-75,--border,--fg-muted. - Trigger contents:
.acrm-recordselect-val(ellipsis; "A, B + N" above two values), optional.acrm-recordselect-clear20px "×" (clearable), 16px chevron. - Panel
.acrm-recordselect-pop(portal,position: fixed,z-index: 101, flips up at the viewport bottom viauseAnchoredMenu, width = trigger or anchor, at leastminWidth):overflow: hidden,--surface-100,1px --border-strong,--radius-md,--shadow-pop;aria-busywhilebusy. - Search row
.acrm-recordselect-search:padding: var(--sp-3) var(--sp-4), 18px search icon--fg-muted, input400 14pxwitharia-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 —UserAvatarfor a colleague (entityType: 'user'),EntityMarkfor a typed record,Avatarfor a person or company, nothing forshape: 'none';.acrm-recordselect-tx=.nm500 13px/20px--ink-900+.em300 12px--fg-secondaryinline (F260); indicator on the right. - Indicator:
.acrm-recordselect-ckaccent check for one value (F261),.acrm-checkbox16px formulti. 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 (::aftercontinues 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-footwith.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";loadingshows the same slot while the host fetches.
Best practices
- Give every row a
sub(e-mail, ICO, city) — it is searched too and separates namesakes on one line. - Use
entityTypeso the row wears the same mark the record wears everywhere else; a colleague gets their face, not initials. - Use
clearableon owner/assignee fields where "nobody" is a valid state; otherwise keep the last value. - Use
fullWidthinside form rows; keep 280px in rails, dialogs' side columns and filters, and passminWidthwhen the anchor is narrower than the content deserves. - Name sections by what the reader is choosing between (Obchody · Projekty), never by the mechanism.
- 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-handmadeblocks 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".
.emholds 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".
Related
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 vsfullWidth),--states,--composition.
Error and single-link use (acrm !4333)
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--fgso 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-optin 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 theonCreaterow — e.g. „Použít text „…““ with glyphwriting, „Nový kontakt: [email protected]“ with glyphmail. No explanation after a dash; the glyph tells the difference from „Založit „…““.- When the
onCreaterow 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 callsonChange, 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.emptyTextwith server search: the state below the minimum length („Napište alespoň 3 znaky“), no ellipsis; „Hledám…“ comes fromloading.- Product status, record type etc. go into the second value (
sub), not into a pill. createPosition: 'top' | 'bottom'(acrm !4367, default bottom): where theonCreaterow sits.topwhen the typed text is the most likely intent — a typed e-mail address shows „Nový kontakt: [email protected]“ (glyphmail, 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_QUERYcharacters. 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 onechangereports. The selected value stays pinned at the top of the open panel. - Do not build a second picker for server search. Use
RecordSelectwith 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 isrole="combobox", focus stays in it, ↑↓ move the active row viaaria-activedescendant, Enter/Tab pick, Esc closes. Query is controlled (query/onQueryChange, shared hookuseControlledQuery).maxWidthcaps 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