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