Search Dialog

Full-screen search dialog with keyboard navigation.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/search-dialog.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { Search } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "../command"; export type SearchItem = { description?: string; href?: string; id: string; keywords?: string; snippet?: string; title: string; }; type SearchScope = "components" | "docs" | "everything"; function useKeyboardShortcut(callback: () => void) { useEffect(() => { const down = (event: KeyboardEvent) => { if ( (event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey) ) { const target = event.target as HTMLElement | null; if ( target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); callback(); } }; window.addEventListener("keydown", down, { capture: true, passive: false }); return () => { window.removeEventListener("keydown", down, { capture: true }); }; }, [callback]); } type SearchDialogProps = { buttonText?: string; buttonTextMobile?: string; docsEmptyText?: string; docsGroupHeading?: string; docsSearch?: (query: string) => Promise<SearchItem[]>; emptyText?: string; enableKeyboardShortcut?: boolean; groupHeading?: string; items: SearchItem[]; minDocsSearchLength?: number; onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; scopeLabels?: Partial<Record<SearchScope, string>>; searchPlaceholder?: string; }; const DEFAULT_SCOPE_LABELS: Record<SearchScope, string> = { components: "Components", docs: "Docs", everything: "Everything", }; function getItemValue(item: SearchItem) { return [ item.title, item.description, item.snippet, item.keywords, item.href, item.id, ] .filter(Boolean) .join(" "); } function HighlightedText({ query, text }: { query: string; text: string }) { const trimmedQuery = query.trim(); if (!trimmedQuery) { return text; } const index = text.toLowerCase().indexOf(trimmedQuery.toLowerCase()); if (index === -1) { return text; } return ( <> {text.slice(0, index)} <mark className="rounded bg-primary/15 px-0.5 text-foreground"> {text.slice(index, index + trimmedQuery.length)} </mark> {text.slice(index + trimmedQuery.length)} </> ); } function ScopeTabs({ getTabId, labels, onScopeChange, panelId, scope, }: { getTabId: (s: SearchScope) => string; labels: Record<SearchScope, string>; onScopeChange: (scope: SearchScope) => void; panelId: string; scope: SearchScope; }) { const scopes: SearchScope[] = ["components", "docs", "everything"]; return ( <div aria-label="Search scope" className="grid grid-cols-3 gap-1 border-b p-1" role="tablist" > {scopes.map((nextScope) => ( <button aria-controls={panelId} aria-selected={scope === nextScope} className={cn( "h-8 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground", scope === nextScope && "bg-accent text-accent-foreground", )} id={getTabId(nextScope)} key={nextScope} onClick={() => { onScopeChange(nextScope); }} role="tab" type="button" > {labels[nextScope]} </button> ))} </div> ); } function SearchResultContent({ item, query, }: { item: SearchItem; query: string; }) { return ( <div className="flex min-w-0 flex-col"> <span className="truncate font-medium">{item.title}</span> {item.snippet ? ( <span className="line-clamp-2 text-xs text-muted-foreground"> <HighlightedText query={query} text={item.snippet} /> </span> ) : item.description ? ( <span className="line-clamp-2 text-xs text-muted-foreground"> {item.description} </span> ) : null} </div> ); } function SearchTriggerButton({ buttonText, buttonTextMobile, onOpen, }: { buttonText?: string; buttonTextMobile?: string; onOpen: () => void; }) { return ( <Button className={cn( "relative h-9 w-full justify-start text-sm text-muted-foreground sm:pr-12 md:w-40 lg:w-64", )} onClick={onOpen} variant="outline" > <Search className="mr-2 size-4" /> <span className="hidden lg:inline-flex">{buttonText ?? "Search..."}</span> <span className="inline-flex lg:hidden"> {buttonTextMobile ?? "Search..."} </span> <kbd className="pointer-events-none absolute right-1.5 top-1.5 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex"> <span className="text-xs"></span>K </kbd> </Button> ); } function ComponentResultsGroup({ groupHeading, hasDocumentationSearch, items, labels, onSelect, query, }: { groupHeading?: string; hasDocumentationSearch: boolean; items: SearchItem[]; labels: Record<SearchScope, string>; onSelect: (item: SearchItem) => void; query: string; }) { return ( <CommandGroup heading={ groupHeading ?? (hasDocumentationSearch ? labels.components : undefined) } > {items.map((item) => ( <CommandItem key={item.id} onSelect={() => { onSelect(item); }} value={getItemValue(item)} > <SearchResultContent item={item} query={query} /> </CommandItem> ))} </CommandGroup> ); } function DocumentationStatusItem({ documentationSearchLength, trimmedQuery, }: { documentationSearchLength: number; trimmedQuery: string; }) { return ( <CommandItem disabled value={`${trimmedQuery} search-docs-min-length`}> <span className="text-sm text-muted-foreground"> Type at least {documentationSearchLength} characters to search docs. </span> </CommandItem> ); } function DocumentationLoadingItem({ trimmedQuery }: { trimmedQuery: string }) { return ( <CommandItem disabled value={`${trimmedQuery} searching-docs`}> <span className="text-sm text-muted-foreground">Searching docs...</span> </CommandItem> ); } function DocumentationResultsGroup({ docsGroupHeading, documentationItems, documentationLoading, documentationSearchLength, onSelect, query, scope, trimmedQuery, }: { docsGroupHeading?: string; documentationItems: SearchItem[]; documentationLoading: boolean; documentationSearchLength: number; onSelect: (item: SearchItem) => void; query: string; scope: SearchScope; trimmedQuery: string; }) { const showMinimumLengthPrompt = scope === "docs" && trimmedQuery.length < documentationSearchLength; const showDocumentationItems = !documentationLoading && trimmedQuery.length >= documentationSearchLength; return ( <CommandGroup heading={docsGroupHeading ?? "Docs"}> {showMinimumLengthPrompt ? ( <DocumentationStatusItem documentationSearchLength={documentationSearchLength} trimmedQuery={trimmedQuery} /> ) : null} {documentationLoading ? ( <DocumentationLoadingItem trimmedQuery={trimmedQuery} /> ) : null} {showDocumentationItems ? documentationItems.map((item) => ( <CommandItem key={item.id} onSelect={() => { onSelect(item); }} value={getItemValue(item)} > <SearchResultContent item={item} query={query} /> </CommandItem> )) : null} </CommandGroup> ); } function SearchDialogList({ activeTabId, currentEmptyText, docsGroupHeading, documentationItems, documentationLoading, documentationSearchLength, groupHeading, hasDocumentationSearch, labels, onComponentSelect, onDocumentationSelect, panelId, query, scope, showComponents, showDocumentation, sortedItems, trimmedQuery, }: { activeTabId?: string; currentEmptyText: string; docsGroupHeading?: string; documentationItems: SearchItem[]; documentationLoading: boolean; documentationSearchLength: number; groupHeading?: string; hasDocumentationSearch: boolean; labels: Record<SearchScope, string>; onComponentSelect: (item: SearchItem) => void; onDocumentationSelect: (item: SearchItem) => void; panelId?: string; query: string; scope: SearchScope; showComponents: boolean; showDocumentation: boolean; sortedItems: SearchItem[]; trimmedQuery: string; }) { return ( <CommandList aria-labelledby={activeTabId} className="max-h-[420px]" id={panelId} role={activeTabId === undefined ? undefined : "tabpanel"} > <CommandEmpty>{currentEmptyText}</CommandEmpty> {showComponents ? ( <ComponentResultsGroup groupHeading={groupHeading} hasDocumentationSearch={hasDocumentationSearch} items={sortedItems} labels={labels} onSelect={onComponentSelect} query={query} /> ) : null} {showDocumentation ? ( <DocumentationResultsGroup docsGroupHeading={docsGroupHeading} documentationItems={documentationItems} documentationLoading={documentationLoading} documentationSearchLength={documentationSearchLength} onSelect={onDocumentationSelect} query={query} scope={scope} trimmedQuery={trimmedQuery} /> ) : null} </CommandList> ); } type DocumentationSearchOptions = { docsSearch?: (query: string) => Promise<SearchItem[]>; minDocsSearchLength?: number; }; function useDocumentationSearch({ docsSearch, minDocsSearchLength, }: DocumentationSearchOptions) { const [documentationItems, setDocumentationItems] = useState<SearchItem[]>( [], ); const [documentationLoading, setDocumentationLoading] = useState(false); const activeDocumentationRequest = useRef(0); const documentationSearchLength = minDocsSearchLength ?? 2; const runDocumentationSearch = useCallback( (nextQuery: string, nextScope: SearchScope) => { const nextTrimmedQuery = nextQuery.trim(); const nextRequest = activeDocumentationRequest.current + 1; activeDocumentationRequest.current = nextRequest; if ( !docsSearch || nextScope === "components" || nextTrimmedQuery.length < documentationSearchLength ) { setDocumentationItems([]); setDocumentationLoading(false); return; } setDocumentationLoading(true); docsSearch(nextTrimmedQuery) .then((results) => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationItems(results); } }) .catch(() => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationItems([]); } }) .finally(() => { if (activeDocumentationRequest.current === nextRequest) { setDocumentationLoading(false); } }); }, [docsSearch, documentationSearchLength], ); return { documentationItems, documentationLoading, documentationSearchLength, hasDocumentationSearch: docsSearch !== undefined, runDocumentationSearch, }; } type SearchDialogHandlersOptions = { enableKeyboardShortcut?: boolean; onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; runDocumentationSearch: (query: string, scope: SearchScope) => void; }; function useSearchDialogHandlers({ enableKeyboardShortcut, onDocsSelect, onSelect, runDocumentationSearch, }: SearchDialogHandlersOptions) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [scope, setScope] = useState<SearchScope>("components"); const toggleOpen = useCallback(() => { if (enableKeyboardShortcut ?? true) { setOpen((previous) => !previous); } }, [enableKeyboardShortcut]); useKeyboardShortcut(toggleOpen); const handleQueryChange = useCallback( (nextQuery: string) => { setQuery(nextQuery); runDocumentationSearch(nextQuery, scope); }, [runDocumentationSearch, scope], ); const handleScopeChange = useCallback( (nextScope: SearchScope) => { setScope(nextScope); runDocumentationSearch(query, nextScope); }, [query, runDocumentationSearch], ); const handleOpenChange = useCallback( (nextOpen: boolean) => { setOpen(nextOpen); if (nextOpen) { runDocumentationSearch(query, scope); } }, [query, runDocumentationSearch, scope], ); const handleComponentSelect = useCallback( (item: SearchItem) => { setOpen(false); onSelect(item); }, [onSelect], ); const handleDocumentationSelect = useCallback( (item: SearchItem) => { setOpen(false); (onDocsSelect ?? onSelect)(item); }, [onDocsSelect, onSelect], ); return { handleComponentSelect, handleDocumentationSelect, handleOpenChange, handleQueryChange, handleScopeChange, open, query, scope, }; } function getCurrentEmptyText({ docsEmptyText, documentationSearchLength, emptyText, scope, trimmedQuery, }: { docsEmptyText?: string; documentationSearchLength: number; emptyText?: string; scope: SearchScope; trimmedQuery: string; }) { if (scope === "docs" && trimmedQuery.length < documentationSearchLength) { return `Type at least ${documentationSearchLength} characters to search docs.`; } if (scope === "docs") { return docsEmptyText ?? "No docs found."; } return emptyText ?? "No results found."; } type SearchDialogViewProps = Pick< SearchDialogProps, | "buttonText" | "buttonTextMobile" | "docsGroupHeading" | "groupHeading" | "searchPlaceholder" > & { currentEmptyText: string; documentationSearch: ReturnType<typeof useDocumentationSearch>; handlers: ReturnType<typeof useSearchDialogHandlers>; labels: Record<SearchScope, string>; showDocumentation: boolean; sortedItems: SearchItem[]; trimmedQuery: string; }; function SearchDialogView({ buttonText, buttonTextMobile, currentEmptyText, docsGroupHeading, documentationSearch, groupHeading, handlers, labels, searchPlaceholder, showDocumentation, sortedItems, trimmedQuery, }: SearchDialogViewProps) { const baseId = useId(); const getTabId = (s: SearchScope) => `${baseId}-tab-${s}`; const panelId = `${baseId}-panel`; return ( <> <SearchTriggerButton buttonText={buttonText} buttonTextMobile={buttonTextMobile} onOpen={() => { handlers.handleOpenChange(true); }} /> <CommandDialog onOpenChange={handlers.handleOpenChange} open={handlers.open} > <CommandInput onValueChange={handlers.handleQueryChange} placeholder={searchPlaceholder ?? "Search..."} value={handlers.query} /> {documentationSearch.hasDocumentationSearch ? ( <ScopeTabs getTabId={getTabId} labels={labels} onScopeChange={handlers.handleScopeChange} panelId={panelId} scope={handlers.scope} /> ) : null} <SearchDialogList activeTabId={ documentationSearch.hasDocumentationSearch ? getTabId(handlers.scope) : undefined } currentEmptyText={currentEmptyText} docsGroupHeading={docsGroupHeading} documentationItems={documentationSearch.documentationItems} documentationLoading={documentationSearch.documentationLoading} documentationSearchLength={ documentationSearch.documentationSearchLength } groupHeading={groupHeading} hasDocumentationSearch={documentationSearch.hasDocumentationSearch} labels={labels} onComponentSelect={handlers.handleComponentSelect} onDocumentationSelect={handlers.handleDocumentationSelect} panelId={ documentationSearch.hasDocumentationSearch ? panelId : undefined } query={handlers.query} scope={handlers.scope} showComponents={handlers.scope !== "docs"} showDocumentation={showDocumentation} sortedItems={sortedItems} trimmedQuery={trimmedQuery} /> </CommandDialog> </> ); } export function SearchDialog({ buttonText, buttonTextMobile, docsEmptyText, docsGroupHeading, docsSearch, emptyText, enableKeyboardShortcut, groupHeading, items, minDocsSearchLength, onDocsSelect, onSelect, scopeLabels, searchPlaceholder, }: SearchDialogProps) { const documentationSearch = useDocumentationSearch({ docsSearch, minDocsSearchLength, }); const handlers = useSearchDialogHandlers({ enableKeyboardShortcut, onDocsSelect, onSelect, runDocumentationSearch: documentationSearch.runDocumentationSearch, }); const labels = { ...DEFAULT_SCOPE_LABELS, ...scopeLabels }; const trimmedQuery = handlers.query.trim(); const sortedItems = useMemo( () => [...items].sort((a, b) => a.title.localeCompare(b.title)), [items], ); const currentEmptyText = getCurrentEmptyText({ docsEmptyText, documentationSearchLength: documentationSearch.documentationSearchLength, emptyText, scope: handlers.scope, trimmedQuery, }); const showDocumentation = documentationSearch.hasDocumentationSearch && handlers.scope !== "components"; return ( <SearchDialogView buttonText={buttonText} buttonTextMobile={buttonTextMobile} currentEmptyText={currentEmptyText} docsGroupHeading={docsGroupHeading} documentationSearch={documentationSearch} groupHeading={groupHeading} handlers={handlers} labels={labels} searchPlaceholder={searchPlaceholder} showDocumentation={showDocumentation} sortedItems={sortedItems} trimmedQuery={trimmedQuery} /> ); }

Dependencies

  • @vllnt/ui@^0.2.1