Skip to content

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.

Terminal window
pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/entity-picker.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/entity-picker.json
EntityPicker: secondary column, types, project scope, hydration, paging, errors, row anatomy, sizes, states

React

Loading the React demo…

Svelte

Loading the Svelte demo…

proptypedefaultmeaning
entityTypesstring[]requiredTypes to search. Several make the picker polymorphic.
contextSgContextrequiredThe widget context. Every read goes through it, so widgets on a page share one cache.
valueEntityRef | nullnullThe chosen row. Two-way in Svelte.
labelFieldstringdisplay-name chainField holding the row label.
searchFieldsSearchFieldSpec[] | ((query) => SearchFieldSpec[])—Fields matched on top of the display-name chain. A function is called with the query.
secondaryFieldstring | CollectionColumn | nullnullField 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.
subLabelFieldstring | CollectionColumn | nullnullField shown under the label, drawn by its data type: a status reads its display name.
subLabel(row) => stringthe type, when several are searchedComputes the sub-label.
thumbnailstring | false'image'Field holding the thumbnail URL. false hides the leading slot.
roundThumbnailbooleanfalseDraws the thumbnail as a circle.
showCodebooleanfalseShows the row's code beside the label when the two differ.
siteUrlstringthe context'sThe site the status sprite is served from, for a secondary that is a status.
fieldsstring[]—Extra fields to request.
filtersFilterGroup | WireGroup | nullnullPre-filter, merged into every search with and.
projectIdnumber—Scopes to one project.
excludeEntityRef[]—Rows kept out of the results.
minQueryLengthnumber0Under it the query carries no name condition.
pageSizenumber20Rows a page, with a load more row under them.
debounceMsnumber250Wait after the last keystroke before searching.
class / classNamestring—Merged after the widget's own classes.
sizefrom PickerControl'sm' | 'md' | 'lg''md'Control height: 7, 8 and 9.
disabledfrom PickerControlbooleanfalse
readonlybooleanfalseKeeps full contrast and drops the affordances.
invalidbooleanfalseSets aria-invalid and the destructive ring.
clearablebooleantrueShows the clear control.
placeholderstring'Search for an entity'Shown in the control while nothing is chosen.
searchPlaceholderstring'Search…'Shown in the query input once something is chosen.
openfrom PickerControlbooleanrequiredReact: whether the popup is showing.
openfrom PickerControlbooleanfalseSvelte: whether the popup is showing, two-way.
emptyLabelstring'No match'Shown when a query matches nothing.
loadingLabelstring'Loading…'Names the skeletons a read stands behind, for a screen reader.
errorLabelstring—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.

eventpayloadwhen
onValueChangeEntityRef | null, PickerRow | nullA row was chosen, or the clear control was pressed.
onErrorErrorA read failed. The list shows it inline as well.
onOpenChangebooleanThe 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.

keydoes
any textOpens the list and searches the server.
Enter, SpaceOn the chevron, opens the list.
Up, DownMoves 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 PickerControlMoves the highlight, and the list scrolls it into view.
EnterChooses the highlighted row, or loads the next page on the load more row.
EscapeCloses the list and clears the query. On a closed picker it does nothing.
BackspaceIn an empty search box, clears the value.
ArrowLeft / ArrowRightfrom PickerControlOn a chip, walks the row. ArrowRight past the last chip returns to the input.
Deletefrom PickerControlOn a chip, removes it and leaves the caret on its neighbour.
TabMoves to the clear control, then the chevron, then out.

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.

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.