{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A BasicDropdown component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\nconst ROTATION_ANGLE_OPEN = 180;\nconst DROPDOWN_OFFSET = 4;\n\nexport interface DropdownItem {\n  icon?: React.ReactNode;\n  id: string | number;\n  label: string;\n}\n\nexport interface BasicDropdownProps {\n  className?: string;\n  items: DropdownItem[];\n  label: string;\n  onChange?: (item: DropdownItem) => void;\n}\n\nexport default function BasicDropdown({\n  label,\n  items,\n  onChange,\n  className = \"\",\n}: BasicDropdownProps) {\n  const [isOpen, setIsOpen] = useState(false);\n  const [selectedItem, setSelectedItem] = useState<DropdownItem | null>(null);\n  const [focusedIndex, setFocusedIndex] = useState(-1);\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const portalRef = useRef<HTMLDivElement>(null);\n  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });\n  const shouldReduceMotion = useReducedMotion();\n\n  const handleItemSelect = (item: DropdownItem) => {\n    setSelectedItem(item);\n    setIsOpen(false);\n    onChange?.(item);\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 + DROPDOWN_OFFSET,\n        width: rect.width,\n      });\n    }\n    setIsOpen(!isOpen);\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 + DROPDOWN_OFFSET,\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        setFocusedIndex(-1);\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\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        setFocusedIndex(-1);\n        buttonRef.current?.focus();\n      } else if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        setFocusedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0));\n      } else if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1));\n      } else if (event.key === \"Enter\" && focusedIndex >= 0) {\n        event.preventDefault();\n        const item = items[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(items.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, items, 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 rounded-lg border bg-background shadow-lg\"\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                : { bounce: 0.1, duration: 0.25, type: \"spring\" as const }\n            }\n          >\n            <ul\n              aria-label=\"Dropdown options\"\n              className=\"py-2\"\n              id=\"dropdown-items\"\n            >\n              {items.map((item, index) => (\n                <motion.li\n                  animate={\n                    shouldReduceMotion ? { opacity: 1 } : { 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                      : { opacity: 0, x: -10 }\n                  }\n                  initial={\n                    shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -10 }\n                  }\n                  key={item.id}\n                  role=\"option\"\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : {\n                          damping: 30,\n                          duration: 0.2,\n                          stiffness: 300,\n                          type: \"spring\" as const,\n                        }\n                  }\n                  whileHover={shouldReduceMotion ? {} : { x: 5 }}\n                >\n                  <button\n                    aria-label={item.label}\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-2\">{item.icon}</span>\n                    ) : null}\n                    {item.label}\n\n                    {selectedItem?.id === item.id && (\n                      <motion.span\n                        animate={shouldReduceMotion ? {} : { scale: 1 }}\n                        className=\"ml-auto\"\n                        initial={shouldReduceMotion ? {} : { scale: 0 }}\n                        transition={\n                          shouldReduceMotion\n                            ? { duration: 0 }\n                            : {\n                                damping: 20,\n                                duration: 0.2,\n                                stiffness: 300,\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            </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={{ duration: shouldReduceMotion ? 0 : 0.2 }}\n          >\n            <ChevronDown className=\"h-4 w-4\" />\n          </motion.div>\n        </button>\n      </div>\n      {typeof window === \"undefined\"\n        ? null\n        : createPortal(dropdownContent, document.body)}\n    </>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/basic-dropdown/index.tsx","type":"registry:ui"}],"name":"basic-dropdown","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Basic Dropdown","type":"registry:ui"}