PickerControl
The box a picker’s value sits in, and the popup its rows come from. A picker built on it supplies its query, its rows, its row renderer and its chip.
Install
Section titled “Install”pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/picker-control.jsonpnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/picker-control.jsonReact
Loading the React demo…
Svelte
Loading the Svelte demo…
The demo draws every shape a picker comes in from one static list of departments: single and several,
inline and summary, a control named by a label of its own, a measured row that ends in +n, a fixed
set with no search row, the three heights, disabled, read-only and invalid, and a row and a chip of
the caller’s own. The chip is a
plain text chip; a picker that draws an entity chip or a status badge passes that instead.
The control
Section titled “The control”The control is a bordered field at one of three heights, sm, md and lg. It carries data-empty
while nothing is chosen, which gives it the reading inset of a plain input; a filled control insets
its leading edge to the room above and below its chip, so a value sits evenly inside the border. The
height holds across both states, and the trailing inset is reserve for the clear control and the
chevron.
The states are attributes on the box: aria-disabled for a disabled or inert control, data-readonly,
data-invalid, data-empty while nothing is chosen, data-multiple on a control that takes several
keys, and the ring the field takes from whatever inside it has focus. A readonly control
keeps full contrast and loses the clear control and the chevron. The clear control appears once
something is chosen, and stays through a load.
A press anywhere on the control toggles the popup, its own caret included. A press on a button inside the control — a chip’s remove control, the clear control, the chevron — is that button’s own. Typing opens the popup, so a press that closed it a moment ago does not swallow the next keystroke, and a press on the control is never read as a dismissal.
The caret that takes focus on open is the control’s own when the control is inline, and the popup’s
search box when it is a summary trigger. Every focus call passes preventScroll, so opening a picker
far down a page leaves the page where it is.
The caret is a combobox, so it carries a name: label when the caller gives one, the placeholder
otherwise, and triggerLabel for a control that shows neither.
Inline and summary
Section titled “Inline and summary”An inline control holds the caret beside its value: a token field, with the chips and the query on one line. A summary control holds no input at all, so the search box sits at the top of the popup instead, and the control shows the selection alone.
summary says what the control shows. chips draws every chip and wraps. ellipsis measures the row
against the room it has, draws whole chips only, hides the rest and follows the last one drawn with a
+n pill; a press on the pill opens the popup, where the hidden ones are. count replaces the row
with one line reading how many are selected. max caps the chips whatever the room allows.
The popup
Section titled “The popup”The popup is portalled, positioned with a fixed strategy and anchored on the whole control. It holds, in order: the search row when the control is a summary trigger, then the list, then the load-more row.
The list draws one of four things. An error is the error line, a read in flight is three skeleton rows, a query that matched nothing is the empty line, and otherwise it is the rows the picker supplied. A further page adds a load-more row under them; a press on it pages rather than selects, so the popup stays open and the value is untouched.
Above the list sits a live region, which says what the list is doing: the read in flight, the number of rows it offered, the empty line, or what a failed read said. It is what a reader hears; the state line inside the list is what a reader sees.
The list fades at whichever edge has more content past it and holds a gutter for its scrollbar, so rows never shift as a page lands. The pattern is coss.com/ui at e937bec, whose scroll area reads the same variables; it is read as a reference and not installed.
| prop | type | default | meaning |
|---|---|---|---|
slot | string | required | The data-slot prefix every part of this picker carries. |
picker | string | required | The data-picker the popup carries. |
multiple | boolean | false | Several keys may be chosen at once. |
keys | string[] | required | The chosen keys, in order. A single picker passes none or one. |
onSelect | (keys: string[]) => void | required | The keys the primitive settled on. The load-more row never reaches it. |
labels | string[] | required | One label per chosen key. The summary, the title and the measured row read it. |
items | string[] | required | React: the item keys the list offers, in order. |
rowCount | number | 0 | Svelte: the rows on show, so a changed list follows the highlight. |
chipKeys | string[] | index and label | Svelte: a stable key per chip, so a removal does not redraw the row. |
chipsSlot | string | <slot>-chips | The data-slot of the chip row. |
summary | 'chips' | 'ellipsis' | 'count' | 'chips' | What the control shows for the selection. |
max | number | 0 | Chips drawn before the rest becomes +n. 0 fits what the row can. |
chipRow | boolean | false | The value is a measured row of chips rather than one chip. |
inline | boolean | true | The control holds the caret. A summary control keeps it in the popup. |
tokenInput | boolean | true | The caret gives its room to the chips. |
inputPlaceholder | string | — | What the caret shows. |
searchable | boolean | true | A summary control keeps a search row. A fixed set has nothing to search. |
textValue | boolean | false | The filled value is plain text, so the control keeps the reading inset in both states. |
rowKey | string | size, summary and labels | What the chips look like, so a change re-measures the row. |
size | 'sm' | 'md' | 'lg' | 'md' | Control height: 7, 8 and 9. |
disabled | boolean | false | |
inert | boolean | disabled | The primitive takes no input. Wider than disabled: a loading picker is inert too. |
readonly | boolean | false | |
invalid | boolean | false | |
clearable | boolean | true | The clear control is drawn once something is chosen. |
placeholder | string | '' | |
label | string | the placeholder, then triggerLabel | The combobox's accessible name, for a control whose placeholder is empty. |
searchPlaceholder | string | 'Search…' | |
open | boolean | required | React: whether the popup is showing. |
open | boolean | false | Svelte: whether the popup is showing, two-way. |
onOpenChange | (open: boolean) => void | required | React: called when the popup opens or closes. |
onOpenChange | (open: boolean) => void | — | Svelte: called when the popup opens or closes. |
query | string | required | React: what the caret holds. |
query | string | '' | Svelte: what the caret holds, two-way. |
onQueryChange | (query: string) => void | required | React: called when the caret changes. |
onQueryChange | (query: string) => void | — | Svelte: called when the caret changes. |
onRemoveAt | (index: number) => void | — | Remove the chip at index. Backspace walks the row through it. |
onClear | () => void | — | |
anchored | boolean | false | The popup is as wide as the control it hangs off. |
loading | boolean | false | Skeleton rows in place of the list. |
count | number | 0 | Svelte: rows the list offers, which is what the live region counts. |
error | string | null | null | The error line in place of the list. |
empty | boolean | false | The empty line in place of the list. |
emptyLabel | string | 'No match' | |
loadingLabel | string | 'Loading…' | The accessible name of the skeletons. |
errorLabel | string | what the read said | Shown in place of the failed read's own message. |
hasMore | boolean | false | A further page is there to be read, and the load-more row is drawn. |
onLoadMore | () => void | — | Called by a press on the load-more row. |
clearLabel | string | 'Clear the selection' | |
triggerLabel | string | 'Show the options' | |
overflowLabel | string | 'Show all n selected' | The accessible name of the +n pill. |
itemToStringLabel | (value: string) => string | — | React: what the primitive writes into the input for an item. |
controlProps | HTMLAttributes<HTMLDivElement> / Record<string, unknown> | — | Attributes the control carries on top of the shared ones. |
| slot | React | receives | draws |
|---|---|---|---|
chip | renderChip | the chip's index, whether the caret is on it, whether the row hides it | One chip of the value. The chip under the caret wears the focus ring; a hidden one keeps its width. |
rows | renderItem | nothing in Svelte, the item key in React | The rows of the list, as items of the primitive. |
A chip carries data-chip so the measured row can weigh it, and data-armed while it holds the
caret, so every picker’s chip says the same thing. The base takes every chip out of the tab order
and moves the caret between them itself, so a chip needs nothing of the caller for that.
The contract
Section titled “The contract”Every picker behaves the same, whether it is built on this base or keeps a primitive of its own. The clauses are design rule 7; a drive over both frameworks checks the ones that apply to a picker’s shape, on every picker page, in both frameworks.
One picker, walked from the keyboard:
- A press on the control toggles the list, its own caret included. Typing opens it again.
- On open the caret takes focus: the control’s input when the control is inline, the popup’s
search box when it is a summary trigger. A fixed set has no search row and holds one caret out
of sight, so the keys still land somewhere. Every focus call passes
preventScroll. ArrowDownandArrowUpmove the highlight and the list scrolls it into view, through a load-more page and back to the top.Entertakes the highlighted row. A multi picker stays open for the next one; a single picker closes.BackspaceandArrowLeftin an empty query belong to the value: on a multi picker they take the caret to the last chip, and on a single pickerBackspaceclears the value in one press.- On a chip,
ArrowLeftandArrowRightwalk the row and step off the last one back into the input,BackspaceandDeleteremove the chip and leave the caret on its neighbour,EnterandSpacegive the caret back, a printable key gives it back and writes that character into the query, andArrowDownopens the list. Escapecloses the list and clears the query. The selection survives it, and on a closed picker the key belongs to whatever encloses the picker.- A press outside the control and the popup closes the list.
Tableaves the control. The chips are walked with the arrows, not withTab.
The chip model is Base UI’s, which coss.com/ui at e937bec takes wholesale; here it lives in core so both frameworks answer a key the same way, and it is read as a reference and not installed.
A readonly control keeps full contrast and loses the clear control and the chevron. A disabled one takes no press and no key.
The clear control follows clearable, and a widget bound to a named field takes that answer from
the field: a field the site flags mandatory is never clearable, since clearing it writes a value the
site refuses. clearableForField in core is the one reading of that rule.
Keyboard
Section titled “Keyboard”| key | does |
|---|---|
ArrowDown / ArrowUp | Moves the highlight, and the list scrolls it into view. |
Enter | Takes the highlighted row. A multi picker keeps the popup open. |
Escape | Closes the popup and clears the query. On a closed picker it does nothing. |
Backspace | In an empty query: takes the caret to the last chip of a multi picker; clears a single one. |
ArrowLeft / ArrowRight | On a chip, walks the row. ArrowRight past the last chip returns to the input. |
Delete | On a chip, removes it and leaves the caret on its neighbour. |
Tab | Leaves the control. |
Composing a wrapper
Section titled “Composing a wrapper”A wrapper owns its value and its rows. It holds the chosen keys, maps each one to a label, answers the
query with rows — from a vocabulary it already has, or from a query layer — and hands the base keys,
labels, its rows and its chip. slot names every part of the result, so entity-type-picker gives
a control at entity-type-picker-control and rows the wrapper marks itself.
What the wrapper still spells out is its own: the slot prefix, the query source, the row and the chip. The entity type picker is the smallest of them at around 250 lines per framework.
One thing an inline single picker owns: the primitive writes the chosen item into the caret, over the
chip that already says it. React passes itemToStringLabel to write nothing; Svelte mirrors the label
into the query, so the base’s clear on close is a change the caret sees.
PickerRow
Section titled “PickerRow”The row every picker, search and tree draws, and the other half of a picker’s list. It draws a row’s contents, not its box: the caller owns the list item and its selection state.
The row opens on an indicator column, which is a fixed width whether or not the row is ticked, so a label sits at one x down the whole list. A multi picker puts its checkbox there and a single picker a tick on the row it holds.
| prop | type | default | meaning |
|---|---|---|---|
row | PickerRow | required | The row to draw: the reference, its label and the values a read answered. |
query | string | '' | The query whose matched runs are bold. |
crumbs | string[] | [] | Crumbs drawn before the label, muted and separated by ›. |
thumbnail | string | false | 'image' | Field holding the thumbnail URL. false hides the leading slot. |
roundThumbnail | boolean | false | |
showCode | boolean | false | Show the row's code beside the label when the two differ. |
subLabelField | FieldSpec | null | null | The muted line under the label, drawn by its data type: a status reads its display name. |
subLabel | string | — | The muted line of the caller's own making. Wins over subLabelField. |
secondaryField | FieldSpec | null | null | The right-aligned value: a path, or a resolved column so it renders by its type. |
secondary | string | — | Right-aligned text of the caller's own making. Wins over secondaryField. |
size | 'sm' | 'md' | 'lg' | 'md' | Picture and text ladder. |
context | SgContext | — | The secondary's schema and the status table are read through it. |
siteUrl | string | the context's | The site the status sprite is served from. |
indicatorSlot | string | 'picker-row-indicator' | The data-slot the indicator column carries. |
indicatorAt | 'start' | 'end' | 'start' | Where the indicator column sits: a checkbox leads, a single picker's tick trails. |
Two slots, glyph and indicator in both frameworks. glyph is drawn in the leading slot when the
row carries no picture; a row whose type is a person draws an avatar there instead. indicator is
the tick or the checkbox in the indicator column, and the column is drawn only where a widget
passes it.