{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"multi-select","type":"registry:component","title":"Multi Select","description":"Composable multi-select primitives with searchable options, removable animated tokens, and a morphing collision-aware panel.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/multi-select/index.tsx","type":"registry:component","target":"@components/motion/multi-select/index.tsx","content":"\"use client\";\n// beui.dev/components/motion/multi-select\n\nexport {\n  MultiSelect,\n  type MultiSelectFilter,\n  type MultiSelectProps,\n} from \"./context\";\nexport {\n  MultiSelectInput,\n  type MultiSelectInputProps,\n  MultiSelectTrigger,\n  type MultiSelectTriggerProps,\n  MultiSelectValue,\n  type MultiSelectValueProps,\n} from \"./trigger\";\nexport {\n  MultiSelectContent,\n  type MultiSelectContentProps,\n} from \"./content\";\nexport {\n  MultiSelectEmpty,\n  type MultiSelectEmptyProps,\n  MultiSelectGroup,\n  type MultiSelectGroupProps,\n  MultiSelectItem,\n  type MultiSelectItemProps,\n  MultiSelectLabel,\n  type MultiSelectLabelProps,\n  MultiSelectList,\n  type MultiSelectListProps,\n  MultiSelectSeparator,\n  type MultiSelectSeparatorProps,\n} from \"./list\";\n"},{"path":"components/motion/multi-select/context.tsx","type":"registry:component","target":"@components/motion/multi-select/context.tsx","content":"\"use client\";\n// beui.dev/components/motion/multi-select\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  createContext,\n  type MutableRefObject,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { useActiveOption } from \"@/components/motion/combobox/use-active-option\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RegisteredMultiSelectItem = {\n  value: string;\n  label: string;\n  keywords: string[];\n  disabled: boolean;\n  groupId: string | null;\n  id: string;\n  ref: MutableRefObject<HTMLButtonElement | null>;\n};\n\nexport type MultiSelectFilter = (\n  value: string,\n  query: string,\n  keywords: string[],\n) => boolean;\n\nconst defaultFilter: MultiSelectFilter = (value, query, keywords) => {\n  const needle = query.trim().toLocaleLowerCase();\n  if (!needle) return true;\n\n  const haystack = [value, ...keywords].join(\" \").toLocaleLowerCase();\n  let queryIndex = 0;\n  for (const character of haystack) {\n    if (character === needle[queryIndex]) queryIndex += 1;\n    if (queryIndex === needle.length) return true;\n  }\n  return false;\n};\n\nexport type MultiSelectContextValue = {\n  open: boolean;\n  setOpen: (open: boolean, restoreFocus?: boolean) => void;\n  values: string[];\n  toggle: (value: string) => void;\n  remove: (value: string) => void;\n  query: string;\n  setQuery: (query: string) => void;\n  activeValue: string | null;\n  setActiveValue: (value: string | null) => void;\n  moveActive: (direction: 1 | -1 | \"first\" | \"last\") => void;\n  toggleActive: () => void;\n  registerItem: (item: RegisteredMultiSelectItem) => void;\n  unregisterItem: (value: string) => void;\n  labelFor: (value: string) => string;\n  isVisible: (value: string) => boolean;\n  hasVisibleItems: (groupId: string) => boolean;\n  visibleCount: number;\n  activeItemId: string | undefined;\n  triggerId: string;\n  listId: string;\n  inputId: string;\n  disabled: boolean;\n  reduce: boolean;\n  triggerRef: MutableRefObject<HTMLDivElement | null>;\n  contentRef: MutableRefObject<HTMLDivElement | null>;\n  inputRef: MutableRefObject<HTMLInputElement | null>;\n  activeLayoutId: string;\n};\n\nexport const MultiSelectContext =\n  createContext<MultiSelectContextValue | null>(null);\nexport const MultiSelectGroupContext = createContext<string | null>(null);\n\nexport function useMultiSelectContext(component: string) {\n  const context = useContext(MultiSelectContext);\n  if (!context) {\n    throw new Error(`${component} must be used within <MultiSelect>`);\n  }\n  return context;\n}\n\nexport function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\") {\n        (ref as MutableRefObject<T | null>).current = node;\n      }\n    }\n  };\n}\n\nexport interface MultiSelectProps {\n  children: ReactNode;\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  query?: string;\n  defaultQuery?: string;\n  onQueryChange?: (query: string) => void;\n  filter?: MultiSelectFilter;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport function MultiSelect({\n  children,\n  value: controlledValue,\n  defaultValue = [],\n  onValueChange,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  query: controlledQuery,\n  defaultQuery = \"\",\n  onQueryChange,\n  filter = defaultFilter,\n  disabled = false,\n  className,\n}: MultiSelectProps) {\n  const reduce = useReducedMotion() ?? false;\n  const baseId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const [internalQuery, setInternalQuery] = useState(defaultQuery);\n  const [items, setItems] = useState<Map<string, RegisteredMultiSelectItem>>(\n    new Map(),\n  );\n\n  const valueControlled = controlledValue !== undefined;\n  const openControlled = controlledOpen !== undefined;\n  const queryControlled = controlledQuery !== undefined;\n  const values = controlledValue ?? internalValue;\n  const open = controlledOpen ?? internalOpen;\n  const query = controlledQuery ?? internalQuery;\n\n  const updateQuery = useCallback(\n    (next: string) => {\n      if (!queryControlled) setInternalQuery(next);\n      onQueryChange?.(next);\n    },\n    [onQueryChange, queryControlled],\n  );\n\n  const updateOpen = useCallback(\n    (next: boolean, restoreFocus = false) => {\n      if (disabled && next) return;\n      if (!openControlled) setInternalOpen(next);\n      onOpenChange?.(next);\n      if (!next) updateQuery(\"\");\n      if (restoreFocus) {\n        requestAnimationFrame(() =>\n          inputRef.current?.focus({ preventScroll: true }),\n        );\n      }\n    },\n    [disabled, onOpenChange, openControlled, updateQuery],\n  );\n\n  const registerItem = useCallback((item: RegisteredMultiSelectItem) => {\n    setItems((current) => {\n      const existing = current.get(item.value);\n      if (\n        existing?.label === item.label &&\n        existing.disabled === item.disabled &&\n        existing.id === item.id &&\n        existing.ref === item.ref &&\n        existing.groupId === item.groupId &&\n        existing.keywords.join(\"\\u0000\") === item.keywords.join(\"\\u0000\")\n      ) {\n        return current;\n      }\n      const next = new Map(current);\n      next.set(item.value, item);\n      return next;\n    });\n  }, []);\n\n  const unregisterItem = useCallback((itemValue: string) => {\n    setItems((current) => {\n      if (!current.has(itemValue)) return current;\n      const next = new Map(current);\n      next.delete(itemValue);\n      return next;\n    });\n  }, []);\n\n  const [openQuery, setOpenQuery] = useState(query);\n  if (open && openQuery !== query) setOpenQuery(query);\n  const listQuery = open ? query : openQuery;\n\n  const visibleItems = useMemo(\n    () =>\n      Array.from(items.values()).filter((item) =>\n        filter(item.value, listQuery, [item.label, ...item.keywords]),\n      ),\n    [filter, items, listQuery],\n  );\n  const enabledVisibleItems = useMemo(\n    () => visibleItems.filter((item) => !item.disabled),\n    [visibleItems],\n  );\n  const visibleValues = useMemo(\n    () => new Set(visibleItems.map((item) => item.value)),\n    [visibleItems],\n  );\n  const visibleGroupIds = useMemo(\n    () => new Set(visibleItems.map((item) => item.groupId)),\n    [visibleItems],\n  );\n\n  const { activeValue, setActiveValue, moveActive } = useActiveOption({\n    open,\n    query: listQuery,\n    value: values[0],\n    enabledItems: enabledVisibleItems,\n  });\n\n  const commitValue = useCallback(\n    (next: string[]) => {\n      if (!valueControlled) setInternalValue(next);\n      onValueChange?.(next);\n    },\n    [onValueChange, valueControlled],\n  );\n\n  const toggle = useCallback(\n    (next: string) => {\n      if (items.get(next)?.disabled) return;\n      commitValue(\n        values.includes(next)\n          ? values.filter((value) => value !== next)\n          : [...values, next],\n      );\n      updateQuery(\"\");\n      requestAnimationFrame(() =>\n        inputRef.current?.focus({ preventScroll: true }),\n      );\n    },\n    [commitValue, items, updateQuery, values],\n  );\n\n  const remove = useCallback(\n    (itemValue: string) => {\n      if (!values.includes(itemValue)) return;\n      commitValue(values.filter((value) => value !== itemValue));\n    },\n    [commitValue, values],\n  );\n\n  const toggleActive = useCallback(() => {\n    if (activeValue) toggle(activeValue);\n  }, [activeValue, toggle]);\n\n  useEffect(() => {\n    if (!open) return;\n    const frame = requestAnimationFrame(() =>\n      inputRef.current?.focus({ preventScroll: true }),\n    );\n    return () => cancelAnimationFrame(frame);\n  }, [open]);\n\n  useEffect(() => {\n    if (!activeValue || !open) return;\n    const item = items.get(activeValue)?.ref.current;\n    const list = item?.closest<HTMLElement>(\"[role='listbox']\");\n    if (!item || !list) return;\n    const itemRect = item.getBoundingClientRect();\n    const listRect = list.getBoundingClientRect();\n    if (itemRect.top < listRect.top) list.scrollTop -= listRect.top - itemRect.top;\n    else if (itemRect.bottom > listRect.bottom) {\n      list.scrollTop += itemRect.bottom - listRect.bottom;\n    }\n  }, [activeValue, items, open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const isInside = (target: Node) =>\n      rootRef.current?.contains(target) || contentRef.current?.contains(target);\n    const onPointerDown = (event: PointerEvent) => {\n      if (!isInside(event.target as Node)) updateOpen(false);\n    };\n    const onFocusIn = (event: FocusEvent) => {\n      if (!isInside(event.target as Node)) updateOpen(false);\n    };\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return;\n      event.preventDefault();\n      updateOpen(false, true);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    window.addEventListener(\"focusin\", onFocusIn);\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      window.removeEventListener(\"pointerdown\", onPointerDown);\n      window.removeEventListener(\"focusin\", onFocusIn);\n      window.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [open, updateOpen]);\n\n  const activeItem = activeValue ? items.get(activeValue) : undefined;\n  const context = useMemo<MultiSelectContextValue>(\n    () => ({\n      open,\n      setOpen: updateOpen,\n      values,\n      toggle,\n      remove,\n      query,\n      setQuery: updateQuery,\n      activeValue,\n      setActiveValue,\n      moveActive,\n      toggleActive,\n      registerItem,\n      unregisterItem,\n      labelFor: (itemValue) => items.get(itemValue)?.label ?? itemValue,\n      isVisible: (itemValue) => !listQuery.trim() || visibleValues.has(itemValue),\n      hasVisibleItems: (groupId) => visibleGroupIds.has(groupId),\n      visibleCount: visibleItems.length,\n      activeItemId: activeItem?.id,\n      triggerId: `${baseId}-trigger`,\n      listId: `${baseId}-list`,\n      inputId: `${baseId}-input`,\n      disabled,\n      reduce,\n      triggerRef,\n      contentRef,\n      inputRef,\n      activeLayoutId: `${baseId}-active`,\n    }),\n    [\n      activeItem?.id,\n      activeValue,\n      baseId,\n      disabled,\n      items,\n      listQuery,\n      moveActive,\n      open,\n      query,\n      reduce,\n      registerItem,\n      remove,\n      setActiveValue,\n      toggle,\n      toggleActive,\n      unregisterItem,\n      updateOpen,\n      updateQuery,\n      values,\n      visibleGroupIds,\n      visibleItems.length,\n      visibleValues,\n    ],\n  );\n\n  return (\n    <MultiSelectContext.Provider value={context}>\n      <div ref={rootRef} className={cn(\"relative w-full\", className)}>\n        {children}\n      </div>\n    </MultiSelectContext.Provider>\n  );\n}\n"},{"path":"components/motion/multi-select/trigger.tsx","type":"registry:component","target":"@components/motion/multi-select/trigger.tsx","content":"\"use client\";\n// beui.dev/components/motion/multi-select\n\nimport { ChevronsUpDown, Search, X } from \"lucide-react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport type {\n  InputHTMLAttributes,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n  Ref,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs, useMultiSelectContext } from \"./context\";\n\nexport interface MultiSelectTriggerProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function MultiSelectTrigger({\n  children,\n  className,\n}: MultiSelectTriggerProps) {\n  const context = useMultiSelectContext(\"MultiSelectTrigger\");\n\n  return (\n    <div\n      ref={context.triggerRef}\n      id={context.triggerId}\n      data-state={context.open ? \"open\" : \"closed\"}\n      onPointerDown={(event) => {\n        const target = event.target as HTMLElement;\n        if (\n          context.disabled ||\n          target === context.inputRef.current ||\n          target.closest(\"[data-multi-select-remove]\")\n        ) {\n          return;\n        }\n        event.preventDefault();\n        context.inputRef.current?.focus({ preventScroll: true });\n        context.setOpen(true);\n      }}\n      className={cn(\n        \"relative z-20 flex min-h-11 w-full min-w-52 cursor-text items-center gap-2 rounded-xl border border-border bg-transparent px-2.5 py-1.5 text-sm text-foreground transition-[border-color] hover:border-(--color-border-strong)\",\n        \"focus-within:ring-2 focus-within:ring-foreground/20\",\n        context.disabled && \"pointer-events-none opacity-50\",\n        className,\n      )}\n    >\n      <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-1.5\">\n        {children}\n      </div>\n      <ChevronsUpDown\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0 text-muted-foreground\"\n      />\n    </div>\n  );\n}\n\nexport interface MultiSelectValueProps {\n  placeholder?: ReactNode;\n  children?: (value: string, label: string) => ReactNode;\n  className?: string;\n  chipClassName?: string;\n}\n\nexport function MultiSelectValue({\n  placeholder = \"Select options\",\n  children,\n  className,\n  chipClassName,\n}: MultiSelectValueProps) {\n  const context = useMultiSelectContext(\"MultiSelectValue\");\n  const showPlaceholder = context.values.length === 0 && !context.open;\n\n  return (\n    <div className={cn(\"contents\", className)}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {showPlaceholder ? (\n          <span key=\"multi-select-placeholder\" className=\"text-muted-foreground\">\n            {placeholder}\n          </span>\n        ) : null}\n        {context.values.map((value) => {\n          const label = context.labelFor(value);\n          return (\n            <motion.span\n              layout={context.reduce ? false : \"position\"}\n              key={`multi-select-value-${value}`}\n              initial={{\n                opacity: 0,\n                clipPath: \"inset(0 0 0 0% round 0.5rem)\",\n                transform: context.reduce\n                  ? \"translateY(0px) scale(1)\"\n                  : \"translateY(6px) scale(0.92)\",\n              }}\n              animate={{\n                opacity: 1,\n                clipPath: \"inset(0 0 0 0% round 0.5rem)\",\n                transform: \"translateY(0px) scale(1)\",\n              }}\n              exit={{\n                opacity: 1,\n                clipPath: context.reduce\n                  ? \"inset(0 0 0 0% round 0.5rem)\"\n                  : \"inset(0 0 0 100% round 0.5rem)\",\n                transform: \"translateY(0px) scale(1)\",\n                transition: {\n                  clipPath: context.reduce\n                    ? { duration: 0 }\n                    : { duration: 0.16, ease: EASE_OUT },\n                  transform: { duration: 0 },\n                },\n              }}\n              transition={\n                context.reduce\n                  ? {\n                      layout: { duration: 0 },\n                      opacity: { duration: 0.15, ease: EASE_OUT },\n                      transform: { duration: 0 },\n                    }\n                  : {\n                      layout: SPRING_LAYOUT,\n                      opacity: { duration: 0.18, ease: EASE_OUT },\n                      transform: SPRING_SWAP,\n                    }\n              }\n              className={cn(\n                \"inline-flex h-7 max-w-full items-center gap-1 rounded-lg bg-muted px-2 text-xs font-medium text-foreground\",\n                chipClassName,\n              )}\n            >\n              <span className=\"truncate\">\n                {children ? children(value, label) : label}\n              </span>\n              <button\n                type=\"button\"\n                data-multi-select-remove=\"\"\n                aria-label={`Remove ${label}`}\n                disabled={context.disabled}\n                onPointerDown={(event) => event.stopPropagation()}\n                onClick={(event) => {\n                  event.stopPropagation();\n                  context.remove(value);\n                  context.inputRef.current?.focus({ preventScroll: true });\n                }}\n                className=\"-mr-1 grid size-5 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n              >\n                <X aria-hidden=\"true\" className=\"size-3\" />\n              </button>\n            </motion.span>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport interface MultiSelectInputProps\n  extends Omit<\n    InputHTMLAttributes<HTMLInputElement>,\n    \"defaultValue\" | \"value\"\n  > {\n  ref?: Ref<HTMLInputElement>;\n  showIcon?: boolean;\n}\n\nexport function MultiSelectInput({\n  ref,\n  className,\n  \"aria-label\": ariaLabel = \"Search options\",\n  onChange,\n  onClick,\n  onFocus,\n  onKeyDown,\n  onPointerDown,\n  placeholder = \"Search…\",\n  showIcon = false,\n  ...props\n}: MultiSelectInputProps) {\n  const context = useMultiSelectContext(\"MultiSelectInput\");\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n\n    if (event.key === \"Backspace\" && !context.query && context.values.length) {\n      event.preventDefault();\n      context.remove(context.values.at(-1) ?? \"\");\n    } else if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!context.open) {\n        context.setOpen(true);\n        return;\n      }\n      context.moveActive(event.key === \"ArrowDown\" ? 1 : -1);\n    } else if (event.key === \"Home\" && context.open) {\n      event.preventDefault();\n      context.moveActive(\"first\");\n    } else if (event.key === \"End\" && context.open) {\n      event.preventDefault();\n      context.moveActive(\"last\");\n    } else if (event.key === \"Enter\") {\n      event.preventDefault();\n      if (context.open) context.toggleActive();\n      else context.setOpen(true);\n    } else if (event.key === \"Escape\" && context.open) {\n      event.preventDefault();\n      context.setOpen(false, true);\n    }\n  };\n\n  return (\n    <div className=\"flex min-w-20 flex-1 items-center gap-1.5\">\n      {showIcon ? (\n        <Search aria-hidden=\"true\" className=\"size-3.5 text-muted-foreground\" />\n      ) : null}\n      <input\n        {...props}\n        ref={mergeRefs(ref, context.inputRef)}\n        id={context.inputId}\n        role=\"combobox\"\n        aria-label={ariaLabel}\n        aria-autocomplete=\"list\"\n        aria-expanded={context.open}\n        aria-controls={context.listId}\n        aria-activedescendant={\n          context.open ? context.activeItemId : undefined\n        }\n        autoComplete=\"off\"\n        disabled={context.disabled}\n        value={context.query}\n        placeholder={context.values.length ? \"\" : placeholder}\n        onPointerDown={(event) => {\n          onPointerDown?.(event);\n          if (event.defaultPrevented || context.open) return;\n          context.setOpen(true);\n        }}\n        onFocus={(event) => {\n          context.setOpen(true);\n          onFocus?.(event);\n        }}\n        onClick={(event) => {\n          context.setOpen(true);\n          onClick?.(event);\n        }}\n        onChange={(event) => {\n          context.setOpen(true);\n          context.setQuery(event.target.value);\n          onChange?.(event);\n        }}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"h-7 min-w-12 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n          className,\n        )}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/multi-select/content.tsx","type":"registry:component","target":"@components/motion/multi-select/content.tsx","content":"\"use client\";\n// beui.dev/components/motion/multi-select\n\nimport { motion, type Transition } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { cn } from \"@/lib/utils\";\nimport { useMultiSelectContext } from \"./context\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"center\" | \"end\";\n\n// Matches the Combobox surface: the field grows into a panel and then\n// separates, preserving a continuous spatial relationship with its trigger.\nconst MULTI_SELECT_MORPH: Transition = {\n  type: \"spring\",\n  duration: 0.5,\n  bounce: 0.22,\n};\nconst VIEWPORT_PADDING = 8;\n\nexport interface MultiSelectContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  sideOffset?: number;\n  avoidCollisions?: boolean;\n  className?: string;\n}\n\nexport function MultiSelectContent({\n  children,\n  side = \"bottom\",\n  align = \"start\",\n  sideOffset = 6,\n  avoidCollisions = true,\n  className,\n}: MultiSelectContentProps) {\n  const context = useMultiSelectContext(\"MultiSelectContent\");\n  const measureRef = useRef<HTMLDivElement>(null);\n  const [portalReady, setPortalReady] = useState(false);\n  const [actualSide, setActualSide] = useState<Side>(side);\n  const [morphReady, setMorphReady] = useState(false);\n  const layout = usePopoverPortalPosition(\n    context.triggerRef,\n    measureRef,\n    portalReady,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n  useLayoutEffect(() => {\n    if (!portalReady) return;\n    const readyFrame = requestAnimationFrame(() => setMorphReady(true));\n    return () => cancelAnimationFrame(readyFrame);\n  }, [portalReady]);\n\n  useLayoutEffect(() => {\n    if (!context.open || !layout) return;\n    if (!avoidCollisions) {\n      setActualSide(side);\n      return;\n    }\n    const below =\n      window.innerHeight - (layout.trigger.top + layout.trigger.height);\n    const above = layout.trigger.top;\n    if (\n      side === \"bottom\" &&\n      below < layout.content.height + sideOffset &&\n      above > below\n    ) {\n      setActualSide(\"top\");\n    } else if (\n      side === \"top\" &&\n      above < layout.content.height + sideOffset &&\n      below > above\n    ) {\n      setActualSide(\"bottom\");\n    } else {\n      setActualSide(side);\n    }\n  }, [avoidCollisions, context.open, layout, side, sideOffset]);\n\n  if (!portalReady) return null;\n\n  const triggerLeft = layout?.trigger.left ?? 0;\n  const triggerWidth = layout?.trigger.width ?? 0;\n  const contentWidth = layout?.content.width ?? triggerWidth;\n  const desiredLeft =\n    align === \"end\"\n      ? triggerLeft + triggerWidth - contentWidth\n      : align === \"center\"\n        ? triggerLeft + (triggerWidth - contentWidth) / 2\n        : triggerLeft;\n  const maxLeft = Math.max(\n    VIEWPORT_PADDING,\n    window.innerWidth - contentWidth - VIEWPORT_PADDING,\n  );\n  const left = Math.min(Math.max(desiredLeft, VIEWPORT_PADDING), maxLeft);\n  const surfaceHeight = layout?.content.height ?? 0;\n\n  return createPortal(\n    <motion.div\n      ref={context.contentRef}\n      data-multi-select-content=\"\"\n      data-side={actualSide}\n      aria-hidden={!context.open}\n      inert={!context.open}\n      initial={false}\n      animate={{\n        height: context.open ? surfaceHeight : 0,\n        opacity: context.open ? 1 : 0,\n        y: context.open\n          ? actualSide === \"bottom\"\n            ? sideOffset\n            : -sideOffset\n          : 0,\n      }}\n      transition={\n        context.reduce || !morphReady ? { duration: 0 } : MULTI_SELECT_MORPH\n      }\n      style={\n        {\n          left,\n          top:\n            actualSide === \"bottom\" && layout\n              ? layout.trigger.top + layout.trigger.height\n              : undefined,\n          bottom:\n            actualSide === \"top\" && layout\n              ? window.innerHeight - layout.trigger.top\n              : undefined,\n          minWidth: triggerWidth,\n          pointerEvents: context.open ? \"auto\" : \"none\",\n          transformOrigin: actualSide === \"bottom\" ? \"top\" : \"bottom\",\n          visibility: layout ? \"visible\" : \"hidden\",\n          \"--multi-select-trigger-width\": `${triggerWidth}px`,\n        } as CSSProperties\n      }\n      className={cn(\n        \"fixed z-[9999] w-(--multi-select-trigger-width) overflow-hidden rounded-xl border border-border bg-background text-popover-foreground outline-none will-change-[height,transform]\",\n        className,\n      )}\n    >\n      <motion.div\n        ref={measureRef}\n        initial={false}\n        animate={{ opacity: context.open ? 1 : 0 }}\n        transition={\n          context.reduce || !morphReady ? { duration: 0 } : MULTI_SELECT_MORPH\n        }\n      >\n        {children}\n      </motion.div>\n    </motion.div>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/multi-select/list.tsx","type":"registry:component","target":"@components/motion/multi-select/list.tsx","content":"\"use client\";\n// beui.dev/components/motion/multi-select\n\nimport { Check } from \"lucide-react\";\nimport { motion } from \"motion/react\";\nimport {\n  type ReactNode,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  MultiSelectGroupContext,\n  useMultiSelectContext,\n} from \"./context\";\n\nexport interface MultiSelectListProps {\n  children: ReactNode;\n  ariaLabel?: string;\n  className?: string;\n}\n\nexport function MultiSelectList({\n  children,\n  ariaLabel = \"Options\",\n  className,\n}: MultiSelectListProps) {\n  const context = useMultiSelectContext(\"MultiSelectList\");\n  return (\n    <div\n      id={context.listId}\n      role=\"listbox\"\n      aria-label={ariaLabel}\n      aria-multiselectable=\"true\"\n      className={cn(\n        \"relative isolate max-h-64 overflow-y-auto overscroll-contain p-1.5 [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:hidden\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface MultiSelectGroupProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function MultiSelectGroup({\n  children,\n  className,\n}: MultiSelectGroupProps) {\n  const context = useMultiSelectContext(\"MultiSelectGroup\");\n  const groupId = useId();\n  return (\n    <MultiSelectGroupContext.Provider value={groupId}>\n      <fieldset\n        hidden={!context.hasVisibleItems(groupId)}\n        className={cn(\"m-0 min-w-0 border-0 p-0 py-0.5\", className)}\n      >\n        {children}\n      </fieldset>\n    </MultiSelectGroupContext.Provider>\n  );\n}\n\nexport interface MultiSelectLabelProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function MultiSelectLabel({\n  children,\n  className,\n}: MultiSelectLabelProps) {\n  const groupId = useContext(MultiSelectGroupContext);\n  const labelClassName = cn(\n    \"w-full px-2 py-1.5 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\",\n    className,\n  );\n  return groupId ? (\n    <legend className={labelClassName}>{children}</legend>\n  ) : (\n    <div className={labelClassName}>{children}</div>\n  );\n}\n\nexport interface MultiSelectItemProps {\n  value: string;\n  children: ReactNode;\n  textValue?: string;\n  keywords?: string[];\n  disabled?: boolean;\n  onSelect?: (value: string) => void;\n  className?: string;\n}\n\nexport function MultiSelectItem({\n  value,\n  children,\n  textValue,\n  keywords = [],\n  disabled = false,\n  onSelect,\n  className,\n}: MultiSelectItemProps) {\n  const context = useMultiSelectContext(\"MultiSelectItem\");\n  const groupId = useContext(MultiSelectGroupContext);\n  const id = useId();\n  const itemRef = useRef<HTMLButtonElement>(null);\n  const label = textValue ?? (typeof children === \"string\" ? children : value);\n  const visible = context.isVisible(value);\n  const active = context.activeValue === value;\n  const selected = context.values.includes(value);\n  const keywordKey = keywords.join(\"\\u0000\");\n  const normalizedKeywords = useMemo(\n    () => (keywordKey ? keywordKey.split(\"\\u0000\") : []),\n    [keywordKey],\n  );\n  const { registerItem, unregisterItem } = context;\n\n  useLayoutEffect(() => {\n    registerItem({\n      value,\n      label,\n      keywords: normalizedKeywords,\n      disabled,\n      groupId,\n      id,\n      ref: itemRef,\n    });\n    return () => unregisterItem(value);\n  }, [\n    disabled,\n    groupId,\n    id,\n    label,\n    normalizedKeywords,\n    registerItem,\n    unregisterItem,\n    value,\n  ]);\n\n  if (!visible) return null;\n\n  return (\n    <button\n      ref={itemRef}\n      id={id}\n      type=\"button\"\n      role=\"option\"\n      aria-selected={selected}\n      disabled={disabled}\n      tabIndex={-1}\n      data-multi-select-item=\"\"\n      data-active={active || undefined}\n      data-selected={selected || undefined}\n      onPointerMove={() => {\n        if (!disabled) context.setActiveValue(value);\n      }}\n      onPointerDown={(event) => event.preventDefault()}\n      onClick={() => {\n        if (disabled) return;\n        onSelect?.(value);\n        context.toggle(value);\n      }}\n      className={cn(\n        \"relative flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm outline-none transition-colors duration-150\",\n        active || selected ? \"text-foreground\" : \"text-muted-foreground\",\n        \"disabled:pointer-events-none disabled:opacity-45\",\n        className,\n      )}\n    >\n      {active ? (\n        <motion.span\n          aria-hidden=\"true\"\n          layoutId={context.activeLayoutId}\n          className=\"absolute inset-0 -z-10 rounded-lg bg-muted\"\n          transition={context.reduce ? { duration: 0 } : SPRING_LAYOUT}\n        />\n      ) : null}\n      <span className=\"min-w-0 flex-1\">{children}</span>\n      <motion.span\n        aria-hidden=\"true\"\n        initial={false}\n        animate={{\n          opacity: selected ? 1 : 0,\n          transform: selected ? \"scale(1)\" : \"scale(0.82)\",\n        }}\n        transition={\n          context.reduce ? { duration: 0 } : { duration: 0.14, ease: EASE_OUT }\n        }\n        className=\"grid size-5 shrink-0 place-items-center text-foreground\"\n      >\n        <Check className=\"size-4\" />\n      </motion.span>\n    </button>\n  );\n}\n\nexport interface MultiSelectEmptyProps {\n  children?: ReactNode;\n  className?: string;\n}\n\nexport function MultiSelectEmpty({\n  children = \"No options found.\",\n  className,\n}: MultiSelectEmptyProps) {\n  const context = useMultiSelectContext(\"MultiSelectEmpty\");\n  if (context.visibleCount > 0) return null;\n  return (\n    <div\n      role=\"status\"\n      className={cn(\n        \"px-3 py-8 text-center text-sm text-muted-foreground\",\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport interface MultiSelectSeparatorProps {\n  className?: string;\n}\n\nexport function MultiSelectSeparator({\n  className,\n}: MultiSelectSeparatorProps) {\n  return (\n    <div aria-hidden=\"true\" className={cn(\"-mx-1 my-1 h-px bg-border\", className)} />\n  );\n}\n"},{"path":"components/motion/combobox/use-active-option.ts","type":"registry:component","target":"@components/motion/combobox/use-active-option.ts","content":"\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to, stamped with the query it\n * was placed under. Which option is *active* is resolved from this during\n * render, never in an effect: a passive effect runs after the commit, so a list\n * would briefly have none of its options active, and a key arriving in that\n * window would move from nowhere onto the row it was already about to\n * highlight.\n */\ntype ActiveCursor = { value: string; query: string };\n\ntype Options = {\n  query: string;\n  value: string | undefined;\n  /** The enabled, visible options in list order — all this hook reads of them. */\n  enabledItems: readonly { value: string }[];\n};\n\nconst isEnabled = (\n  enabledItems: Options[\"enabledItems\"],\n  candidate: string | undefined,\n): candidate is string =>\n  candidate !== undefined && enabledItems.some((i) => i.value === candidate);\n\n/**\n * The cursor's option, or null once the query or the result set it was placed\n * in has changed. A cursor that outlived either would steal Enter from the row\n * the user is aiming at. The result-set half costs something: a live search\n * that blanks its rows while fetching and returns the same ones loses the moved\n * highlight. That is deliberate — a highlight visibly back at the top beats one\n * silently in the wrong place.\n *\n * It is stamped with the query rather than with the identity of the visible\n * list because callers routinely pass an inline `filter`, which makes that list\n * a fresh array on every render.\n */\nfunction liveCursorValue(cursor: ActiveCursor | null, options: Options) {\n  if (cursor === null || cursor.query !== options.query) return null;\n  return isEnabled(options.enabledItems, cursor.value) ? cursor.value : null;\n}\n\n/**\n * Where the highlight sits with no live cursor: the selection if it can be\n * selected, otherwise the first option that can. Only enabled options qualify —\n * an active disabled option would point `aria-activedescendant` at a row Enter\n * then refuses to select.\n */\nfunction fallbackActive({ value, enabledItems }: Options) {\n  return isEnabled(enabledItems, value) ? value : (enabledItems[0]?.value ?? null);\n}\n\n/** The active option, from a cursor that may or may not still be live. */\nconst resolveActive = (cursor: ActiveCursor | null, options: Options) =>\n  liveCursorValue(cursor, options) ?? fallbackActive(options);\n\nexport function useActiveOption({ open, ...options }: Options & { open: boolean }) {\n  const { query, value, enabledItems } = options;\n  const [cursor, setCursor] = useState<ActiveCursor | null>(null);\n\n  const live = liveCursorValue(cursor, options);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // gone, so a value that reappears later cannot revive it.\n  if (cursor !== null && live === null) setCursor(null);\n  const derived = live ?? fallbackActive(options);\n\n  // Nothing is active until the list has been opened once. After that the\n  // resolution above is already stable across a close — the list keeps\n  // filtering by the query it was open with — so the highlight holds its row\n  // through the exit without being frozen separately.\n  const [opened, setOpened] = useState(open);\n  if (open && !opened) setOpened(true);\n  const activeValue = opened ? derived : null;\n\n  // Both callbacks keep one identity for the life of the component, and read\n  // the list through a ref to do it. A caller will put them in a `useMemo` or\n  // an effect's dependencies — the exhaustive-deps rule makes it — and\n  // `enabledItems` is a fresh array on every render for any consumer passing an\n  // inline `filter`, so a callback keyed to it would be rebuilt every render.\n  // Written after commit rather than during render: a render React discards\n  // still runs the component body, and a handler reading this in that window\n  // would step against a list the committed tree does not have.\n  const latest = useRef({ open, query, value, enabledItems });\n  useLayoutEffect(() => {\n    latest.current = { open, query, value, enabledItems };\n  });\n\n  const setActiveValue = useCallback((next: string | null) => {\n    setCursor(\n      next === null ? null : { value: next, query: latest.current.query },\n    );\n  }, []);\n\n  // Steps from the option the cursor really resolves to, inside the update, so\n  // that two keys landing in one batch move two rows rather than one.\n  const moveActive = useCallback(\n    (direction: 1 | -1 | \"first\" | \"last\") => {\n      const options = latest.current;\n      // While closed the list is still filtering by the query it was open\n      // with, so a step taken now would be measured against rows the next\n      // render replaces. Opening is the caller's job; stepping waits for it.\n      if (!options.open) return;\n      const rows = options.enabledItems;\n      const last = rows.length - 1;\n      if (last < 0) {\n        setCursor(null);\n        return;\n      }\n      setCursor((current) => {\n        // `resolveActive` always lands on a member of `enabledItems` once the\n        // list is non-empty, which the early return above guarantees, so there\n        // is always a row to step from.\n        const from = resolveActive(current, options);\n        const at = rows.findIndex((item) => item.value === from);\n        const index =\n          direction === \"first\"\n            ? 0\n            : direction === \"last\"\n              ? last\n              : (at + direction + rows.length) % rows.length;\n        return { value: rows[index].value, query: options.query };\n      });\n    },\n    [],\n  );\n\n  return { activeValue, setActiveValue, moveActive };\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"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"components/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"}]}