{
  "name": "rhf-autocomplete-field",
  "type": "registry:ui",
  "dependencies": [
    "@hookform/lenses",
    "lucide-react",
    "react",
    "react-hook-form"
  ],
  "registryDependencies": [
    "command",
    "field",
    "input",
    "popover"
  ],
  "files": [
    {
      "path": "./items/react-hook-form/autocomplete-field/component.tsx",
      "type": "registry:ui",
      "target": "./components/ui/shuip/react-hook-form/autocomplete-field.tsx",
      "content": "'use client';\n\nimport type { Lens } from '@hookform/lenses';\nimport { Loader2 } from 'lucide-react';\nimport * as React from 'react';\nimport { useController } from 'react-hook-form';\nimport { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';\nimport { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';\nimport { Input } from '@/components/ui/input';\nimport { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';\nimport { cn } from '@/lib/utils';\n\nconst DEBOUNCE_TIME = 300;\n\nfunction defaultFilter(suggestion: string, query: string) {\n  return suggestion.toLowerCase().includes(query.toLowerCase());\n}\n\nexport interface AutocompleteFieldProps extends Omit<React.ComponentProps<typeof Input>, 'value' | 'onChange'> {\n  lens: Lens<string>;\n  label?: string;\n  description?: string;\n  placeholder?: string;\n  suggestions?: string[];\n  onSearch?: (query: string) => Promise<string[]>;\n  emptyText?: string;\n  debounceMs?: number;\n  filter?: (suggestion: string, query: string) => boolean;\n}\n\nexport function AutocompleteField({\n  lens,\n  label,\n  description,\n  placeholder,\n  suggestions,\n  onSearch,\n  emptyText = 'No results',\n  debounceMs = DEBOUNCE_TIME,\n  filter = defaultFilter,\n  ...props\n}: AutocompleteFieldProps) {\n  const { field, fieldState } = useController(lens.interop());\n\n  const [open, setOpen] = React.useState(false);\n  const [selectedIndex, setSelectedIndex] = React.useState(-1);\n  const [results, setResults] = React.useState<string[]>([]);\n  const [isPending, startTransition] = React.useTransition();\n  const debounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);\n  const requestIdRef = React.useRef(0);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n\n  const query = field.value ?? '';\n\n  const items = React.useMemo(() => {\n    if (onSearch) return results;\n    if (!suggestions) return [];\n    if (!query) return suggestions;\n    return suggestions.filter((suggestion) => filter(suggestion, query));\n  }, [onSearch, results, suggestions, query, filter]);\n\n  React.useEffect(() => {\n    if (!onSearch || !open) return;\n\n    if (debounceTimerRef.current) {\n      clearTimeout(debounceTimerRef.current);\n    }\n\n    if (!query) {\n      requestIdRef.current++;\n      setResults([]);\n      return;\n    }\n\n    debounceTimerRef.current = setTimeout(() => {\n      const requestId = ++requestIdRef.current;\n      startTransition(async () => {\n        try {\n          const res = await onSearch(query);\n          if (requestId !== requestIdRef.current) return;\n          setResults(res);\n        } catch {\n          if (requestId !== requestIdRef.current) return;\n          setResults([]);\n        }\n      });\n    }, debounceMs);\n\n    return () => {\n      if (debounceTimerRef.current) {\n        clearTimeout(debounceTimerRef.current);\n      }\n      requestIdRef.current++;\n    };\n  }, [query, onSearch, open, debounceMs]);\n\n  const handleSelect = (value: string) => {\n    field.onChange(value);\n    setOpen(false);\n    setSelectedIndex(-1);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (!open) return;\n\n    if (e.key === 'Escape') {\n      e.preventDefault();\n      setOpen(false);\n      setSelectedIndex(-1);\n      return;\n    }\n\n    if (items.length === 0) return;\n\n    switch (e.key) {\n      case 'ArrowDown':\n        e.preventDefault();\n        setSelectedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n        break;\n      case 'ArrowUp':\n        e.preventDefault();\n        setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n        break;\n      case 'Enter':\n        if (selectedIndex >= 0 && selectedIndex < items.length) {\n          e.preventDefault();\n          handleSelect(items[selectedIndex]);\n        }\n        break;\n    }\n  };\n\n  const handleBlur = (e: React.FocusEvent) => {\n    const relatedTarget = e.relatedTarget as HTMLElement;\n    if (popoverRef.current?.contains(relatedTarget)) {\n      return;\n    }\n    field.onBlur();\n    setTimeout(() => {\n      setOpen(false);\n      setSelectedIndex(-1);\n    }, 150);\n  };\n\n  const id = props.id ?? field.name;\n\n  return (\n    <Field className='gap-2' data-invalid={fieldState.invalid}>\n      {label && <FieldLabel htmlFor={id}>{label}</FieldLabel>}\n      <Popover open={open} onOpenChange={setOpen}>\n        <PopoverAnchor asChild>\n          <div className='relative'>\n            <Input\n              name={field.name}\n              value={query}\n              placeholder={placeholder}\n              autoComplete='off'\n              {...props}\n              id={id}\n              onChange={(e) => {\n                field.onChange(e.target.value);\n                setOpen(true);\n                setSelectedIndex(-1);\n              }}\n              onFocus={() => setOpen(true)}\n              onBlur={handleBlur}\n              onKeyDown={handleKeyDown}\n              aria-invalid={fieldState.invalid}\n            />\n            {isPending && (\n              <div className='absolute inset-y-0 right-0 flex items-center pr-3'>\n                <Loader2 className='size-4 animate-spin text-muted-foreground' />\n              </div>\n            )}\n          </div>\n        </PopoverAnchor>\n        <PopoverContent\n          ref={popoverRef}\n          className='p-0'\n          align='start'\n          onOpenAutoFocus={(e) => e.preventDefault()}\n          style={{ width: 'var(--radix-popover-trigger-width)' }}\n        >\n          <Command shouldFilter={false} className='w-full'>\n            <CommandList className='max-h-60'>\n              <CommandEmpty>{isPending ? 'Searching…' : emptyText}</CommandEmpty>\n              <CommandGroup>\n                {items.map((item, index) => (\n                  <CommandItem\n                    key={`${item}-${index}`}\n                    value={item}\n                    onSelect={() => handleSelect(item)}\n                    className={cn('cursor-pointer', selectedIndex === index && 'bg-accent')}\n                  >\n                    {item}\n                  </CommandItem>\n                ))}\n              </CommandGroup>\n            </CommandList>\n          </Command>\n        </PopoverContent>\n      </Popover>\n      {fieldState.invalid && <FieldError className='text-xs text-left' errors={[fieldState.error]} />}\n      {description && <FieldDescription className='text-xs'>{description}</FieldDescription>}\n    </Field>\n  );\n}\n"
    }
  ],
  "$schema": "https://ui.shadcn.com/schema/registry-item.json"
}
