{
  "name": "tsf-combobox-field",
  "type": "registry:ui",
  "dependencies": [
    "cmdk",
    "lucide-react",
    "react"
  ],
  "registryDependencies": [
    "badge",
    "command",
    "field",
    "tsf-form-context"
  ],
  "files": [
    {
      "path": "./items/tanstack-form/combobox-field/component.tsx",
      "type": "registry:ui",
      "target": "./components/ui/shuip/tanstack-form/combobox-field.tsx",
      "content": "'use client';\n\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { Check, Loader2, Search, X } from 'lucide-react';\nimport * as React from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';\nimport { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';\nimport { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';\nimport { cn } from '@/lib/utils';\n\nconst DEBOUNCE_TIME = 300;\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  sublabel?: string;\n}\n\ntype ComboboxFieldVariant = 'boxed' | 'ghost';\ntype ComboboxFieldSize = 'sm' | 'default';\n\nexport interface ComboboxFieldProps {\n  multiple?: boolean;\n  options?: ComboboxOption[];\n  onSearch?: (query: string) => Promise<ComboboxOption[]>;\n  maxResults?: number;\n  variant?: ComboboxFieldVariant;\n  size?: ComboboxFieldSize;\n  label?: string;\n  description?: string;\n  placeholder?: string;\n  emptyText?: string;\n  debounceMs?: number;\n  defaultSelected?: ComboboxOption | ComboboxOption[];\n}\n\nconst shellVariants: Record<ComboboxFieldVariant, string> = {\n  boxed:\n    'rounded-md border border-input bg-transparent shadow-xs focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20',\n  ghost: 'rounded-md border border-transparent bg-transparent',\n};\n\nconst shellSizes: Record<ComboboxFieldSize, string> = {\n  default: 'min-h-9 gap-1.5 px-2 py-1 text-sm',\n  sm: 'min-h-7 gap-1 px-1.5 py-0.5 text-xs',\n};\n\nfunction toArray(value: string | string[] | undefined | null): string[] {\n  if (Array.isArray(value)) return value;\n  if (value) return [value];\n  return [];\n}\n\nexport function ComboboxField({\n  multiple = false,\n  options,\n  onSearch,\n  maxResults,\n  variant = 'boxed',\n  size = 'default',\n  label,\n  description,\n  placeholder,\n  emptyText = 'No results',\n  debounceMs = DEBOUNCE_TIME,\n  defaultSelected,\n}: ComboboxFieldProps) {\n  // The consumer's field type (string vs string[]) decides single vs multi; the context is\n  // widened to the shared shape and values are normalised through `toArray`.\n  const field = useFieldContext<string | string[]>();\n  const { isValid, errors } = field.state.meta;\n\n  const [open, setOpen] = React.useState(false);\n  const [query, setQuery] = React.useState('');\n  const [typed, setTyped] = React.useState(false);\n  const [results, setResults] = React.useState<ComboboxOption[]>([]);\n  const [isPending, startTransition] = React.useTransition();\n  const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const requestIdRef = React.useRef(0);\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n\n  // Static options + presets, resolved during render so a value shows its label on first\n  // paint; async results and manual picks live in `cacheRef`. `getOption` reads both.\n  const cacheRef = React.useRef<Map<string, ComboboxOption> | null>(null);\n  cacheRef.current ??= new Map();\n  const staticLookup = React.useMemo(() => {\n    const map = new Map<string, ComboboxOption>();\n    const seed = Array.isArray(defaultSelected) ? defaultSelected : defaultSelected ? [defaultSelected] : [];\n    for (const option of [...seed, ...(options ?? [])]) map.set(option.value, option);\n    return map;\n  }, [defaultSelected, options]);\n  const getOption = React.useCallback(\n    (value: string | undefined) => (value ? (staticLookup.get(value) ?? cacheRef.current?.get(value)) : undefined),\n    [staticLookup],\n  );\n\n  const selectedValues = toArray(field.state.value);\n\n  // A pre-filled-but-untouched single label counts as an empty query, so the full list\n  // (or `onSearch('')` recents) shows on focus instead of filtering down to the label.\n  const effectiveQuery = typed ? query : '';\n\n  // Held in a ref so an inline `onSearch` prop doesn't re-trigger the search effect every render.\n  const onSearchRef = React.useRef(onSearch);\n  onSearchRef.current = onSearch;\n  const hasSearch = Boolean(onSearch);\n\n  const runSearch = React.useCallback((search: string) => {\n    const search$ = onSearchRef.current;\n    if (!search$) return;\n    const requestId = ++requestIdRef.current;\n    startTransition(async () => {\n      try {\n        const res = await search$(search);\n        if (requestId !== requestIdRef.current) return;\n        for (const option of res) cacheRef.current?.set(option.value, option);\n        setResults(res);\n      } catch {\n        if (requestId !== requestIdRef.current) return;\n        setResults([]);\n      }\n    });\n  }, []);\n\n  React.useEffect(() => {\n    if (!hasSearch || !open) return;\n\n    if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);\n\n    if (!effectiveQuery) {\n      runSearch('');\n      return;\n    }\n\n    debounceTimerRef.current = setTimeout(() => runSearch(effectiveQuery), debounceMs);\n\n    return () => {\n      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);\n    };\n  }, [effectiveQuery, hasSearch, open, debounceMs, runSearch]);\n\n  const items = React.useMemo(() => {\n    let list: ComboboxOption[];\n    if (onSearch) {\n      list = results;\n    } else if (!options) {\n      list = [];\n    } else if (!effectiveQuery) {\n      list = options;\n    } else {\n      list = options.filter((option) => option.label.toLowerCase().includes(effectiveQuery.toLowerCase()));\n    }\n    return maxResults ? list.slice(0, maxResults) : list;\n  }, [onSearch, results, options, effectiveQuery, maxResults]);\n\n  const commit = (next: string[]) => {\n    field.handleChange(multiple ? next : (next[0] ?? ''));\n  };\n\n  const firstValue = selectedValues[0];\n  const singleLabel = !multiple ? getOption(firstValue)?.label : undefined;\n  const inputValue = open ? query : multiple ? '' : (singleLabel ?? firstValue ?? '');\n\n  const closeMenu = () => {\n    setOpen(false);\n    setQuery('');\n    setTyped(false);\n  };\n\n  const handleFocus = () => {\n    setOpen(true);\n    setTyped(false);\n    if (!multiple && singleLabel) {\n      setQuery(singleLabel);\n      requestAnimationFrame(() => inputRef.current?.select());\n    } else {\n      setQuery('');\n    }\n  };\n\n  const handleBlur = (e: React.FocusEvent) => {\n    if (containerRef.current?.contains(e.relatedTarget as Node | null)) return;\n    field.handleBlur();\n    closeMenu();\n  };\n\n  const handleSelect = (option: ComboboxOption) => {\n    cacheRef.current?.set(option.value, option);\n    if (multiple) {\n      const next = selectedValues.includes(option.value)\n        ? selectedValues.filter((value) => value !== option.value)\n        : [...selectedValues, option.value];\n      commit(next);\n      setQuery('');\n      setTyped(false);\n      inputRef.current?.focus();\n      return;\n    }\n    commit([option.value]);\n    closeMenu();\n    inputRef.current?.blur();\n  };\n\n  const removeValue = (value: string) => {\n    commit(selectedValues.filter((current) => current !== value));\n  };\n\n  const handleValueChange = (value: string) => {\n    setQuery(value);\n    setTyped(true);\n    setOpen(true);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Escape') {\n      e.preventDefault();\n      closeMenu();\n      inputRef.current?.blur();\n      return;\n    }\n    if (multiple && e.key === 'Backspace' && query === '' && selectedValues.length > 0) {\n      removeValue(selectedValues[selectedValues.length - 1]);\n    }\n  };\n\n  const iconSize = size === 'sm' ? 'size-3.5' : 'size-4';\n  const showPlaceholder = multiple ? selectedValues.length === 0 : true;\n\n  return (\n    <Field className='gap-2' data-invalid={!isValid}>\n      {label && <FieldLabel>{label}</FieldLabel>}\n      <Command shouldFilter={false} className='relative h-auto overflow-visible bg-transparent'>\n        <div\n          ref={containerRef}\n          aria-invalid={!isValid || undefined}\n          className={cn('flex w-full flex-wrap items-center', shellVariants[variant], shellSizes[size])}\n          onMouseDown={(e) => {\n            if (e.target !== inputRef.current) {\n              e.preventDefault();\n              inputRef.current?.focus();\n            }\n          }}\n        >\n          <Search className={cn('shrink-0 text-muted-foreground', iconSize)} aria-hidden />\n          {multiple &&\n            selectedValues.map((value) => (\n              <Badge key={value} variant='secondary' className='gap-1'>\n                {getOption(value)?.label ?? value}\n                <button\n                  type='button'\n                  className='cursor-pointer rounded-full outline-none focus-visible:ring-1 focus-visible:ring-ring'\n                  onMouseDown={(e) => {\n                    e.preventDefault();\n                    removeValue(value);\n                  }}\n                  aria-label={`Remove ${getOption(value)?.label ?? value}`}\n                >\n                  <X className='size-3' />\n                </button>\n              </Badge>\n            ))}\n          <CommandPrimitive.Input\n            ref={inputRef}\n            value={inputValue}\n            placeholder={showPlaceholder ? placeholder : undefined}\n            aria-label={label}\n            aria-invalid={!isValid || undefined}\n            onValueChange={handleValueChange}\n            onFocus={handleFocus}\n            onBlur={handleBlur}\n            onKeyDown={handleKeyDown}\n            className='min-w-20 flex-1 bg-transparent outline-hidden placeholder:text-muted-foreground'\n          />\n          {isPending && <Loader2 className={cn('shrink-0 animate-spin text-muted-foreground', iconSize)} />}\n        </div>\n        {open && (\n          <div\n            className='absolute top-full left-0 z-50 mt-1 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md'\n            onMouseDown={(e) => e.preventDefault()}\n          >\n            <CommandList className='max-h-60'>\n              {items.length === 0 ? (\n                <div className='py-6 text-center text-sm text-muted-foreground'>\n                  {isPending ? 'Searching…' : emptyText}\n                </div>\n              ) : (\n                <CommandGroup>\n                  {items.map((option) => {\n                    const isSelected = selectedValues.includes(option.value);\n                    return (\n                      <CommandItem\n                        key={option.value}\n                        value={option.value}\n                        onSelect={() => handleSelect(option)}\n                        className='cursor-pointer'\n                      >\n                        <Check className={isSelected ? 'opacity-100' : 'opacity-0'} />\n                        <div className='flex flex-col'>\n                          <span>{option.label}</span>\n                          {option.sublabel && <span className='text-xs text-muted-foreground'>{option.sublabel}</span>}\n                        </div>\n                      </CommandItem>\n                    );\n                  })}\n                </CommandGroup>\n              )}\n            </CommandList>\n          </div>\n        )}\n      </Command>\n      {!isValid && (\n        <FieldError\n          className='text-xs text-left'\n          errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}\n        />\n      )}\n      {description && <FieldDescription className='text-xs'>{description}</FieldDescription>}\n    </Field>\n  );\n}\n"
    }
  ],
  "$schema": "https://ui.shadcn.com/schema/registry-item.json"
}
