{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select-morph","type":"registry:component","title":"Select Morph Select","description":"Composable primitives (MorphSelect, MorphSelectTrigger, MorphSelectValue, MorphSelectContent, MorphSelectItem) where the trigger morphs into the panel via a shared layoutId — one continuous surface that grows open and shrinks back, never detaching.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/select-morph.tsx","type":"registry:component","target":"@components/motion/select-morph.tsx","content":"\"use client\";\n// beui.dev/components/motion/select\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\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\n// Shared-layout morph: trigger box grows into the panel and back, one surface.\nconst MORPH: Transition = { type: \"spring\", duration: 0.5, bounce: 0.22 };\n// Trigger and panel header share this row so the morph stays seamless.\nconst ROW = \"flex w-full items-center justify-between gap-2 px-3.5 py-2.5 text-sm\";\n\nconst LIST: Variants = {\n  hidden: {},\n  show: { transition: { staggerChildren: 0.035, delayChildren: 0.08 } },\n};\nconst ITEM: Variants = {\n  hidden: { opacity: 0, y: -6, filter: \"blur(3px)\" },\n  show: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n};\n\ninterface MorphContextValue {\n  value: string | undefined;\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  select: (value: string) => void;\n  register: (value: string, label: string) => void;\n  unregister: (value: string) => void;\n  labelFor: (value: string | undefined) => string | undefined;\n  placeholder: string;\n  setPlaceholder: (p: string) => void;\n  reduce: boolean;\n  layoutId: string;\n  triggerId: string;\n  listId: string;\n  disabled: boolean;\n}\n\nconst MorphContext = createContext<MorphContextValue | null>(null);\n\nfunction useMorphContext(component: string) {\n  const ctx = useContext(MorphContext);\n  if (!ctx) throw new Error(`${component} must be used within <MorphSelect>`);\n  return ctx;\n}\n\nexport interface MorphSelectProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\n/**\n * Select whose trigger morphs into the panel via a shared layoutId — instead of\n * a separate dropdown opening, the trigger itself grows into the menu and\n * shrinks back, never detaching. Composable like `Select` (the gooey variant).\n */\nexport function MorphSelect({\n  value,\n  defaultValue,\n  onValueChange,\n  disabled = false,\n  className,\n  children,\n}: MorphSelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [open, setOpen] = useState(false);\n  const [internal, setInternal] = useState(defaultValue);\n  // ref-counted: items render twice (hidden registrar + open panel), so a\n  // label is only dropped once every copy with that value has unmounted.\n  const [labels, setLabels] = useState<\n    Map<string, { label: string; count: number }>\n  >(new Map());\n  const [placeholder, setPlaceholder] = useState(\"Select\");\n\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n\n  const select = useCallback(\n    (next: string) => {\n      if (!controlled) setInternal(next);\n      onValueChange?.(next);\n      setOpen(false);\n    },\n    [controlled, onValueChange],\n  );\n\n  const register = useCallback((v: string, label: string) => {\n    setLabels((m) => {\n      const next = new Map(m);\n      next.set(v, { label, count: (m.get(v)?.count ?? 0) + 1 });\n      return next;\n    });\n  }, []);\n  const unregister = useCallback((v: string) => {\n    setLabels((m) => {\n      const entry = m.get(v);\n      if (!entry) return m;\n      const next = new Map(m);\n      if (entry.count <= 1) next.delete(v);\n      else next.set(v, { label: entry.label, count: entry.count - 1 });\n      return next;\n    });\n  }, []);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setOpen(false);\n    const onPointer = (e: PointerEvent) => {\n      if (rootRef.current && !rootRef.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      value: current,\n      open,\n      setOpen,\n      select,\n      register,\n      unregister,\n      labelFor: (v) => (v === undefined ? undefined : labels.get(v)?.label),\n      placeholder,\n      setPlaceholder,\n      reduce,\n      layoutId: `${baseId}-surface`,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      disabled,\n    }),\n    [\n      current,\n      open,\n      select,\n      register,\n      unregister,\n      labels,\n      placeholder,\n      reduce,\n      baseId,\n      disabled,\n    ],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={rootRef} className={cn(\"relative\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphSelectValueProps {\n  placeholder?: string;\n  className?: string;\n}\n\nexport function MorphSelectValue({\n  placeholder,\n  className,\n}: MorphSelectValueProps) {\n  const ctx = useMorphContext(\"MorphSelectValue\");\n  // surface the placeholder so the morph header (rendered by content) matches\n  useEffect(() => {\n    if (placeholder) ctx.setPlaceholder(placeholder);\n  }, [placeholder, ctx.setPlaceholder]);\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <span\n      className={cn(label ? \"text-foreground\" : \"text-muted-foreground\", className)}\n    >\n      {label ?? placeholder ?? \"Select\"}\n    </span>\n  );\n}\n\nexport interface MorphSelectTriggerProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function MorphSelectTrigger({\n  className,\n  children,\n}: MorphSelectTriggerProps) {\n  const ctx = useMorphContext(\"MorphSelectTrigger\");\n  return (\n    <>\n      {/* invisible sizer reserves the closed height (the morph surface is\n          absolute, so this keeps surrounding layout from shifting) */}\n      <div\n        aria-hidden\n        inert\n        className={cn(ROW, \"invisible rounded-xl border border-border\")}\n      >\n        {children}\n        <ChevronDown className=\"h-4 w-4\" />\n      </div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {!ctx.open ? (\n          <motion.button\n            key=\"trigger\"\n            layoutId={ctx.layoutId}\n            type=\"button\"\n            id={ctx.triggerId}\n            disabled={ctx.disabled}\n            aria-haspopup=\"listbox\"\n            aria-expanded={ctx.open}\n            aria-controls={ctx.listId}\n            onClick={() => ctx.setOpen(true)}\n            transition={ctx.reduce ? { duration: 0 } : MORPH}\n            style={{ borderRadius: 12 }}\n            className={cn(\n              ROW,\n              \"absolute inset-x-0 top-0 z-10 border border-border bg-background text-foreground outline-none transition-colors\",\n              \"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20\",\n              \"disabled:pointer-events-none disabled:opacity-50\",\n              className,\n            )}\n          >\n            <motion.span layout=\"position\" className=\"min-w-0 truncate\">\n              {children}\n            </motion.span>\n            <motion.span layout=\"position\" className=\"text-muted-foreground\">\n              <ChevronDown className=\"h-4 w-4\" />\n            </motion.span>\n          </motion.button>\n        ) : null}\n      </AnimatePresence>\n    </>\n  );\n}\n\nexport interface MorphSelectContentProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport function MorphSelectContent({\n  className,\n  children,\n}: MorphSelectContentProps) {\n  const ctx = useMorphContext(\"MorphSelectContent\");\n  const label = ctx.labelFor(ctx.value);\n  return (\n    <>\n      {/* always-mounted, hidden — keeps item label registrations alive while\n          closed so the trigger shows the selected value before first open */}\n      <div className=\"hidden\">{children}</div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {ctx.open ? (\n          <motion.div\n            key=\"panel\"\n            layoutId={ctx.layoutId}\n            id={ctx.listId}\n            role=\"listbox\"\n            aria-labelledby={ctx.triggerId}\n            transition={ctx.reduce ? { duration: 0 } : MORPH}\n            style={{ borderRadius: 12 }}\n            className={cn(\n              \"absolute inset-x-0 top-0 z-30 overflow-hidden border border-border bg-background shadow-lg\",\n              className,\n            )}\n          >\n            {/* header mirrors the trigger (continuous morph) and collapses the\n                panel back into the trigger when clicked */}\n            <motion.button\n              type=\"button\"\n              layout=\"position\"\n              aria-expanded\n              onClick={() => ctx.setOpen(false)}\n              className={cn(ROW, \"outline-none\")}\n            >\n              <span\n                className={cn(\n                  \"min-w-0 truncate\",\n                  label ? \"text-foreground\" : \"text-muted-foreground\",\n                )}\n              >\n                {label ?? ctx.placeholder}\n              </span>\n              <motion.span\n                animate={{ rotate: 180 }}\n                transition={ctx.reduce ? { duration: 0 } : MORPH}\n                className=\"text-muted-foreground\"\n              >\n                <ChevronDown className=\"h-4 w-4\" />\n              </motion.span>\n            </motion.button>\n\n            <div className=\"h-px bg-border\" />\n\n            <motion.ul\n              initial=\"hidden\"\n              animate=\"show\"\n              variants={ctx.reduce ? undefined : LIST}\n              className=\"p-1\"\n            >\n              {children}\n            </motion.ul>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </>\n  );\n}\n\nexport interface MorphSelectItemProps {\n  value: string;\n  disabled?: boolean;\n  className?: string;\n  children: ReactNode;\n}\n\nexport function MorphSelectItem({\n  value,\n  disabled = false,\n  className,\n  children,\n}: MorphSelectItemProps) {\n  const ctx = useMorphContext(\"MorphSelectItem\");\n  const selected = ctx.value === value;\n  const label = typeof children === \"string\" ? children : value;\n\n  useLayoutEffect(() => {\n    ctx.register(value, label);\n    return () => ctx.unregister(value);\n  }, [ctx.register, ctx.unregister, value, label]);\n\n  return (\n    <motion.li variants={ctx.reduce ? undefined : ITEM}>\n      <button\n        type=\"button\"\n        role=\"option\"\n        aria-selected={selected}\n        disabled={disabled}\n        onClick={() => ctx.select(value)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors\",\n          selected\n            ? \"bg-muted text-foreground\"\n            : \"text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:bg-muted\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          className,\n        )}\n      >\n        {children}\n        {selected ? <Check className=\"h-3.5 w-3.5 shrink-0\" /> : null}\n      </button>\n    </motion.li>\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"}]}