{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select","type":"registry:component","title":"Select","description":"Composable select whose menu fades and scales out of the trigger, and opens upward when there is no room below.","author":"Ryan","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/select.tsx","type":"registry:component","target":"@components/motion/select.tsx","content":"\"use client\";\n// easeui.dev/components/motion/select\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type KeyboardEvent,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\n// The menu opens in 150ms and closes a little faster, scaling a touch from the\n// trigger edge so it stays visually connected to the button.\nconst OPEN = { duration: 0.15, ease: EASE } as const;\nconst CLOSE = { duration: 0.1, ease: EASE } as const;\n\ninterface SelectContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  choose: (value: string) => void;\n  labelOf: (value: string | undefined) => string | undefined;\n  setLabel: (value: string, label: string) => void;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  listRef: RefObject<HTMLDivElement | null>;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n}\n\nconst SelectContext = createContext<SelectContextValue | null>(null);\n\nfunction useSelect(part: string) {\n  const ctx = useContext(SelectContext);\n  if (!ctx) throw new Error(`${part} must be used inside <Select>`);\n  return ctx;\n}\n\nfunction enabledOptions(list: HTMLElement | null) {\n  return Array.from(\n    list?.querySelectorAll<HTMLButtonElement>('[role=\"option\"]:not(:disabled)') ?? [],\n  );\n}\n\nexport interface SelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state when uncontrolled. Default false. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function Select({\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  disabled = false,\n  className,\n  children,\n}: SelectProps) {\n  const id = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const [innerValue, setInnerValue] = useState(defaultValue);\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const [labels, setLabels] = useState<Record<string, string>>({});\n  const current = value ?? innerValue;\n  const open = openProp ?? innerOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (openProp === undefined) setInnerOpen(next);\n      onOpenChange?.(next);\n    },\n    [openProp, onOpenChange],\n  );\n\n  const choose = useCallback(\n    (next: string) => {\n      if (value === undefined) setInnerValue(next);\n      onValueChange?.(next);\n      setOpen(false);\n      triggerRef.current?.focus();\n    },\n    [value, onValueChange, setOpen],\n  );\n\n  const setLabel = useCallback((key: string, label: string) => {\n    setLabels((prev) => (prev[key] === label ? prev : { ...prev, [key]: label }));\n  }, []);\n\n  // A press anywhere outside the select closes the menu.\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open, setOpen]);\n\n  const ctx = useMemo<SelectContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      choose,\n      labelOf: (key) => (key === undefined ? undefined : labels[key]),\n      setLabel,\n      triggerRef,\n      listRef,\n      triggerId: `${id}-trigger`,\n      listId: `${id}-list`,\n      disabled,\n    }),\n    [current, open, setOpen, choose, labels, setLabel, id, disabled],\n  );\n\n  return (\n    <SelectContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </SelectContext.Provider>\n  );\n}\n\nexport function SelectTrigger({ className, children }: { className?: string; children: ReactNode }) {\n  const s = useSelect(\"SelectTrigger\");\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      s.setOpen(true);\n    }\n  };\n\n  return (\n    <button\n      ref={s.triggerRef}\n      type=\"button\"\n      id={s.triggerId}\n      disabled={s.disabled}\n      aria-haspopup=\"listbox\"\n      aria-expanded={s.open}\n      aria-controls={s.listId}\n      onClick={() => s.setOpen(!s.open)}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"flex h-10 w-full touch-manipulation items-center justify-between gap-2 rounded-lg bg-background px-3 text-left text-sm text-foreground outline-none\",\n        \"shadow-[0_0_0_1px_var(--border-strong)] transition-shadow duration-150\",\n        \"focus-visible:shadow-[0_0_0_2px_color-mix(in_oklch,var(--foreground)_40%,transparent)]\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      <ChevronDown\n        aria-hidden=\"true\"\n        className={cn(\n          \"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out\",\n          s.open && \"rotate-180\",\n        )}\n      />\n    </button>\n  );\n}\n\nexport function SelectValue({ placeholder, className }: { placeholder?: string; className?: string }) {\n  const s = useSelect(\"SelectValue\");\n  const label = s.labelOf(s.value);\n  return (\n    <span className={cn(\"truncate\", label ? \"text-foreground\" : \"text-muted-foreground\", className)}>\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport function SelectContent({ className, children }: { className?: string; children: ReactNode }) {\n  const s = useSelect(\"SelectContent\");\n  const reduce = useReducedMotion();\n  const [placement, setPlacement] = useState<\"top\" | \"bottom\">(\"bottom\");\n  const { open, triggerRef, listRef } = s;\n\n  // Open upward when there is not enough room below the trigger.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const trigger = triggerRef.current;\n    const list = listRef.current;\n    if (!trigger || !list) return;\n    const rect = trigger.getBoundingClientRect();\n    const below = window.innerHeight - rect.bottom;\n    setPlacement(below < list.offsetHeight + 16 && rect.top > below ? \"top\" : \"bottom\");\n  }, [open, triggerRef, listRef]);\n\n  // Move focus into the menu, starting from the chosen option.\n  useEffect(() => {\n    if (!open) return;\n    const items = enabledOptions(listRef.current);\n    const start = items.find((item) => item.getAttribute(\"aria-selected\") === \"true\") ?? items[0];\n    start?.focus({ preventScroll: true });\n  }, [open, listRef]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      s.setOpen(false);\n      triggerRef.current?.focus();\n      return;\n    }\n    if (event.key === \"Tab\") {\n      s.setOpen(false);\n      return;\n    }\n    const items = enabledOptions(listRef.current);\n    if (items.length === 0) return;\n    const index = items.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      event.key === \"ArrowDown\"\n        ? index + 1\n        : event.key === \"ArrowUp\"\n          ? index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? items.length - 1\n              : null;\n    if (next === null) return;\n    event.preventDefault();\n    items[(next + items.length) % items.length].focus();\n  };\n\n  const isTop = placement === \"top\";\n\n  // Options stay mounted while closed so the trigger always knows each label.\n  return (\n    <motion.div\n      ref={listRef}\n      id={s.listId}\n      role=\"listbox\"\n      aria-labelledby={s.triggerId}\n      aria-hidden={!open}\n      inert={!open}\n      onKeyDown={onKeyDown}\n      initial={false}\n      animate={\n        reduce\n          ? { opacity: open ? 1 : 0 }\n          : { opacity: open ? 1 : 0, scale: open ? 1 : 0.97 }\n      }\n      transition={reduce ? { duration: 0 } : open ? OPEN : CLOSE}\n      style={{\n        transformOrigin: isTop ? \"bottom\" : \"top\",\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n      className={cn(\n        \"absolute inset-x-0 z-30 flex flex-col gap-0.5 rounded-lg bg-background p-1\",\n        \"shadow-[0_0_0_1px_var(--border-strong),0_12px_24px_-12px_rgb(0_0_0/0.3)]\",\n        isTop ? \"bottom-full mb-1.5\" : \"top-full mt-1.5\",\n        className,\n      )}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\nexport interface SelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function SelectItem({ value, disabled = false, className, children }: SelectItemProps) {\n  const s = useSelect(\"SelectItem\");\n  const selected = s.value === value;\n  const label = typeof children === \"string\" ? children : value;\n  const { setLabel } = s;\n\n  useLayoutEffect(() => {\n    setLabel(value, label);\n  }, [setLabel, value, label]);\n\n  return (\n    <button\n      type=\"button\"\n      role=\"option\"\n      aria-selected={selected}\n      disabled={disabled}\n      tabIndex={-1}\n      onClick={() => s.choose(value)}\n      className={cn(\n        \"flex min-h-9 w-full touch-manipulation items-center justify-between gap-2 rounded-md px-2.5 text-left text-sm outline-none transition-colors duration-150\",\n        selected ? \"text-foreground\" : \"text-muted-foreground\",\n        \"hover:bg-muted hover:text-foreground focus:bg-muted focus:text-foreground\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        className,\n      )}\n    >\n      {children}\n      {selected ? <Check aria-hidden=\"true\" className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n    </button>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}