{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A SearchableDropdown component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { ChevronDown, Search, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\nconst ROTATION_ANGLE_OPEN = 180;\n\nexport interface SearchableDropdownItem {\n  description?: string;\n  icon?: React.ReactNode;\n  id: string | number;\n  label: string;\n}\n\nexport interface SearchableDropdownProps {\n  className?: string;\n  emptyMessage?: string;\n  items: SearchableDropdownItem[];\n  label: string;\n  onChange?: (item: SearchableDropdownItem) => void;\n  placeholder?: string;\n}\n\nexport default function SearchableDropdown({\n  label,\n  items,\n  onChange,\n  placeholder = \"Search...\",\n  emptyMessage = \"No results found\",\n  className = \"\",\n}: SearchableDropdownProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [selectedItem, setSelectedItem] =\n    useState<SearchableDropdownItem | null>(null);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const portalRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });\n  const shouldReduceMotion = useReducedMotion();\n\n  const filteredItems = useMemo(() => {\n    const trimmedQuery = searchQuery.trim();\n    if (!trimmedQuery) {\n      return items;\n    }\n\n    // Cache lowercase query to avoid repeated calls\n    const query = trimmedQuery.toLowerCase();\n    const itemsLength = items.length;\n    const results: typeof items = [];\n\n    // Early exit optimization: use for loop instead of filter for better performance\n    for (let i = 0; i < itemsLength; i++) {\n      const item = items[i];\n      const itemLabel = item.label.toLowerCase();\n      const description = item.description?.toLowerCase();\n\n      if (itemLabel.includes(query) || description?.includes(query)) {\n        results.push(item);\n      }\n    }\n\n    return results;\n  }, [items, searchQuery]);\n\n  const handleItemSelect = (item: SearchableDropdownItem) => {\n    setSelectedItem(item);\n    setIsOpen(false);\n    setSearchQuery(\"\");\n    onChange?.(item);\n  };\n\n  const handleClearSearch = () => {\n    setSearchQuery(\"\");\n    inputRef.current?.focus();\n  };\n\n  const handleToggle = () => {\n    if (!isOpen && buttonRef.current) {\n      const rect = buttonRef.current.getBoundingClientRect();\n      setPosition({\n        left: rect.left,\n        top: rect.bottom + 4,\n        width: rect.width,\n      });\n    }\n    setIsOpen(!isOpen);\n    if (isOpen) {\n      setSearchQuery(\"\");\n    } else {\n      setTimeout(() => inputRef.current?.focus(), 100);\n    }\n  };\n\n  // Update position on scroll/resize when open\n  useEffect(() => {\n    if (!(isOpen && buttonRef.current)) {\n      return;\n    }\n\n    const updatePosition = () => {\n      if (buttonRef.current) {\n        const rect = buttonRef.current.getBoundingClientRect();\n        setPosition({\n          left: rect.left,\n          top: rect.bottom + 4,\n          width: rect.width,\n        });\n      }\n    };\n\n    window.addEventListener(\"scroll\", updatePosition, true);\n    window.addEventListener(\"resize\", updatePosition);\n\n    return () => {\n      window.removeEventListener(\"scroll\", updatePosition, true);\n      window.removeEventListener(\"resize\", updatePosition);\n    };\n  }, [isOpen]);\n\n  // Close dropdown when clicking outside\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (\n        isOpen &&\n        dropdownRef.current &&\n        !dropdownRef.current.contains(target) &&\n        portalRef.current &&\n        !portalRef.current.contains(target)\n      ) {\n        setIsOpen(false);\n        setSearchQuery(\"\");\n      }\n    };\n\n    if (isOpen) {\n      document.addEventListener(\"mousedown\", handleClickOutside);\n    }\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, [isOpen]);\n\n  // Keyboard navigation with arrow keys, enter, and escape\n  const [focusedIndex, setFocusedIndex] = useState(-1);\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (!isOpen) {\n        // Open dropdown on Enter or Space when button is focused\n        if (\n          (event.key === \"Enter\" || event.key === \" \") &&\n          document.activeElement === buttonRef.current\n        ) {\n          event.preventDefault();\n          handleToggle();\n        }\n        return;\n      }\n\n      if (event.key === \"Escape\") {\n        setIsOpen(false);\n        setSearchQuery(\"\");\n        setFocusedIndex(-1);\n        buttonRef.current?.focus();\n      } else if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        setFocusedIndex((prev) =>\n          prev < filteredItems.length - 1 ? prev + 1 : 0\n        );\n      } else if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setFocusedIndex((prev) =>\n          prev > 0 ? prev - 1 : filteredItems.length - 1\n        );\n      } else if (event.key === \"Enter\" && focusedIndex >= 0) {\n        event.preventDefault();\n        const item = filteredItems[focusedIndex];\n        if (item) {\n          handleItemSelect(item);\n        }\n      } else if (event.key === \"Home\") {\n        event.preventDefault();\n        setFocusedIndex(0);\n      } else if (event.key === \"End\") {\n        event.preventDefault();\n        setFocusedIndex(filteredItems.length - 1);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n    // biome-ignore lint/correctness/useExhaustiveDependencies: Handlers are stable via closure\n  }, [isOpen, filteredItems, focusedIndex, handleItemSelect, handleToggle]);\n\n  // Reset focused index when items change\n  useEffect(() => {\n    setFocusedIndex(-1);\n  }, []);\n\n  const dropdownContent = (\n    <AnimatePresence>\n      {isOpen ? (\n        <div ref={portalRef}>\n          <motion.div\n            animate={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 1, scaleY: 1, y: 0 }\n            }\n            className=\"fixed z-50 origin-top overflow-hidden rounded-lg border bg-background/95 shadow-lg backdrop-blur-md\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : {\n                    opacity: 0,\n                    scaleY: 0.8,\n                    transition: { duration: 0.15 },\n                    y: -10,\n                  }\n            }\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 0, scaleY: 0.8, y: -10 }\n            }\n            style={{\n              left: `${position.left}px`,\n              top: `${position.top}px`,\n              width: `${position.width}px`,\n            }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    damping: 30,\n                    duration: 0.25,\n                    mass: 0.8,\n                    stiffness: 400,\n                    type: \"spring\" as const,\n                  }\n            }\n          >\n            {/* Search Input */}\n            <div className=\"relative border-b p-2\">\n              <motion.div\n                animate={\n                  shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }\n                }\n                className=\"relative\"\n                initial={\n                  shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -10 }\n                }\n                transition={\n                  shouldReduceMotion\n                    ? { duration: 0 }\n                    : {\n                        damping: 25,\n                        delay: 0.05,\n                        duration: 0.2,\n                        stiffness: 400,\n                        type: \"spring\" as const,\n                      }\n                }\n              >\n                <Search className=\"absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground\" />\n                <input\n                  aria-autocomplete=\"list\"\n                  aria-controls=\"dropdown-items\"\n                  aria-expanded={isOpen}\n                  aria-label=\"Search dropdown items\"\n                  className=\"w-full rounded-md border bg-transparent py-2 pr-8 pl-9 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n                  onChange={(e) => {\n                    setSearchQuery(e.target.value);\n                    setFocusedIndex(-1);\n                  }}\n                  placeholder={placeholder}\n                  ref={inputRef}\n                  role=\"combobox\"\n                  type=\"text\"\n                  value={searchQuery}\n                />\n                <AnimatePresence>\n                  {searchQuery ? (\n                    <motion.button\n                      animate={{ opacity: 1 }}\n                      aria-label=\"Clear search\"\n                      className=\"absolute top-1/2 right-2 min-h-[44px] min-w-[44px] -translate-y-1/2 rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n                      exit={{ opacity: 0 }}\n                      initial={{ opacity: 0 }}\n                      onClick={handleClearSearch}\n                      transition={{\n                        damping: 25,\n                        stiffness: 400,\n                        type: \"spring\" as const,\n                      }}\n                      type=\"button\"\n                    >\n                      <X aria-hidden=\"true\" className=\"h-4 w-4\" />\n                    </motion.button>\n                  ) : null}\n                </AnimatePresence>\n              </motion.div>\n            </div>\n\n            {/* Items List */}\n            <ul\n              aria-label=\"Dropdown options\"\n              className=\"max-h-60 overflow-y-auto py-2\"\n              id=\"dropdown-items\"\n            >\n              <AnimatePresence mode=\"popLayout\">\n                {filteredItems.length > 0 ? (\n                  filteredItems.map((item, index) => (\n                    <motion.li\n                      animate={\n                        shouldReduceMotion\n                          ? { opacity: 1 }\n                          : { filter: \"blur(0px)\", opacity: 1, x: 0 }\n                      }\n                      aria-selected={\n                        selectedItem?.id === item.id || index === focusedIndex\n                      }\n                      className=\"block\"\n                      exit={\n                        shouldReduceMotion\n                          ? { opacity: 0, transition: { duration: 0 } }\n                          : { filter: \"blur(4px)\", opacity: 0, x: -10 }\n                      }\n                      initial={\n                        shouldReduceMotion\n                          ? { opacity: 1 }\n                          : { filter: \"blur(4px)\", opacity: 0, x: -10 }\n                      }\n                      key={item.id}\n                      layout\n                      role=\"option\"\n                      transition={\n                        shouldReduceMotion\n                          ? { duration: 0 }\n                          : {\n                              damping: 28,\n                              delay: index * 0.02,\n                              duration: 0.2,\n                              mass: 0.6,\n                              stiffness: 400,\n                              type: \"spring\" as const,\n                            }\n                      }\n                    >\n                      <button\n                        aria-label={`${item.label}${item.description ? `, ${item.description}` : \"\"}`}\n                        className={`flex min-h-[44px] w-full items-center px-4 py-2 text-left text-sm transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${\n                          selectedItem?.id === item.id\n                            ? \"font-medium text-brand\"\n                            : \"\"\n                        } ${index === focusedIndex ? \"bg-muted\" : \"\"}`}\n                        onClick={() => handleItemSelect(item)}\n                        onMouseEnter={() => setFocusedIndex(index)}\n                        type=\"button\"\n                      >\n                        {item.icon ? (\n                          <span className=\"mr-3 shrink-0\">{item.icon}</span>\n                        ) : null}\n                        <div className=\"min-w-0 flex-1\">\n                          <span className=\"block truncate\">{item.label}</span>\n                          {item.description ? (\n                            <span className=\"block truncate text-muted-foreground text-xs\">\n                              {item.description}\n                            </span>\n                          ) : null}\n                        </div>\n\n                        {selectedItem?.id === item.id && (\n                          <motion.span\n                            animate={shouldReduceMotion ? {} : { scale: 1 }}\n                            className=\"ml-2 shrink-0\"\n                            initial={shouldReduceMotion ? {} : { scale: 0 }}\n                            transition={\n                              shouldReduceMotion\n                                ? { duration: 0 }\n                                : {\n                                    damping: 25,\n                                    duration: 0.2,\n                                    mass: 0.5,\n                                    stiffness: 400,\n                                    type: \"spring\" as const,\n                                  }\n                            }\n                          >\n                            <svg\n                              className=\"h-4 w-4 text-brand\"\n                              fill=\"none\"\n                              stroke=\"currentColor\"\n                              viewBox=\"0 0 24 24\"\n                            >\n                              <title>Selected</title>\n                              <path\n                                d=\"M5 13l4 4L19 7\"\n                                strokeLinecap=\"round\"\n                                strokeLinejoin=\"round\"\n                                strokeWidth={2}\n                              />\n                            </svg>\n                          </motion.span>\n                        )}\n                      </button>\n                    </motion.li>\n                  ))\n                ) : (\n                  <motion.li\n                    animate={{ opacity: 1 }}\n                    className=\"px-4 py-8 text-center text-muted-foreground text-sm\"\n                    initial={\n                      shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }\n                    }\n                    transition={\n                      shouldReduceMotion\n                        ? { duration: 0 }\n                        : {\n                            damping: 25,\n                            duration: 0.2,\n                            stiffness: 400,\n                            type: \"spring\" as const,\n                          }\n                    }\n                  >\n                    {emptyMessage}\n                  </motion.li>\n                )}\n              </AnimatePresence>\n            </ul>\n          </motion.div>\n        </div>\n      ) : null}\n    </AnimatePresence>\n  );\n\n  return (\n    <>\n      <div className={`relative inline-block ${className}`} ref={dropdownRef}>\n        <button\n          aria-expanded={isOpen}\n          aria-haspopup=\"listbox\"\n          aria-label={selectedItem ? `${label}: ${selectedItem.label}` : label}\n          className=\"flex min-h-[44px] w-full cursor-pointer items-center justify-between gap-2 rounded-lg border bg-background px-4 py-2 text-left transition-colors hover:bg-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n          id=\"dropdown-button\"\n          onClick={handleToggle}\n          ref={buttonRef}\n          type=\"button\"\n        >\n          <span className=\"block truncate\">\n            {String(selectedItem ? selectedItem.label : label)}\n          </span>\n          <motion.div\n            animate={{ rotate: isOpen ? ROTATION_ANGLE_OPEN : 0 }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : {\n                    damping: 25,\n                    duration: 0.2,\n                    stiffness: 400,\n                    type: \"spring\" as const,\n                  }\n            }\n          >\n            <ChevronDown className=\"h-4 w-4\" />\n          </motion.div>\n        </button>\n      </div>\n      {typeof window !== \"undefined\" &&\n        createPortal(dropdownContent, document.body)}\n    </>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/searchable-dropdown/index.tsx","type":"registry:ui"}],"name":"searchable-dropdown","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Searchable Dropdown","type":"registry:ui"}