{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"An animated Combobox component for SmoothUI with text filtering, async search, and keyboard navigation.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport {\n  Command,\n  CommandEmpty,\n  CommandGroup,\n  CommandInput,\n  CommandItem,\n  CommandList,\n} from \"@/components/ui/command\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport { CheckIcon, ChevronsUpDownIcon, LoaderIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { DURATION_INSTANT, SPRING_DEFAULT } from \"@/components/smoothui/lib/animation\";\nimport SmoothButton from \"../smooth-button\";\n\nexport interface ComboboxOption {\n  /** Whether the option is disabled */\n  disabled?: boolean;\n  /** The display label for the option */\n  label: string;\n  /** The value of the option */\n  value: string;\n}\n\nexport interface ComboboxProps {\n  /** Accessible label for the combobox */\n  \"aria-label\"?: string;\n  /** ID of element that labels this combobox */\n  \"aria-labelledby\"?: string;\n  /** Additional CSS class names for the trigger button */\n  className?: string;\n  /** Additional CSS class names for the popover content */\n  contentClassName?: string;\n  /** Whether the combobox is disabled */\n  disabled?: boolean;\n  /** Text shown when no results match */\n  emptyText?: string;\n  /** Async search callback — receives the query string, returns filtered options */\n  onSearch?: (query: string) => Promise<ComboboxOption[]>;\n  /** Callback when the selected value changes */\n  onValueChange?: (value: string) => void;\n  /** Static list of options (used when onSearch is not provided) */\n  options?: ComboboxOption[];\n  /** Placeholder text for the trigger button */\n  placeholder?: string;\n  /** Debounce delay in ms for the onSearch callback */\n  searchDebounce?: number;\n  /** Placeholder text for the search input */\n  searchPlaceholder?: string;\n  /** The controlled selected value */\n  value?: string;\n}\n\nconst MotionCommandItem = motion.create(CommandItem);\n\nexport default function Combobox({\n  value,\n  onValueChange,\n  options: staticOptions,\n  onSearch,\n  searchDebounce = 300,\n  placeholder = \"Select an option…\",\n  searchPlaceholder = \"Search…\",\n  emptyText = \"No results found.\",\n  disabled = false,\n  className,\n  contentClassName,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n}: ComboboxProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [asyncOptions, setAsyncOptions] = useState<ComboboxOption[]>([]);\n  const [loading, setLoading] = useState(false);\n  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const displayOptions = onSearch ? asyncOptions : (staticOptions ?? []);\n\n  const selectedLabel = displayOptions.find(\n    (opt) => opt.value === value\n  )?.label;\n\n  const handleSearch = useCallback(\n    (searchQuery: string) => {\n      setQuery(searchQuery);\n\n      if (!onSearch) {\n        return;\n      }\n\n      if (debounceRef.current) {\n        clearTimeout(debounceRef.current);\n      }\n\n      debounceRef.current = setTimeout(async () => {\n        setLoading(true);\n        try {\n          const results = await onSearch(searchQuery);\n          setAsyncOptions(results);\n        } finally {\n          setLoading(false);\n        }\n      }, searchDebounce);\n    },\n    [onSearch, searchDebounce]\n  );\n\n  // Load initial async options when popover opens\n  useEffect(() => {\n    if (open && onSearch && asyncOptions.length === 0 && !loading) {\n      setLoading(true);\n      onSearch(\"\").then((results) => {\n        setAsyncOptions(results);\n        setLoading(false);\n      });\n    }\n  }, [open, onSearch, asyncOptions.length, loading]);\n\n  // Clean up debounce on unmount\n  useEffect(\n    () => () => {\n      if (debounceRef.current) {\n        clearTimeout(debounceRef.current);\n      }\n    },\n    []\n  );\n\n  const handleSelect = (selectedValue: string) => {\n    const newValue = selectedValue === value ? \"\" : selectedValue;\n    onValueChange?.(newValue);\n    setOpen(false);\n  };\n\n  const itemTransition = shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT;\n\n  return (\n    <Popover onOpenChange={setOpen} open={open}>\n      <PopoverTrigger asChild>\n        <SmoothButton\n          aria-expanded={open}\n          aria-haspopup=\"listbox\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          className={cn(\n            \"h-9 w-full justify-between px-3 py-2 text-left font-normal [&:hover_*]:text-white\",\n            !selectedLabel && \"text-muted-foreground\",\n            shouldReduceMotion && \"!transition-none !duration-0\",\n            className\n          )}\n          disabled={disabled}\n          role=\"combobox\"\n          type=\"button\"\n          variant=\"outline\"\n        >\n          <span\n            className={cn(\n              \"truncate transition-colors\",\n              !selectedLabel && \"text-muted-foreground\"\n            )}\n          >\n            {selectedLabel ?? placeholder}\n          </span>\n          <ChevronsUpDownIcon className=\"ml-2 size-4 shrink-0 opacity-50 transition-colors\" />\n        </SmoothButton>\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        className={cn(\n          \"w-[var(--radix-popover-trigger-width)] p-0\",\n          shouldReduceMotion && \"!animate-none !transition-none !duration-0\",\n          contentClassName\n        )}\n      >\n        <Command shouldFilter={!onSearch}>\n          <CommandInput\n            className=\"\"\n            onValueChange={handleSearch}\n            placeholder={searchPlaceholder}\n            value={query}\n          />\n          <CommandList>\n            <AnimatePresence>\n              {loading ? (\n                <motion.div\n                  animate={{ opacity: 1 }}\n                  className=\"flex items-center justify-center py-4\"\n                  exit={\n                    shouldReduceMotion\n                      ? { opacity: 0, transition: DURATION_INSTANT }\n                      : { opacity: 0 }\n                  }\n                  initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n                  transition={itemTransition}\n                >\n                  <LoaderIcon className=\"size-4 animate-spin text-muted-foreground\" />\n                  <span className=\"ml-2 text-muted-foreground text-sm\">\n                    Loading…\n                  </span>\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n\n            {!loading && <CommandEmpty>{emptyText}</CommandEmpty>}\n\n            {!loading && displayOptions.length > 0 && (\n              <CommandGroup>\n                {displayOptions.map((option, index) => (\n                  <MotionCommandItem\n                    animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n                    disabled={option.disabled}\n                    initial={\n                      shouldReduceMotion\n                        ? { opacity: 1 }\n                        : { opacity: 0, transform: \"translateY(4px)\" }\n                    }\n                    key={option.value}\n                    keywords={[option.label]}\n                    onSelect={handleSelect}\n                    transition={\n                      shouldReduceMotion\n                        ? DURATION_INSTANT\n                        : {\n                            ...SPRING_DEFAULT,\n                            delay: index * 0.02,\n                          }\n                    }\n                    value={option.value}\n                  >\n                    <CheckIcon\n                      className={cn(\n                        \"mr-2 size-4 shrink-0\",\n                        value === option.value ? \"opacity-100\" : \"opacity-0\"\n                      )}\n                    />\n                    {option.label}\n                  </MotionCommandItem>\n                ))}\n              </CommandGroup>\n            )}\n          </CommandList>\n        </Command>\n      </PopoverContent>\n    </Popover>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/combobox/index.tsx","type":"registry:ui"}],"name":"combobox","registryDependencies":["command","popover","https://smoothui.dev/r/smooth-button.json","https://smoothui.dev/r/lib.json"],"title":"Combobox","type":"registry:ui"}