EntityPicker
Searches one or more entity types on the server as you type, and binds the row you choose.
Single and multi: EntityMultiPicker binds several rows.
Install
Section titled “Install”pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/entity-picker.jsonpnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/entity-picker.jsonReact
Loading the React demo…
Svelte
Loading the Svelte demo…
| prop | type | default | meaning |
|---|---|---|---|
entityTypes | string[] | required | Types to search. Several make the picker polymorphic. |
context | SgContext | required | The widget context. Every read goes through it, so widgets on a page share one cache. |
value | EntityRef | null | null | The chosen row. Two-way in Svelte. |
labelField | string | display-name chain | Field holding the row label. |
searchFields | SearchFieldSpec[] | ((query) => SearchFieldSpec[]) | — | Fields matched on top of the display-name chain. A function is called with the query. |
secondaryField | string | CollectionColumn | null | null | Field shown right-aligned, drawn by its data type. A resolved column renders it by type. |
secondary | (row) => string | — | Right-aligned text of your own. Wins over secondaryField. |
subLabelField | string | CollectionColumn | null | null | Field shown under the label, drawn by its data type: a status reads its display name. |
subLabel | (row) => string | the type, when several are searched | Computes the sub-label. |
thumbnail | string | false | 'image' | Field holding the thumbnail URL. false hides the leading slot. |
roundThumbnail | boolean | false | Draws the thumbnail as a circle. |
showCode | boolean | false | Shows the row's code beside the label when the two differ. |
siteUrl | string | the context's | The site the status sprite is served from, for a secondary that is a status. |
fields | string[] | — | Extra fields to request. |
filters | FilterGroup | WireGroup | null | null | Pre-filter, merged into every search with and. |
projectId | number | — | Scopes to one project. |
exclude | EntityRef[] | — | Rows kept out of the results. |
minQueryLength | number | 0 | Under it the query carries no name condition. |
pageSize | number | 20 | Rows a page, with a load more row under them. |
debounceMs | number | 250 | Wait after the last keystroke before searching. |
class / className | string | — | Merged after the widget's own classes. |
sizefrom PickerControl | 'sm' | 'md' | 'lg' | 'md' | Control height: 7, 8 and 9. |
disabledfrom PickerControl | boolean | false | |
readonly | boolean | false | Keeps full contrast and drops the affordances. |
invalid | boolean | false | Sets aria-invalid and the destructive ring. |
clearable | boolean | true | Shows the clear control. |
placeholder | string | 'Search for an entity' | Shown in the control while nothing is chosen. |
searchPlaceholder | string | 'Search…' | Shown in the query input once something is chosen. |
openfrom PickerControl | boolean | required | React: whether the popup is showing. |
openfrom PickerControl | boolean | false | Svelte: whether the popup is showing, two-way. |
emptyLabel | string | 'No match' | Shown when a query matches nothing. |
loadingLabel | string | 'Loading…' | Names the skeletons a read stands behind, for a screen reader. |
errorLabel | string | — | Shown in place of what the failed read said. |
Every other attribute is spread onto the root: id, aria-*, data-*, key handlers and a
ref to the root element.
The secondary column shows nothing until secondaryField or secondary asks for something.
secondaryField is drawn by the field’s data type, so a status is a badge, an entity is the name it
links to, a date is formatted and a number is right-aligned in tabular figures; secondaryField="id"
puts the id back, in mono. The field is requested with the row.
A query shorter than minQueryLength, or none at all, still lists the first page, sorted by the last
update, so an open picker is never empty.
A row is { type, id, name, values }, where values holds the attributes and the relationships in
one map. A dotted field is a literal key with dots in it, so project.Project.name reads straight
out of it.
The picker is w-full; wrap it in a sized container.
Events
Section titled “Events”| event | payload | when |
|---|---|---|
onValueChange | EntityRef | null, PickerRow | null | A row was chosen, or the clear control was pressed. |
onError | Error | A read failed. The list shows it inline as well. |
onOpenChange | boolean | The popup opened or closed. Svelte also binds it with bind:open. |
None. The row is fixed: leading thumbnail or avatar, label with the matched words in bold and the code beside it, sub-label, right-aligned secondary.
Keyboard
Section titled “Keyboard”| key | does |
|---|---|
| any text | Opens the list and searches the server. |
Enter, Space | On the chevron, opens the list. |
Up, Down | Moves the highlight through the rows and keeps it in view, including across a load more page. The last row holds it rather than wrapping. |
ArrowDown / ArrowUpfrom PickerControl | Moves the highlight, and the list scrolls it into view. |
Enter | Chooses the highlighted row, or loads the next page on the load more row. |
Escape | Closes the list and clears the query. On a closed picker it does nothing. |
Backspace | In an empty search box, clears the value. |
ArrowLeft / ArrowRightfrom PickerControl | On a chip, walks the row. ArrowRight past the last chip returns to the input. |
Deletefrom PickerControl | On a chip, removes it and leaves the caret on its neighbour. |
Tab | Moves to the clear control, then the chevron, then out. |
API behaviour
Section titled “API behaviour”A query is split on whitespace and every word must match, each as its own contains condition,
or’d across the fields the type has out of cached_display_name, code, name, title,
content and subject. contains works on text fields and through dotted paths, and an operator
the field does not accept is a 400, never a silent pass (017_filter_operators). Client-side
filtering is off: the server decides what matches.
Each searched type gets its own POST /entity/<type>/_search with api3_hash, the only content
type that expresses a nested and and or group (030_complex_filters).
POST /entity/_text_search is not used: it takes no fields, so every row comes back as name,
links and status whatever the type, and a picker needs a thumbnail and a sub-label
(post_entity_text_search).
With no name condition the rows are sorted -updated_at. Unsorted, a read comes back id ascending,
which is the oldest work on the site; an unsortable or unknown sort field is a silent 200 no-op
(026_result_order).
Another page exists when the page came back full. links.next is emitted on every page forever,
including empty ones, so it is never the stop signal (006_pagination).
A pre-filter naming a field the type does not have is dropped on that type. The same name in a
filter would be a 400 while an unknown name in fields is a silent 200 (003_query,
017_filter_operators), so a picker across several types applies each condition only where it
resolves. projectId follows the same rule: it becomes project on a type that links one and
projects on a site-wide type such as HumanUser, and nothing on Project itself.
A bare { type, id } is resolved on mount by one id in read per type, with the same field list.
A row that cannot be resolved keeps the label Type id.
A thumbnail URL is presigned and re-minted on every read, so the picker holds the row and re-reads rather than storing the string (field_types/image).
The control is the combobox input with the chosen row’s chip inline; the list is the combobox
popup. The loading state is the skeleton item of each registry.
Reference
Section titled “Reference”The row, chip and checkbox anatomy follows shadcn’s Base UI Combobox, read through shadcn 4.21.0. The primitive underneath is Base UI Combobox 1.8.0 in React and Bits UI Combobox 2.19.1 in Svelte. shadcn-svelte ships no combobox item, so each widget composes the headless primitive of its framework and both draw the same rows, the same classes and the same states.