{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "autocomplete",
  "title": "Autocomplete",
  "author": "Poyraz Avsever",
  "description": "Poyraz Soft Glass autocomplete with semantic states and composable variants.",
  "dependencies": [
    "lucide-react@^0.574.0"
  ],
  "registryDependencies": [
    "@poyraz/poyraz-utils",
    "@poyraz/poyraz-theme",
    "@poyraz/poyraz-recipes"
  ],
  "files": [
    {
      "path": "registry/poyraz/ui/phase7/molecules/autocomplete.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Check, ChevronDown, X, Search } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  fieldVariants,\n  floatingItemVariants,\n  floatingSurfaceVariants,\n  type FloatingItemProps,\n  type FloatingSurfaceProps,\n} from \"@/components/ui/recipes\";\n\n/* ================================================================== */\n/*  AUTOCOMPLETE — Searchable combobox / typeahead                     */\n/* ================================================================== */\n\nexport interface AutocompleteOption {\n  /** Unique value */\n  value: string;\n  /** Display label */\n  label: string;\n  /** Whether this option is disabled */\n  disabled?: boolean;\n  /** Optional group name */\n  group?: string;\n  description?: string;\n  media?: React.ReactNode;\n}\n\nexport interface AutocompleteProps {\n  /** Available options */\n  options: AutocompleteOption[];\n  /** Currently selected value(s) */\n  value?: string | string[];\n  defaultValue?: string | string[];\n  /** Called when selection changes */\n  onValueChange?: (value: string | string[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Placeholder for the input */\n  placeholder?: string;\n  /** Allow multiple selections */\n  multiple?: boolean;\n  /** Allow free-form text (not just from the list) */\n  freeSolo?: boolean;\n  /** Custom filter function */\n  filterFn?: (option: AutocompleteOption, query: string) => boolean;\n  /** Called when the search query changes */\n  onSearchChange?: (query: string) => void;\n  /** Show a loading spinner */\n  loading?: boolean;\n  state?: \"ready\" | \"loading\" | \"error\";\n  loadingText?: React.ReactNode;\n  errorText?: React.ReactNode;\n  /** Disabled state */\n  disabled?: boolean;\n  /** Empty state text */\n  emptyText?: string;\n  emptyContent?: React.ReactNode;\n  /** Additional class for the wrapper */\n  className?: string;\n  variant?: \"default\" | \"soft\" | \"glass\";\n  radius?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n  size?: \"sm\" | \"md\" | \"lg\";\n  dropdownSurface?: NonNullable<FloatingSurfaceProps[\"surface\"]>;\n  dropdownRadius?: NonNullable<FloatingSurfaceProps[\"radius\"]>;\n  itemSize?: NonNullable<FloatingItemProps[\"size\"]>;\n}\n\nfunction defaultFilter(option: AutocompleteOption, query: string) {\n  return option.label.toLowerCase().includes(query.toLowerCase());\n}\n\nfunction Autocomplete({\n  options,\n  value,\n  defaultValue,\n  onValueChange,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  placeholder = \"Search…\",\n  multiple = false,\n  freeSolo = false,\n  filterFn = defaultFilter,\n  onSearchChange,\n  loading = false,\n  state = \"ready\",\n  loadingText = \"Searching…\",\n  errorText = \"Unable to load options.\",\n  disabled = false,\n  emptyText = \"No results found.\",\n  emptyContent,\n  className,\n  variant,\n  radius,\n  size = \"md\",\n  dropdownSurface,\n  dropdownRadius,\n  itemSize = \"md\",\n}: AutocompleteProps) {\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const [internalValue, setInternalValue] = React.useState<string | string[] | undefined>(\n    defaultValue,\n  );\n  const [query, setQuery] = React.useState(\"\");\n  const [highlightIndex, setHighlightIndex] = React.useState(-1);\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const listboxId = React.useId();\n  const open = controlledOpen ?? internalOpen;\n  const valueControlled = value !== undefined;\n  const resolvedValue = valueControlled ? value : internalValue;\n  const resolvedState = loading || state === \"loading\" ? \"loading\" : state;\n\n  const setOpen = React.useCallback(\n    (next: boolean) => {\n      if (controlledOpen === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange],\n  );\n\n  // Normalize value to array for internal use\n  const selectedValues = React.useMemo(() => {\n    if (!resolvedValue) return [];\n    return Array.isArray(resolvedValue) ? resolvedValue : [resolvedValue];\n  }, [resolvedValue]);\n\n  // Filtered options\n  const filtered = React.useMemo(() => {\n    if (!query) return options;\n    return options.filter((o) => filterFn(o, query));\n  }, [options, query, filterFn]);\n\n  // Group options\n  const grouped = React.useMemo(() => {\n    const groups = new Map<string, AutocompleteOption[]>();\n    for (const opt of filtered) {\n      const key = opt.group ?? \"\";\n      if (!groups.has(key)) groups.set(key, []);\n      groups.get(key)!.push(opt);\n    }\n    return groups;\n  }, [filtered]);\n\n  // Flat list for keyboard nav\n  const flatFiltered = React.useMemo(() => {\n    const arr: AutocompleteOption[] = [];\n    for (const opts of grouped.values()) arr.push(...opts);\n    return arr;\n  }, [grouped]);\n\n  // Close on outside click\n  React.useEffect(() => {\n    function handleClickOutside(e: MouseEvent) {\n      if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {\n        setOpen(false);\n        // If freeSolo is off, clear the query when closing without selection\n        if (!freeSolo) setQuery(\"\");\n      }\n    }\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n  }, [freeSolo, setOpen]);\n\n  // Reset highlight when filtered list changes\n  React.useEffect(() => {\n    setHighlightIndex(-1);\n  }, [query]);\n\n  // Scroll highlighted item into view\n  React.useEffect(() => {\n    if (highlightIndex >= 0 && listRef.current) {\n      const items = listRef.current.querySelectorAll(\"[data-autocomplete-item]\");\n      items[highlightIndex]?.scrollIntoView({ block: \"nearest\" });\n    }\n  }, [highlightIndex]);\n\n  const handleSelect = (optionValue: string) => {\n    if (multiple) {\n      const next = selectedValues.includes(optionValue)\n        ? selectedValues.filter((v) => v !== optionValue)\n        : [...selectedValues, optionValue];\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    } else {\n      if (!valueControlled) setInternalValue(optionValue);\n      onValueChange?.(optionValue);\n      const label = options.find((o) => o.value === optionValue)?.label ?? optionValue;\n      setQuery(label);\n      setOpen(false);\n    }\n  };\n\n  const handleRemove = (val: string) => {\n    if (multiple) {\n      const next = selectedValues.filter((v) => v !== val);\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    }\n  };\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const q = e.target.value;\n    setQuery(q);\n    onSearchChange?.(q);\n    if (!open) setOpen(true);\n  };\n\n  const findEnabled = (start: number, direction: 1 | -1) => {\n    if (!flatFiltered.length) return -1;\n    let index = start;\n    for (let count = 0; count < flatFiltered.length; count += 1) {\n      index = (index + direction + flatFiltered.length) % flatFiltered.length;\n      if (!flatFiltered[index]?.disabled) return index;\n    }\n    return -1;\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault();\n      setHighlightIndex((i) => findEnabled(i, 1));\n      if (!open) setOpen(true);\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setHighlightIndex((i) => findEnabled(i < 0 ? 0 : i, -1));\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      setHighlightIndex(findEnabled(-1, 1));\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      setHighlightIndex(findEnabled(0, -1));\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (highlightIndex >= 0 && flatFiltered[highlightIndex]) {\n        const opt = flatFiltered[highlightIndex];\n        if (!opt.disabled) handleSelect(opt.value);\n      } else if (freeSolo && query) {\n        const next = multiple ? [...selectedValues, query] : query;\n        if (!valueControlled) setInternalValue(next);\n        onValueChange?.(next);\n        if (!multiple) setOpen(false);\n      }\n    } else if (e.key === \"Escape\") {\n      setOpen(false);\n    } else if (e.key === \"Tab\") {\n      setOpen(false);\n    } else if (e.key === \"Backspace\" && !query && multiple && selectedValues.length > 0) {\n      // Remove last tag\n      const next = selectedValues.slice(0, -1);\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    }\n  };\n\n  // When not multiple, sync query with selected label\n  React.useEffect(() => {\n    if (!multiple && selectedValues.length > 0 && !open) {\n      const label = options.find((o) => o.value === selectedValues[0])?.label;\n      if (label) setQuery(label);\n    }\n  }, [selectedValues, multiple, open, options]);\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-slot=\"autocomplete\"\n      data-state={resolvedState}\n      className={cn(\"relative w-full\", className)}\n    >\n      {/* Trigger */}\n      <div\n        className={cn(\n          fieldVariants({ variant, radius }),\n          \"flex flex-wrap items-center gap-1.5 min-h-[36px] w-full\",\n          \"px-3\",\n          size === \"sm\" && \"min-h-8 py-1\",\n          size === \"md\" && \"min-h-9 py-1.5\",\n          size === \"lg\" && \"min-h-11 py-2\",\n          \"transition-[color,background-color,border-color] duration-[var(--poyraz-motion-duration-base)] ease-[var(--poyraz-motion-ease-out)]\",\n          open && \"border-primary ring-2 ring-ring ring-offset-2\",\n          disabled && \"opacity-40 cursor-not-allowed\",\n        )}\n        onClick={() => {\n          if (!disabled) {\n            inputRef.current?.focus();\n            setOpen(true);\n          }\n        }}\n      >\n        {/* Tags (multiple mode) */}\n        {multiple &&\n          selectedValues.map((val) => {\n            const label = options.find((o) => o.value === val)?.label ?? val;\n            return (\n              <span\n                key={val}\n                className=\"inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide border border-border-strong bg-muted text-secondary-foreground animate-poyraz-scale-in\"\n              >\n                {label}\n                <button\n                  type=\"button\"\n                  className=\"ml-0.5 hover:text-primary transition-[color,transform] duration-[var(--poyraz-motion-duration-fast)] ease-[var(--poyraz-motion-ease-out)] hover:scale-110 active:scale-95 cursor-pointer\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    handleRemove(val);\n                  }}\n                  aria-label={`Remove ${label}`}\n                >\n                  <X className=\"h-3 w-3\" />\n                </button>\n              </span>\n            );\n          })}\n\n        <div className=\"flex flex-1 items-center gap-2 min-w-[80px]\">\n          <Search className=\"h-4 w-4 shrink-0 text-placeholder\" />\n          <input\n            ref={inputRef}\n            type=\"text\"\n            value={query}\n            onChange={handleInputChange}\n            onFocus={() => setOpen(true)}\n            onKeyDown={handleKeyDown}\n            placeholder={multiple && selectedValues.length > 0 ? \"\" : placeholder}\n            disabled={disabled}\n            className={cn(\n              \"flex-1 bg-transparent text-sm text-foreground placeholder:text-placeholder\",\n              \"outline-none border-none p-0\",\n              \"disabled:cursor-not-allowed\",\n            )}\n            role=\"combobox\"\n            aria-expanded={open}\n            aria-haspopup=\"listbox\"\n            aria-autocomplete=\"list\"\n            aria-controls={open ? listboxId : undefined}\n            aria-activedescendant={\n              highlightIndex >= 0 ? `${listboxId}-option-${highlightIndex}` : undefined\n            }\n            autoComplete=\"off\"\n          />\n        </div>\n\n        <ChevronDown\n          className={cn(\n            \"h-4 w-4 shrink-0 text-placeholder transition-transform duration-200 ease-out\",\n            open && \"rotate-180\",\n          )}\n        />\n      </div>\n\n      {/* Dropdown */}\n      {open && (\n        <div\n          ref={listRef}\n          id={listboxId}\n          role=\"listbox\"\n          className={cn(\n            floatingSurfaceVariants({ surface: dropdownSurface, radius: dropdownRadius }),\n            \"absolute z-50 mt-1 w-full max-h-[240px] overflow-y-auto\",\n            \"origin-top animate-in fade-in-0 slide-in-from-top-2 [--poyraz-enter-scale:0.98] duration-[var(--poyraz-motion-duration-base)] ease-[var(--poyraz-motion-ease-out)] motion-reduce:[--poyraz-enter-scale:1] motion-reduce:[--poyraz-enter-translate-y:0]\",\n          )}\n        >\n          {resolvedState === \"loading\" && (\n            <div\n              role=\"status\"\n              aria-live=\"polite\"\n              className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground animate-poyraz-fade-in\"\n            >\n              <div className=\"size-4 rounded-full border-2 border-primary/25 border-t-primary animate-poyraz-spin\" />\n              {loadingText}\n            </div>\n          )}\n\n          {resolvedState === \"error\" && (\n            <div\n              role=\"alert\"\n              className=\"px-3 py-6 text-center text-sm text-destructive-muted-foreground animate-poyraz-fade-in\"\n            >\n              {errorText}\n            </div>\n          )}\n\n          {resolvedState === \"ready\" && flatFiltered.length === 0 && (\n            <div className=\"px-3 py-6 text-center text-sm text-placeholder animate-poyraz-fade-in\">\n              {emptyContent ?? emptyText}\n            </div>\n          )}\n\n          {resolvedState === \"ready\" &&\n            Array.from(grouped.entries()).map(([group, opts]) => (\n              <div key={group || \"__ungrouped\"} role=\"group\" className=\"animate-poyraz-fade-in\">\n                {group && (\n                  <div className=\"px-3 py-1.5 text-[11px] font-bold uppercase tracking-widest text-placeholder border-b border-accent\">\n                    {group}\n                  </div>\n                )}\n                {opts.map((opt) => {\n                  const isSelected = selectedValues.includes(opt.value);\n                  const flatIdx = flatFiltered.indexOf(opt);\n                  const isHighlighted = flatIdx === highlightIndex;\n                  return (\n                    <div\n                      key={opt.value}\n                      id={`${listboxId}-option-${flatIdx}`}\n                      data-autocomplete-item\n                      role=\"option\"\n                      aria-selected={isSelected}\n                      aria-disabled={opt.disabled}\n                      className={cn(\n                        floatingItemVariants({ size: itemSize, radius: \"md\" }),\n                        \"cursor-pointer border border-transparent\",\n                        isHighlighted && \"bg-muted border-border translate-x-0.5\",\n                        !isHighlighted && \"hover:bg-muted hover:border-border\",\n                        opt.disabled && \"pointer-events-none opacity-40\",\n                      )}\n                      onClick={() => {\n                        if (!opt.disabled) handleSelect(opt.value);\n                      }}\n                      onMouseEnter={() => setHighlightIndex(flatIdx)}\n                    >\n                      {opt.media && (\n                        <span className=\"flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-md bg-surface-subtle [&_img]:size-full [&_img]:object-cover\">\n                          {opt.media}\n                        </span>\n                      )}\n                      <span className=\"min-w-0 flex-1\">\n                        <span className=\"block truncate font-medium\">{opt.label}</span>\n                        {opt.description && (\n                          <span className=\"mt-0.5 block truncate text-xs text-muted-foreground\">\n                            {opt.description}\n                          </span>\n                        )}\n                      </span>\n                      {isSelected && (\n                        <Check className=\"h-4 w-4 shrink-0 text-primary animate-poyraz-scale-in\" />\n                      )}\n                    </div>\n                  );\n                })}\n              </div>\n            ))}\n        </div>\n      )}\n    </div>\n  );\n}\nAutocomplete.displayName = \"Autocomplete\";\n\nexport { Autocomplete };\n",
      "type": "registry:ui",
      "target": "@ui/molecules/autocomplete.tsx"
    }
  ],
  "meta": {
    "category": "phase-7-composite",
    "phase": "beta"
  },
  "type": "registry:ui"
}