{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-search",
  "title": "Command Search",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Busca ⌘K que não sabe buscar nem navegar: onSearch entra por prop, e resposta fora de ordem não repovoa a lista.",
  "dependencies": [
    "cmdk",
    "react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/use-debounce"
  ],
  "files": [
    {
      "path": "registry/visant/ui/command-search.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Command } from \"cmdk\";\nimport { cn } from \"@/lib/utils\";\nimport { useDebounce } from \"@/hooks/use-debounce\";\n\n/**\n * CommandSearch — a busca ⌘K: gatilho, diálogo, resultado assíncrono.\n *\n * Colhida de um painel onde ela era inseparável do produto: dava `fetch` em\n * `/api/admin/search`, carregava nove rotas `/admin?tab=…` cravadas no arquivo,\n * lia o estado da sidebar e navegava com o router do Next. Servia a um app.\n *\n * Aqui o componente **não busca e não navega**. Ele recebe `onSearch` e chama\n * `onSelect`. É o que permite a mesma peça atender uma API REST, um índice local\n * e um banco vetorial sem tocar neste arquivo — e é o que a faz rodar fora do Next.\n *\n * ── A corrida que a versão original tinha ───────────────────────────────────\n * Duas buscas em voo voltam fora de ordem: \"cli\" demora 400ms, \"cliente\" volta em\n * 80ms, e a resposta de \"cli\" aterrissa **depois** — a lista mostra o resultado da\n * consulta antiga com o texto novo no campo. Ninguém reporta isso como bug; as\n * pessoas concluem que a busca \"é ruim\".\n *\n * O `seq` resolve: cada disparo carimba um número, e só a resposta do **último**\n * pode escrever no estado. O `AbortController` não bastaria sozinho, porque quem\n * implementa `onSearch` pode nem usar `fetch`.\n *\n * ── Outras decisões ─────────────────────────────────────────────────────────\n * - **Debounce antes do disparo**, não depois: a tecla não pode custar uma volta\n *   de rede.\n * - **`minChars` existe porque uma letra não é uma consulta.** Abaixo do mínimo o\n *   diálogo mostra os atalhos, que é a coisa útil a fazer com a busca vazia.\n * - **Erro é um estado**, não um `console.error`. Sem isso a lista fica em branco\n *   e parece \"nada encontrado\" — a pessoa reescreve a busca contra um servidor fora.\n * - **`shouldFilter={false}`**: quem filtra é quem tem o índice. Filtrar de novo\n *   aqui esconderia resultado que o servidor achou por sinônimo ou por typo.\n * - O gatilho tem `aria-label` mesmo quando mostra texto: recolhido, ele é só um\n *   ícone, e \"button\" não é nome acessível.\n */\n\nexport interface CommandSearchResult {\n  id: string;\n  title: string;\n  subtitle?: string;\n  /** Rótulo curto de categoria. Aparece como selo à direita. */\n  type?: string;\n  icon?: React.ReactNode;\n}\n\nexport interface CommandSearchProps {\n  onSearch: (query: string, signal: AbortSignal) => Promise<CommandSearchResult[]>;\n  onSelect: (result: CommandSearchResult) => void;\n  /** Mostrados com a busca vazia — o \"acesso rápido\". */\n  quickLinks?: CommandSearchResult[];\n  minChars?: number;\n  delay?: number;\n  placeholder?: string;\n  triggerLabel?: string;\n  quickLinksLabel?: string;\n  resultsLabel?: string;\n  emptyLabel?: string;\n  errorLabel?: string;\n  /** Só o ícone, sem rótulo nem atalho. Para barras estreitas e sidebar recolhida. */\n  compact?: boolean;\n  /** O painel direto na página, sem gatilho nem diálogo. Para tela de busca e vitrine. */\n  inline?: boolean;\n  /**\n   * Tecla do atalho global, combinada com ⌘/Ctrl. `null` desliga o atalho e\n   * deixa só o clique no gatilho.\n   *\n   * Existe porque o atalho é registrado no `document`: duas instâncias montadas\n   * na mesma página disputam o ⌘K e as duas abrem. Acontece de verdade quando um\n   * app tem busca no cabeçalho e busca dentro de um painel — e aconteceria nesta\n   * própria vitrine, onde montar a demo roubaria o ⌘K de quem só está navegando.\n   */\n  shortcut?: string | null;\n  className?: string;\n}\n\nfunction SearchGlyph({ className }: { className?: string }) {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      className={className}\n    >\n      <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n      <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nexport function CommandSearch({\n  onSearch,\n  onSelect,\n  quickLinks = [],\n  minChars = 2,\n  delay = 300,\n  placeholder = \"O que você está procurando?\",\n  triggerLabel = \"Buscar\",\n  quickLinksLabel = \"Acesso rápido\",\n  resultsLabel = \"Resultados\",\n  emptyLabel = \"Nenhum resultado encontrado.\",\n  errorLabel = \"Não foi possível buscar agora.\",\n  compact = false,\n  inline = false,\n  shortcut = \"k\",\n  className,\n}: CommandSearchProps) {\n  const [open, setOpen] = React.useState(false);\n  const [query, setQuery] = React.useState(\"\");\n  const [results, setResults] = React.useState<CommandSearchResult[]>([]);\n  const [loading, setLoading] = React.useState(false);\n  const [failed, setFailed] = React.useState(false);\n\n  const debounced = useDebounce(query, delay);\n  const aberto = inline || open;\n  const seq = React.useRef(0);\n\n  React.useEffect(() => {\n    if (!shortcut || inline) return;\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key.toLowerCase() === shortcut.toLowerCase() && (event.metaKey || event.ctrlKey)) {\n        event.preventDefault();\n        setOpen((v) => !v);\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, inline]);\n\n  React.useEffect(() => {\n    if (!aberto) return;\n\n    const needle = debounced.trim();\n    if (needle.length < minChars) {\n      // Sobe o carimbo: uma resposta em voo da consulta anterior não pode\n      // repovoar a lista depois que a pessoa apagou o texto.\n      seq.current += 1;\n      setResults([]);\n      setLoading(false);\n      setFailed(false);\n      return;\n    }\n\n    const mine = ++seq.current;\n    const controller = new AbortController();\n    setLoading(true);\n    setFailed(false);\n\n    onSearch(needle, controller.signal)\n      .then((found) => {\n        if (mine !== seq.current) return; // chegou tarde: manda a antiga embora.\n        setResults(found);\n      })\n      .catch((error) => {\n        if (mine !== seq.current || controller.signal.aborted) return;\n        // Erro é estado. Em branco a pessoa acha que não existe o que ela procura.\n        setFailed(true);\n        setResults([]);\n        // Sem guarda de ambiente: o registry atravessa Vite, Next e RSC, e cada\n        // um expõe `process`/`import.meta.env` de um jeito. Falha de busca é rara\n        // o bastante para o log valer em produção também.\n        console.error(error);\n      })\n      .finally(() => {\n        if (mine === seq.current) setLoading(false);\n      });\n\n    return () => controller.abort();\n  }, [debounced, aberto, minChars, onSearch]);\n\n  const choose = (result: CommandSearchResult) => {\n    setOpen(false);\n    setQuery(\"\");\n    onSelect(result);\n  };\n\n  const showQuick = query.trim().length < minChars && quickLinks.length > 0;\n\n  const row = (result: CommandSearchResult) => (\n    <Command.Item\n      key={`${result.type ?? \"r\"}-${result.id}`}\n      value={`${result.id} ${result.title} ${result.subtitle ?? \"\"}`}\n      onSelect={() => choose(result)}\n      className={cn(\n        \"flex cursor-pointer select-none items-center justify-between gap-3\",\n        \"rounded-[var(--r-control,0.375rem)] px-2 py-2 text-sm outline-none\",\n        \"data-[selected=true]:bg-[var(--accent,var(--muted))]\",\n      )}\n    >\n      <span className=\"flex min-w-0 items-center gap-2\">\n        {result.icon ? <span className=\"shrink-0 opacity-70\">{result.icon}</span> : null}\n        <span className=\"flex min-w-0 flex-col\">\n          <span className=\"truncate font-semibold\">{result.title}</span>\n          {result.subtitle ? (\n            <span className=\"truncate text-xs text-[var(--muted-foreground)]\">{result.subtitle}</span>\n          ) : null}\n        </span>\n      </span>\n      {result.type ? (\n        <span className=\"shrink-0 rounded-full border border-[var(--border)] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide opacity-60\">\n          {result.type}\n        </span>\n      ) : null}\n    </Command.Item>\n  );\n\n  const corpo = (\n    <>\n      <div className=\"flex items-center gap-2.5 border-b border-[var(--border)] px-4\">\n        <SearchGlyph className=\"h-4 w-4 shrink-0 text-[var(--muted-foreground)]\" />\n        <Command.Input\n          value={query}\n          onValueChange={setQuery}\n          placeholder={placeholder}\n          className=\"h-12 w-full bg-transparent text-sm outline-none placeholder:text-[var(--muted-foreground)]\"\n        />\n        {loading ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"h-3.5 w-3.5 shrink-0 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--foreground)] motion-reduce:animate-none\"\n          />\n        ) : null}\n      </div>\n\n      <Command.List className=\"max-h-[min(24rem,60vh)] overflow-y-auto p-2\">\n        {showQuick ? (\n          <Command.Group\n            heading={quickLinksLabel}\n            className={cn(\n              \"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5\",\n              \"[&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-bold\",\n              \"[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.08em]\",\n              \"[&_[cmdk-group-heading]]:text-[var(--muted-foreground)]\",\n            )}\n          >\n            {quickLinks.map(row)}\n          </Command.Group>\n        ) : null}\n\n        {results.length > 0 ? (\n          <Command.Group\n            heading={resultsLabel}\n            className={cn(\n              \"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5\",\n              \"[&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-bold\",\n              \"[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.08em]\",\n              \"[&_[cmdk-group-heading]]:text-[var(--muted-foreground)]\",\n            )}\n          >\n            {results.map(row)}\n          </Command.Group>\n        ) : null}\n\n        {failed ? (\n          <p className=\"px-2 py-8 text-center text-sm text-[var(--destructive)]\">{errorLabel}</p>\n        ) : null}\n\n        {!failed && !loading && !showQuick && results.length === 0 && query.trim().length >= minChars ? (\n          <p className=\"px-2 py-8 text-center text-sm text-[var(--muted-foreground)]\">{emptyLabel}</p>\n        ) : null}\n      </Command.List>\n    </>\n  );\n\n  if (inline) {\n    return (\n      <Command\n        label={triggerLabel}\n        shouldFilter={false}\n        className={cn(\n          \"w-full overflow-hidden rounded-[var(--r-surface,0.75rem)] border border-[var(--border)]\",\n          \"bg-[var(--popover,var(--background))] text-[var(--popover-foreground,var(--foreground))]\",\n          \"[box-shadow:var(--e-overlay,0_12px_32px_-12px_rgb(0_0_0/0.35))]\",\n          className,\n        )}\n      >\n        {corpo}\n      </Command>\n    );\n  }\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        // Recolhido este botão é só um ícone: sem nome acessível ele se anuncia\n        // como \"button\" no leitor de tela e no Tab.\n        aria-label={shortcut ? `${triggerLabel} (Ctrl+${shortcut.toUpperCase()})` : triggerLabel}\n        className={cn(\n          \"group relative flex h-[var(--control-h,2.25rem)] items-center pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5\",\n          \"rounded-[var(--r-control,0.5rem)] border border-[var(--border)]\",\n          \"text-[var(--muted-foreground)]\",\n          \"transition-[background-color,border-color,color,box-shadow]\",\n          \"[transition-duration:var(--dur-fast,160ms)]\",\n          \"[transition-timing-function:var(--ease-out)]\",\n          \"hover:bg-[var(--muted)] hover:text-[var(--foreground)]\",\n          \"outline-none focus-visible:border-[color-mix(in_oklab,var(--ring)_60%,var(--foreground))] focus-visible:[box-shadow:0_0_0_1px_color-mix(in_oklab,var(--ring)_30%,transparent)]\",\n          compact ? \"w-9 justify-center\" : \"w-full gap-2.5 px-3 text-sm\",\n          className,\n        )}\n      >\n        <SearchGlyph className=\"h-3.5 w-3.5 shrink-0\" />\n        {!compact ? (\n          <>\n            <span className=\"truncate\">{triggerLabel}</span>\n            {/* Anunciar um atalho desligado é pior que não anunciar nenhum. */}\n            {shortcut ? (\n              <kbd className=\"pointer-events-none ml-auto hidden select-none items-center gap-0.5 rounded border border-[var(--border)] px-1.5 py-0.5 font-mono text-[10px] font-bold opacity-70 sm:flex\">\n                ⌘{shortcut.toUpperCase()}\n              </kbd>\n            ) : null}\n          </>\n        ) : null}\n      </button>\n\n      <Command.Dialog\n        open={open}\n        onOpenChange={setOpen}\n        label={triggerLabel}\n        // Quem filtra é quem tem o índice — ver o bloco no topo.\n        shouldFilter={false}\n        className={cn(\n          \"fixed left-1/2 top-[15vh] z-50 w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2\",\n          \"overflow-hidden rounded-[var(--r-surface,0.75rem)] border border-[var(--border)]\",\n          \"bg-[var(--popover,var(--background))] text-[var(--popover-foreground,var(--foreground))]\",\n          \"[box-shadow:var(--e-modal,0_24px_64px_-16px_rgb(0_0_0/0.45))]\",\n        )}\n      >\n        {corpo}\n      </Command.Dialog>\n    </>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "controles",
    "busca",
    "comando",
    "teclado"
  ],
  "type": "registry:ui"
}