Skip to content

SearchControl

The pause before a query is asked for, the answer that arrives too late to count, the page under the rows, and the list itself. A search built on it supplies the read and draws its own rows.

Terminal window
pnpm dlx shadcn@latest add https://sg-widgets.vercel.app/r/react/search-control.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://sg-widgets.vercel.app/r/svelte/search-control.json
SearchControl: a query, a page, a list with no query, and a read that failed

React

Loading the React demo…

Svelte

Loading the Svelte demo…

The demo reads a static crew list behind a 300ms pause, at four rows a page. The first case takes a query, debounces it, pages it and emits the row picked. The second takes no query at all: one read, drawn straight into the page with no command box around it. The third fails, so the list shows the error line instead.

load is the read. It is handed the query as it stands and the page wanted, and answers the rows it found plus whether a further page may be there. Everything around it belongs to the base.

A query is asked for once the pause has elapsed, so a typist does not fire a request a letter. A query that arrives while a read is in flight takes the next ticket, which is what makes the earlier answer stale: it is dropped rather than landing over the query that replaced it. The same ticket is taken when the list is emptied, so a cancelled read never writes.

An empty query empties the list and reads nothing. A search that browses rather than matching sets readsEmpty, and then an empty query reads too — at once, because no one is typing.

request is what the read depends on besides the query: the level of a tree, the person whose rows are listed. A change to it empties the list and reads again at once. enabled holds every read back, for a list that has nothing to read yet.

The list draws one of four things. An error is the error line. A first page in flight is skeleton rows, shaped like the rows they stand in for; a page arriving under rows already on screen leaves those rows where they are. A read that answered nothing is the empty line, and otherwise it is the rows the wrapper drew. With paging on, a further page adds a load-more row under them.

The blocks carry search-error, search-loading and search-empty as their data-slot, and the load-more row search-load-more. A wrapper renames any of them, or passes null to leave a block unnamed.

command is a command box with a search row at the top of it, dialog the same box inside a dialog, and bare the list alone, for a section of a larger surface that draws its own heading and takes no query.

The highlight is the base’s: it lands on the first row whenever the list changes. Matching is the server’s alone — the command box never filters what came back.

Above the list sits a live region, which says what the list is doing: the read in flight, the number of rows it answered, 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 pages land. The pattern is coss.com/ui at e937bec, whose scroll area reads the same variables.

proptypedefaultmeaning
load(request) => Promise<SearchAnswer<T>>requiredThe read behind the list. Takes the query and the page, answers the rows and hasMore.
querystring''What the caret holds. Two-way in Svelte.
onQueryChange(query: string) => void—Called when the caret changes.
requeststring''What the read depends on besides the query. A change reads again at once.
enabledbooleantrueNothing is read while this is off.
readsEmptybooleanfalseAn empty query reads too, rather than emptying the list.
pagingbooleanfalseA further page is asked for on a load-more row under the rows.
debounceMsnumber250The pause before a typed query is asked for.
shell'command' | 'dialog' | 'bare''command'What is drawn around the list.
commandClassstring—Classes on the command box.
onKeyDown(event, items) => void—Keys the wrapper owns, on the command box, with the rows they act on. Spelled onkeydown in Svelte.
openbooleanfalseWhether the dialog is showing. Two-way in Svelte.
onOpenChange(open: boolean) => void—
titlestring'Search'The dialog's accessible name.
descriptionstring—The line under it.
placeholderstring'Search…'
emptyLabelstring'No match'Shown when the read answered nothing.
loadingLabelstring'Loading…'The accessible name of the skeletons.
errorLabelstringwhat the read saidShown in place of the failed read's own message.
errorSlotstring | null'search-error'The data-slot of the error line.
loadingSlotstring | null'search-loading'The data-slot of the skeleton block.
emptySlotstring | null'search-empty'The data-slot of the empty line.
skeletonLinesnumber3How many rows the skeletons stand in for.
skeletonLeadstring'h-6 w-10 shrink-0'The leading slot of a skeleton row.
slotreceivesdraws
rowsthe rows read, the query and whether a read is in flightThe rows of the list, as items of the command box or as plain rows under a bare shell.
emptynothingDrawn in place of the empty line.
keydoes
ArrowDown / ArrowUpMoves the highlight, through a load-more page.
EnterTakes the highlighted row, or reads the next page on the load-more row.
EscapeWith a query, clears it and the rows and keeps the caret in the box. With no query, the shell takes it: a dialog or a popover closes, and the inline box does nothing.

A press inside the list that replaces the rows returns the caret to the box, so the rows that replaced them take the highlight and the arrows go on walking the list.

Anything else a wrapper needs reaches it through onKeyDown, which is handed the rows on show: the hierarchical search walks the tree with Left and Right that way.

A wrapper owns its read and its rows. It writes one load, draws one row, and says which shell it wants. The three search widgets in this registry are that and little else: the global search adds the trigger, the hotkey and the recents above the results; the hierarchical search adds the level it is browsing and the keys that walk it; the context selector uses the bare shell for the tasks assigned to one person, beside its recents and its tree.

Nothing here is API behaviour of its own. A search that pages a text search reads a full page as the only sign of another one, since a read carries no total and links.next is emitted forever (006_pagination); hasMorePage in core answers that, and the wrapper passes the result as hasMore.

The rows a search draws while its read is in flight. Skeletons are shaped like the rows they stand in for, so a list holds its place when the page lands: the same inset, the same height and the same zero gap. lead is the leading slot’s shape, which is a thumbnail in one widget and a glyph in another. The block carries the accessible name the state line gives it, so a reader hears what is happening rather than nothing.

proptypedefaultmeaning
labelstringrequiredThe accessible name of the block.
linesnumber3Rows the read stands in for.
leadstringh-6 w-10 shrink-0The leading slot’s shape: a thumbnail, a row glyph.
slotNamestring—The data-slot the block carries.