{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "searchable-select",
  "title": "Searchable Select",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Combobox com busca em rótulo e keywords ocultos, grupos, e criação no lugar que sobrevive à falha. Digita dentro de Dialog.",
  "dependencies": [
    "@radix-ui/react-popover",
    "cmdk",
    "react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/searchable-select.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as Popover from \"@radix-ui/react-popover\";\nimport { Command } from \"cmdk\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * SearchableSelect — combobox com busca, grupos e criação no lugar.\n *\n * ── Por que Popover + cmdk, e não um Select ─────────────────────────────────\n * A primeira versão era um Radix Select com um `<input>` enfiado no conteúdo. Não\n * dava para digitar: o Select é dono do typeahead por teclado e do foco itinerante,\n * e puxava o foco do campo a cada tecla. Dentro de um Dialog quebrava de vez.\n * Popover + cmdk é o padrão canônico e digita em qualquer lugar.\n *\n * ── `modal` no Popover não é opcional ───────────────────────────────────────\n * Quando este combobox vive dentro de um Dialog, o `PopoverContent` sai do DOM do\n * Dialog por portal — e o focus-trap do Dialog continua mandando, roubando o foco\n * de volta do campo de busca. `modal` pausa esse escopo enquanto o popover está\n * aberto. Fora de Dialog, é inofensivo. Tirar essa prop reintroduz um bug que só\n * aparece dentro de modal, que é o pior lugar para descobrir.\n *\n * ── A busca tem uma fonte de verdade só ─────────────────────────────────────\n * `shouldFilter={false}`: o cmdk não filtra, quem filtra é a função `matches`.\n * Precisa ser assim porque a busca casa contra três coisas — o rótulo, os\n * `keywords` **ocultos** da opção e o nome do grupo. Um cliente digita \"Construir\n * Aí\" e acha a tarefa cujo título não tem o nome do cliente. Deixar o cmdk filtrar\n * em paralelo criaria dois critérios discordando na mesma lista.\n *\n * ── `onCreate` fica no fim e aparece mesmo com resultados ───────────────────\n * \"Achei parecido\" não é \"achei\". O `+ Criar \"…\"` do Notion/Linear vive embaixo da\n * lista sempre que há texto. Quando a criação **falha**, `onCreate` devolve `null`\n * e o popover **fica aberto com o texto intacto** — refazer é uma tecla, não\n * redigitar tudo. O combobox não conhece rota nem mensagem de erro: quem chamou dá\n * o recado.\n *\n * Nenhuma string de interface é fixa: tudo é prop, porque o registry atravessa\n * marcas e idiomas.\n */\n\nexport interface SearchableOption {\n  value: string;\n  label: string;\n  icon?: React.ReactNode;\n  /**\n   * Termos que a busca considera mas que NÃO aparecem no rótulo — ex.: o nome do\n   * cliente de uma tarefa. Deixa \"grepar\" por algo que está fora da tela.\n   */\n  keywords?: string;\n}\n\nexport interface SearchableSelectProps {\n  value: string;\n  onValueChange: (value: string) => void;\n  options?: SearchableOption[];\n  groups?: Array<{ label: string; options: SearchableOption[] }>;\n  placeholder?: string;\n  searchPlaceholder?: string;\n  emptyLabel?: string;\n  noResultsLabel?: string;\n  /** Valor sentinela do item \"nenhum\". Trocável se colidir com um valor real. */\n  noneValue?: string;\n  /** Esconde o item \"nenhum\" — use quando toda opção real já é um valor válido. */\n  hideEmptyOption?: boolean;\n  /** Seleciona sozinho quando sobra exatamente uma opção. Para edição em célula. */\n  autoSelect?: boolean;\n  /** Cria de verdade a partir do texto buscado. `null` = falhou, popover fica aberto. */\n  onCreate?: (label: string) => Promise<string | null>;\n  /** Substantivo do que se cria: \"task\", \"projeto\", \"cliente\". */\n  createEntityLabel?: string;\n  createLabel?: string;\n  creatingLabel?: string;\n  /** \"trailing\" (padrão) é selo esmaecido; \"leading\" é para avatar e thumbnail. */\n  iconPosition?: \"leading\" | \"trailing\";\n  onOpenChange?: (open: boolean) => void;\n  /** A lista direto na página, sem gatilho nem popover. Para painel lateral e vitrine. */\n  inline?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\nconst itemClass = cn(\n  \"flex cursor-pointer select-none items-center gap-2 rounded-[var(--r-control,0.375rem)]\",\n  \"px-2 py-1.5 pointer-coarse:min-h-11 text-sm outline-none\",\n  \"data-[selected=true]:bg-[var(--accent,var(--muted))]\",\n  \"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50\",\n);\n\nexport function SearchableSelect({\n  value,\n  onValueChange,\n  options,\n  groups,\n  placeholder = \"Selecione...\",\n  searchPlaceholder = \"Buscar...\",\n  emptyLabel = \"Nenhum\",\n  noResultsLabel = \"Nenhum resultado encontrado\",\n  noneValue = \"__none__\",\n  hideEmptyOption = false,\n  autoSelect = false,\n  onCreate,\n  createEntityLabel,\n  createLabel = \"Criar\",\n  creatingLabel = \"Criando\",\n  iconPosition = \"trailing\",\n  onOpenChange,\n  inline = false,\n  disabled,\n  className,\n}: SearchableSelectProps) {\n  const [search, setSearch] = React.useState(\"\");\n  const [open, setOpen] = React.useState(false);\n  const [creating, setCreating] = React.useState(false);\n\n  const allOptions = React.useMemo<SearchableOption[]>(\n    () => (groups ? groups.flatMap((g) => g.options) : options ?? []),\n    [options, groups],\n  );\n\n  const selectedLabel = React.useMemo(() => {\n    if (!value || value === noneValue) return \"\";\n    return allOptions.find((o) => o.value === value)?.label ?? value;\n  }, [allOptions, value, noneValue]);\n\n  const filteredGroups = React.useMemo(() => {\n    const needle = search.toLowerCase().trim();\n\n    // Fonte única do que a busca enxerga: rótulo + keywords ocultos + nome do grupo.\n    const matches = (opt: SearchableOption, extra?: string) =>\n      `${opt.label} ${opt.keywords ?? \"\"} ${extra ?? \"\"}`.toLowerCase().includes(needle);\n\n    if (groups) {\n      if (!needle) return groups;\n      return groups\n        .map((group) => ({ ...group, options: group.options.filter((o) => matches(o, group.label)) }))\n        .filter((group) => group.options.length > 0);\n    }\n\n    if (options) {\n      if (!needle) return [{ label: \"\", options }];\n      const hit = options.filter((o) => matches(o));\n      return hit.length > 0 ? [{ label: \"\", options: hit }] : [];\n    }\n\n    return [];\n  }, [options, groups, search]);\n\n  const visibleCount = React.useMemo(\n    () => filteredGroups.reduce((acc, g) => acc + g.options.length, 0),\n    [filteredGroups],\n  );\n\n  const handleOpenChange = React.useCallback(\n    (next: boolean) => {\n      setOpen(next);\n      if (!next) setSearch(\"\");\n      onOpenChange?.(next);\n    },\n    [onOpenChange],\n  );\n\n  const select = React.useCallback(\n    (v: string) => {\n      onValueChange(v);\n      handleOpenChange(false);\n    },\n    [onValueChange, handleOpenChange],\n  );\n\n  const handleCreate = React.useCallback(async () => {\n    const label = search.trim();\n    if (!label || !onCreate || creating) return;\n    setCreating(true);\n    try {\n      const created = await onCreate(label);\n      // Falhou: popover aberto, texto intacto. Refazer é uma tecla.\n      if (created) select(created);\n    } finally {\n      setCreating(false);\n    }\n  }, [search, onCreate, creating, select]);\n\n  // Sobrou uma: escolhe sozinha. É o que faz a edição em célula ser uma digitada.\n  React.useEffect(() => {\n    if (!autoSelect || !(open || inline) || visibleCount !== 1) return;\n    const only = filteredGroups.find((g) => g.options.length > 0)?.options[0];\n    if (only && only.value !== value) select(only.value);\n  }, [filteredGroups, visibleCount, autoSelect, open, inline, value, select]);\n\n  const showCreate = Boolean(onCreate && search.trim());\n\n  // Quem filtra é `matches`, acima. Duas fontes discordariam.\n  const lista = (\n        <Command shouldFilter={false}>\n      <div className=\"flex items-center gap-2 border-b border-[var(--border)] px-3\">\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          className=\"h-3.5 w-3.5 shrink-0 text-[var(--muted-foreground)]\"\n        >\n          <circle cx=\"7\" cy=\"7\" r=\"4.5\" />\n          <path d=\"M10.5 10.5L14 14\" strokeLinecap=\"round\" />\n        </svg>\n        <Command.Input\n          value={search}\n          onValueChange={setSearch}\n          placeholder={searchPlaceholder}\n          className={cn(\n            \"h-10 pointer-coarse:h-11 w-full bg-transparent text-sm outline-none\",\n            \"placeholder:text-[var(--muted-foreground)]\",\n          )}\n        />\n      </div>\n\n      <Command.List className=\"max-h-[min(18.75rem,var(--radix-popover-content-available-height,18.75rem))] overflow-y-auto p-1\">\n        {!hideEmptyOption ? (\n          <Command.Item value={noneValue} onSelect={() => select(noneValue)} className={cn(itemClass, \"italic text-[var(--muted-foreground)]\")}>\n            {emptyLabel}\n          </Command.Item>\n        ) : null}\n\n        {filteredGroups.map((group, index) => (\n          <Command.Group\n            key={group.label || index}\n            heading={group.label || undefined}\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            {group.options.map((option) => (\n              <Command.Item key={option.value} value={option.value} onSelect={() => select(option.value)} className={itemClass}>\n                {option.icon && iconPosition === \"leading\" ? (\n                  <span className=\"flex shrink-0 items-center\">{option.icon}</span>\n                ) : null}\n                <span className=\"min-w-0 truncate\">{option.label}</span>\n                {option.icon && iconPosition === \"trailing\" ? (\n                  <span className=\"ml-auto opacity-50\">{option.icon}</span>\n                ) : null}\n                {option.value === value ? (\n                  <svg\n                    aria-hidden=\"true\"\n                    viewBox=\"0 0 12 12\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    className={cn(\"h-3 w-3 shrink-0\", !(option.icon && iconPosition === \"trailing\") && \"ml-auto\")}\n                  >\n                    <path d=\"M2 6.5l2.5 2.5L10 3.5\" />\n                  </svg>\n                ) : null}\n              </Command.Item>\n            ))}\n          </Command.Group>\n        ))}\n\n        {/* Vazio só é vazio quando não há saída oferecida abaixo. */}\n        {visibleCount === 0 && !showCreate ? (\n          <p className=\"px-2 py-6 text-center text-sm text-[var(--muted-foreground)]\">\n            {noResultsLabel}\n          </p>\n        ) : null}\n\n        {showCreate ? (\n          <Command.Group>\n            <Command.Item\n              value={`__create__${search}`}\n              disabled={creating}\n              onSelect={handleCreate}\n              className={itemClass}\n            >\n              <span aria-hidden=\"true\" className={cn(\"shrink-0 text-base leading-none\", creating && \"animate-pulse\")}>\n                +\n              </span>\n              <span className=\"min-w-0 truncate\">\n                {creating ? creatingLabel : createLabel}\n                {createEntityLabel ? ` ${createEntityLabel}` : \"\"} “{search.trim()}”\n              </span>\n            </Command.Item>\n          </Command.Group>\n        ) : null}\n      </Command.List>\n    </Command>\n  );\n\n  if (inline) {\n    return (\n      <div\n        className={cn(\n          \"w-full overflow-hidden rounded-[var(--r-surface,0.625rem)] 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        {lista}\n      </div>\n    );\n  }\n\n  return (\n    // `modal`: ver o bloco no topo. Não remova.\n    <Popover.Root open={open} onOpenChange={handleOpenChange} modal>\n      <Popover.Trigger\n        disabled={disabled}\n        aria-haspopup=\"listbox\"\n        className={cn(\n          \"group relative flex h-[var(--control-h,2.5rem)] w-full items-center justify-between gap-2 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5\",\n          \"rounded-[var(--r-control,0.5rem)] border border-[var(--input,var(--border))]\",\n          \"bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)]\",\n          \"hover:bg-[var(--muted)] disabled:hover:bg-transparent\",\n          \"transition-[background-color,border-color,box-shadow]\",\n          \"[transition-duration:var(--dur-fast,160ms)]\",\n          \"[transition-timing-function:var(--ease-out)]\",\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          \"disabled:cursor-not-allowed disabled:opacity-50\",\n          className,\n        )}\n      >\n        <span className={cn(\"line-clamp-1 text-left\", !selectedLabel && \"text-[var(--muted-foreground)]\")}>\n          {selectedLabel || placeholder}\n        </span>\n        <svg\n          aria-hidden=\"true\"\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"h-4 w-4 shrink-0 opacity-50 transition-transform [transition-duration:var(--dur-base,200ms)] [transition-timing-function:var(--ease-out)] group-data-[state=open]:rotate-180 motion-reduce:transition-none\"\n        >\n          <path d=\"M4 6l4 4 4-4\" />\n        </svg>\n      </Popover.Trigger>\n\n      <Popover.Portal>\n        <Popover.Content\n          align=\"start\"\n          sideOffset={4}\n          collisionPadding={8}\n          className={cn(\n            \"z-50 w-[var(--radix-popover-trigger-width)] overflow-hidden p-0\",\n            \"rounded-[var(--r-surface,0.625rem)] 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            // Cresce do gatilho e fecha de volta nele. Sem isto a lista pulava pronta na tela.\n            \"origin-[var(--radix-popover-content-transform-origin)]\",\n            \"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n            \"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n            \"[animation-duration:var(--dur-fast,160ms)] [animation-timing-function:var(--ease-out)]\",\n          )}\n        >\n          {lista}\n        </Popover.Content>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "controles",
    "combobox",
    "busca",
    "formulario"
  ],
  "type": "registry:ui"
}