{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"An animated Select dropdown component for SmoothUI wrapping Radix Select with smooth animations.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  DURATION_INSTANT,\n  SPRING_DEFAULT,\n  SPRING_SNAPPY,\n} from \"@/components/smoothui/lib/animation\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nconst CHEVRON_ROTATION = 180;\nconst DROPDOWN_OFFSET = 4;\nconst STAGGER_DELAY = 0.02;\nconst ITEM_HOVER_X = 2;\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SelectOptionProps {\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 SelectGroupOption {\n  /** Label for the group */\n  label: string;\n  /** Options within this group */\n  options: SelectOptionProps[];\n}\n\nexport interface SelectProps {\n  /** Accessible label for the select */\n  \"aria-label\"?: string;\n  /** ID of element that labels this select */\n  \"aria-labelledby\"?: string;\n  /** Additional CSS class names for the trigger */\n  className?: string;\n  /** Additional CSS class names for the content dropdown */\n  contentClassName?: string;\n  /** The default value (uncontrolled) */\n  defaultValue?: string;\n  /** Whether the select is disabled */\n  disabled?: boolean;\n  /** Grouped options */\n  groups?: SelectGroupOption[];\n  /** The name attribute for form submission */\n  name?: string;\n  /** Callback when the value changes */\n  onValueChange?: (value: string) => void;\n  /** Flat list of options */\n  options?: SelectOptionProps[];\n  /** Placeholder text when no value is selected */\n  placeholder?: string;\n  /** Whether the select is required */\n  required?: boolean;\n  /** The size of the trigger */\n  size?: \"sm\" | \"default\";\n  /** The controlled value of the select */\n  value?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport default function Select({\n  value: controlledValue,\n  defaultValue,\n  onValueChange,\n  placeholder = \"Select an option\",\n  disabled = false,\n  required = false,\n  name,\n  options,\n  groups,\n  className,\n  contentClassName,\n  size = \"default\",\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n}: SelectProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [isOpen, setIsOpen] = useState(false);\n  const [internalValue, setInternalValue] = useState(defaultValue ?? \"\");\n  const [focusedIndex, setFocusedIndex] = useState(-1);\n  const [position, setPosition] = useState({ left: 0, top: 0, width: 0 });\n\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const wrapperRef = useRef<HTMLDivElement>(null);\n  const portalRef = useRef<HTMLDivElement>(null);\n\n  const selectedValue =\n    controlledValue === undefined ? internalValue : controlledValue;\n\n  // Flatten all options for keyboard navigation\n  const allOptions: SelectOptionProps[] = (() => {\n    const flat: SelectOptionProps[] = [];\n    if (options) {\n      for (const opt of options) {\n        flat.push(opt);\n      }\n    }\n    if (groups) {\n      for (const group of groups) {\n        for (const opt of group.options) {\n          flat.push(opt);\n        }\n      }\n    }\n    return flat;\n  })();\n\n  const selectedLabel = allOptions.find(\n    (opt) => opt.value === selectedValue\n  )?.label;\n\n  // ---------------------------------------------------------------------------\n  // Handlers\n  // ---------------------------------------------------------------------------\n\n  const handleSelect = useCallback(\n    (opt: SelectOptionProps) => {\n      if (opt.disabled) {\n        return;\n      }\n      if (controlledValue === undefined) {\n        setInternalValue(opt.value);\n      }\n      onValueChange?.(opt.value);\n      setIsOpen(false);\n      setFocusedIndex(-1);\n      triggerRef.current?.focus();\n    },\n    [controlledValue, onValueChange]\n  );\n\n  const handleToggle = useCallback(() => {\n    if (disabled) {\n      return;\n    }\n    if (!isOpen && triggerRef.current) {\n      const rect = triggerRef.current.getBoundingClientRect();\n      setPosition({\n        left: rect.left,\n        top: rect.bottom + DROPDOWN_OFFSET,\n        width: rect.width,\n      });\n    }\n    setIsOpen((prev) => !prev);\n    setFocusedIndex(-1);\n  }, [disabled, isOpen]);\n\n  // ---------------------------------------------------------------------------\n  // Position updates on scroll/resize\n  // ---------------------------------------------------------------------------\n\n  useEffect(() => {\n    if (!(isOpen && triggerRef.current)) {\n      return;\n    }\n\n    const updatePosition = () => {\n      if (triggerRef.current) {\n        const rect = triggerRef.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    return () => {\n      window.removeEventListener(\"scroll\", updatePosition, true);\n      window.removeEventListener(\"resize\", updatePosition);\n    };\n  }, [isOpen]);\n\n  // ---------------------------------------------------------------------------\n  // Click outside to close\n  // ---------------------------------------------------------------------------\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n\n    const handleClickOutside = (event: MouseEvent) => {\n      const target = event.target as Node;\n      if (\n        wrapperRef.current &&\n        !wrapperRef.current.contains(target) &&\n        portalRef.current &&\n        !portalRef.current.contains(target)\n      ) {\n        setIsOpen(false);\n        setFocusedIndex(-1);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n  }, [isOpen]);\n\n  // ---------------------------------------------------------------------------\n  // Keyboard navigation\n  // ---------------------------------------------------------------------------\n\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (!isOpen) {\n        if (\n          (event.key === \"Enter\" || event.key === \" \") &&\n          document.activeElement === triggerRef.current\n        ) {\n          event.preventDefault();\n          handleToggle();\n        }\n        return;\n      }\n\n      if (event.key === \"Escape\") {\n        setIsOpen(false);\n        setFocusedIndex(-1);\n        triggerRef.current?.focus();\n      } else if (event.key === \"ArrowDown\") {\n        event.preventDefault();\n        setFocusedIndex((prev) =>\n          prev < allOptions.length - 1 ? prev + 1 : 0\n        );\n      } else if (event.key === \"ArrowUp\") {\n        event.preventDefault();\n        setFocusedIndex((prev) =>\n          prev > 0 ? prev - 1 : allOptions.length - 1\n        );\n      } else if (event.key === \"Enter\" && focusedIndex >= 0) {\n        event.preventDefault();\n        const opt = allOptions[focusedIndex];\n        if (opt) {\n          handleSelect(opt);\n        }\n      } else if (event.key === \"Home\") {\n        event.preventDefault();\n        setFocusedIndex(0);\n      } else if (event.key === \"End\") {\n        event.preventDefault();\n        setFocusedIndex(allOptions.length - 1);\n      }\n    };\n\n    document.addEventListener(\"keydown\", handleKeyDown);\n    return () => document.removeEventListener(\"keydown\", handleKeyDown);\n    // biome-ignore lint/correctness/useExhaustiveDependencies: handlers stable via closure\n  }, [isOpen, allOptions, focusedIndex, handleSelect, handleToggle]);\n\n  // ---------------------------------------------------------------------------\n  // Render helpers\n  // ---------------------------------------------------------------------------\n\n  /** Render a single option item with stagger animation */\n  const renderItem = (opt: SelectOptionProps, itemIndex: number) => {\n    const isSelected = opt.value === selectedValue;\n    const isFocused = itemIndex === focusedIndex;\n\n    return (\n      <motion.div\n        animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}\n        exit={\n          shouldReduceMotion\n            ? { opacity: 0, transition: { duration: 0 } }\n            : { opacity: 0, x: -8 }\n        }\n        initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, x: -8 }}\n        key={opt.value}\n        transition={\n          shouldReduceMotion\n            ? DURATION_INSTANT\n            : {\n                ...SPRING_SNAPPY,\n                delay: itemIndex * STAGGER_DELAY,\n              }\n        }\n        whileHover={shouldReduceMotion ? {} : { x: ITEM_HOVER_X }}\n      >\n        <button\n          aria-selected={isSelected}\n          className={cn(\n            \"relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-left text-sm outline-hidden\",\n            \"transition-colors\",\n            opt.disabled\n              ? \"pointer-events-none opacity-50\"\n              : \"hover:bg-accent hover:text-white\",\n            isFocused && \"bg-accent text-white\",\n            isSelected && \"font-medium\"\n          )}\n          disabled={opt.disabled}\n          onClick={() => handleSelect(opt)}\n          onMouseEnter={() => setFocusedIndex(itemIndex)}\n          role=\"option\"\n          type=\"button\"\n        >\n          <span className=\"flex-1 truncate\">{opt.label}</span>\n\n          {/* Animated checkmark */}\n          <span className=\"absolute right-2 flex size-3.5 items-center justify-center\">\n            <AnimatePresence>\n              {isSelected && (\n                <motion.span\n                  animate={shouldReduceMotion ? {} : { opacity: 1, scale: 1 }}\n                  exit={\n                    shouldReduceMotion\n                      ? { opacity: 0, transition: { duration: 0 } }\n                      : { opacity: 0, scale: 0 }\n                  }\n                  initial={shouldReduceMotion ? {} : { opacity: 0, scale: 0 }}\n                  transition={\n                    shouldReduceMotion\n                      ? DURATION_INSTANT\n                      : {\n                          damping: 20,\n                          duration: 0.2,\n                          stiffness: 300,\n                          type: \"spring\" as const,\n                        }\n                  }\n                >\n                  <Check className=\"size-4\" />\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        </button>\n      </motion.div>\n    );\n  };\n\n  // Global index counter for stagger across groups\n  let globalIndex = 0;\n\n  // ---------------------------------------------------------------------------\n  // Dropdown content (portalled)\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, scale: 1, y: 0 }\n            }\n            className={cn(\n              \"fixed z-50 origin-top overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md\",\n              contentClassName\n            )}\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : {\n                    opacity: 0,\n                    scale: 0.95,\n                    transition: { duration: 0.15 },\n                    y: -4,\n                  }\n            }\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { opacity: 0, scale: 0.95, y: -4 }\n            }\n            role=\"listbox\"\n            style={{\n              left: `${position.left}px`,\n              top: `${position.top}px`,\n              width: `${position.width}px`,\n            }}\n            transition={shouldReduceMotion ? DURATION_INSTANT : SPRING_DEFAULT}\n          >\n            <div className=\"max-h-60 overflow-y-auto p-1\">\n              {/* Flat options */}\n              {options &&\n                options.length > 0 &&\n                (() => {\n                  const items = options.map((opt) => {\n                    const idx = globalIndex;\n                    globalIndex += 1;\n                    return renderItem(opt, idx);\n                  });\n                  return items;\n                })()}\n\n              {/* Grouped options */}\n              {groups\n                ? groups.map((group, groupIdx) => {\n                    const groupItems = group.options.map((opt) => {\n                      const idx = globalIndex;\n                      globalIndex += 1;\n                      return renderItem(opt, idx);\n                    });\n\n                    return (\n                      <div key={group.label}>\n                        {groupIdx > 0 && (\n                          <div className=\"pointer-events-none -mx-1 my-1 h-px bg-border\" />\n                        )}\n                        <div className=\"px-2 py-1.5 text-muted-foreground text-xs\">\n                          {group.label}\n                        </div>\n                        {groupItems}\n                      </div>\n                    );\n                  })\n                : null}\n            </div>\n          </motion.div>\n        </div>\n      ) : null}\n    </AnimatePresence>\n  );\n\n  // ---------------------------------------------------------------------------\n  // Main render\n  // ---------------------------------------------------------------------------\n\n  return (\n    <>\n      <div className=\"relative inline-block w-full\" ref={wrapperRef}>\n        {/* Hidden native input for form submission */}\n        {name ? (\n          <input\n            aria-hidden=\"true\"\n            name={name}\n            required={required}\n            tabIndex={-1}\n            type=\"hidden\"\n            value={selectedValue}\n          />\n        ) : null}\n\n        <button\n          aria-expanded={isOpen}\n          aria-haspopup=\"listbox\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledBy}\n          aria-required={required || undefined}\n          className={cn(\n            \"flex w-full cursor-pointer items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground\",\n            size === \"default\" ? \"h-9\" : \"h-8\",\n            className\n          )}\n          data-placeholder={!selectedLabel || undefined}\n          disabled={disabled}\n          onClick={handleToggle}\n          ref={triggerRef}\n          role=\"combobox\"\n          type=\"button\"\n        >\n          <span\n            className={cn(\n              \"line-clamp-1 flex items-center gap-2 text-left\",\n              !selectedLabel && \"text-muted-foreground\"\n            )}\n          >\n            {selectedLabel ?? placeholder}\n          </span>\n\n          {/* Animated chevron */}\n          <motion.div\n            animate={{ rotate: isOpen ? CHEVRON_ROTATION : 0 }}\n            className=\"shrink-0\"\n            transition={\n              shouldReduceMotion\n                ? DURATION_INSTANT\n                : { bounce: 0.05, duration: 0.25, type: \"spring\" as const }\n            }\n          >\n            <ChevronDown className=\"size-4 opacity-50\" />\n          </motion.div>\n        </button>\n      </div>\n\n      {typeof window === \"undefined\"\n        ? null\n        : createPortal(dropdownContent, document.body)}\n    </>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/select/index.tsx","type":"registry:ui"}],"name":"select","registryDependencies":["https://smoothui.dev/r/lib.json"],"title":"Select","type":"registry:ui"}