{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "author": "DesignX ", "name": "motion-combobox", "type": "registry:component", "title": "Combobox", "description": "A searchable select with a sliding highlight.", "dependencies": [ "lucide-react@^0.577.0", "motion@^14.0.0" ], "registryDependencies": [ "@dx/lib-ease", "@dx/utils" ], "files": [ { "path": "components/motion/combobox.tsx", "type": "registry:component", "target": "components/motion/combobox.tsx", "content": "export {\n ComboboxContent,\n type ComboboxContentProps,\n} from \"./combobox/content\";\nexport {\n Combobox,\n type ComboboxFilter,\n type ComboboxProps,\n} from \"./combobox/context\";\nexport {\n ComboboxEmpty,\n type ComboboxEmptyProps,\n ComboboxGroup,\n type ComboboxGroupProps,\n ComboboxItem,\n type ComboboxItemProps,\n ComboboxLabel,\n type ComboboxLabelProps,\n ComboboxList,\n type ComboboxListProps,\n ComboboxSeparator,\n type ComboboxSeparatorProps,\n} from \"./combobox/list\";\nexport {\n ComboboxInput,\n type ComboboxInputProps,\n ComboboxTrigger,\n type ComboboxTriggerProps,\n ComboboxValue,\n type ComboboxValueProps,\n} from \"./combobox/trigger\";\n" }, { "path": "components/motion/combobox/content.tsx", "type": "registry:component", "target": "components/motion/combobox/content.tsx", "content": "import { 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 { useComboboxContext } from \"./context\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"center\" | \"end\";\n\n// The panel uses one weighted spring for both directions, so opening and\n// closing travel through the same detached geometry.\nconst COMBOBOX_MORPH: Transition = {\n type: \"spring\",\n duration: 0.5,\n bounce: 0.22,\n};\nconst VIEWPORT_PADDING = 8;\n\nexport interface ComboboxContentProps {\n children: ReactNode;\n side?: Side;\n align?: Align;\n sideOffset?: number;\n avoidCollisions?: boolean;\n className?: string;\n}\n\nexport function ComboboxContent({\n children,\n side = \"bottom\",\n align = \"start\",\n sideOffset = 6,\n avoidCollisions = true,\n className,\n}: ComboboxContentProps) {\n const context = useComboboxContext(\"ComboboxContent\");\n const measureRef = useRef(null);\n const [portalReady, setPortalReady] = useState(false);\n const [actualSide, setActualSide] = useState(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 // Preserve the resolved side during exit, so top panels close upward.\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 setActualSide(side);\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 \n \n {children}\n \n ,\n document.body,\n );\n}\n" }, { "path": "components/motion/combobox/context.tsx", "type": "registry:component", "target": "components/motion/combobox/context.tsx", "content": "import { 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 { cn } from \"@/lib/utils\";\nimport { useActiveOption } from \"./use-active-option\";\n\nexport type RegisteredItem = {\n value: string;\n label: string;\n keywords: string[];\n disabled: boolean;\n groupId: string | null;\n id: string;\n ref: MutableRefObject;\n};\n\nexport type ComboboxFilter = (\n value: string,\n query: string,\n keywords: string[],\n) => boolean;\n\nconst defaultFilter: ComboboxFilter = (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 ComboboxContextValue = {\n open: boolean;\n setOpen: (open: boolean, restoreFocus?: boolean) => void;\n value: string | undefined;\n select: (value: string) => void;\n query: string;\n setQuery: (query: string) => void;\n /** The option the list is currently pointing at, resolved during render. */\n activeValue: string | null;\n /**\n * Records where the pointer or keyboard moved to. The recorded value is\n * dropped when the query or the result set changes, so it does not always\n * survive to the next `activeValue`.\n */\n setActiveValue: (value: string | null) => void;\n moveActive: (direction: 1 | -1 | \"first\" | \"last\") => void;\n selectActive: () => void;\n registerItem: (item: RegisteredItem) => void;\n unregisterItem: (value: string) => void;\n labelFor: (value: string | undefined) => string | undefined;\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;\n contentRef: MutableRefObject;\n inputRef: MutableRefObject;\n activeLayoutId: string;\n};\n\nexport const ComboboxContext = createContext(null);\nexport const ComboboxGroupContext = createContext(null);\n\nexport function useComboboxContext(component: string) {\n const context = useContext(ComboboxContext);\n if (!context) throw new Error(`${component} must be used within `);\n return context;\n}\n\nexport function mergeRefs(...refs: Array | 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).current = node;\n }\n };\n}\n\nexport interface ComboboxProps {\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?: ComboboxFilter;\n disabled?: boolean;\n className?: string;\n}\n\nexport function Combobox({\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}: ComboboxProps) {\n const reduce = useReducedMotion() ?? false;\n const baseId = useId();\n const rootRef = useRef(null);\n const triggerRef = useRef(null);\n const contentRef = useRef(null);\n const inputRef = useRef(null);\n const [internalValue, setInternalValue] = useState(defaultValue);\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const [internalQuery, setInternalQuery] = useState(defaultQuery);\n const [items, setItems] = useState>(new Map());\n\n const valueControlled = controlledValue !== undefined;\n const openControlled = controlledOpen !== undefined;\n const queryControlled = controlledQuery !== undefined;\n const value = valueControlled ? controlledValue : internalValue;\n const open = openControlled ? controlledOpen : internalOpen;\n const query = queryControlled ? 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 [disabled, onOpenChange, openControlled, updateQuery],\n );\n\n const registerItem = useCallback((item: RegisteredItem) => {\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 // The query the list filters by. Closing clears `query`, but the panel is\n // still on screen for its exit, so it keeps filtering by the query it was\n // open with rather than repopulating mid-collapse. The input already reads\n // `query` only while open, so nothing the user can see reads the other one.\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,\n enabledItems: enabledVisibleItems,\n });\n\n const select = useCallback(\n (next: string) => {\n if (items.get(next)?.disabled) return;\n if (!valueControlled) setInternalValue(next);\n onValueChange?.(next);\n updateOpen(false, true);\n },\n [items, onValueChange, updateOpen, valueControlled],\n );\n\n const selectActive = useCallback(() => {\n if (activeValue) select(activeValue);\n }, [activeValue, select]);\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(\"[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 }, [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(\n () => ({\n open,\n setOpen: updateOpen,\n value,\n select,\n query,\n setQuery: updateQuery,\n activeValue,\n setActiveValue,\n moveActive,\n selectActive,\n registerItem,\n unregisterItem,\n labelFor: (itemValue) =>\n itemValue === undefined ? undefined : items.get(itemValue)?.label,\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 select,\n selectActive,\n setActiveValue,\n unregisterItem,\n updateOpen,\n updateQuery,\n value,\n visibleItems.length,\n visibleGroupIds,\n visibleValues,\n ],\n );\n\n return (\n \n
\n {children}\n
\n
\n );\n}\n" }, { "path": "components/motion/combobox/list.tsx", "type": "registry:component", "target": "components/motion/combobox/list.tsx", "content": "import { 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 { ComboboxGroupContext, useComboboxContext } from \"./context\";\n\nexport interface ComboboxListProps {\n children: ReactNode;\n ariaLabel?: string;\n className?: string;\n}\n\nexport function ComboboxList({\n children,\n ariaLabel = \"Options\",\n className,\n}: ComboboxListProps) {\n const context = useComboboxContext(\"ComboboxList\");\n return (\n \n {children}\n \n );\n}\n\nexport interface ComboboxGroupProps {\n children: ReactNode;\n className?: string;\n}\n\nexport function ComboboxGroup({ children, className }: ComboboxGroupProps) {\n const context = useComboboxContext(\"ComboboxGroup\");\n const groupId = useId();\n return (\n \n \n );\n}\n\nexport interface ComboboxLabelProps {\n children: ReactNode;\n className?: string;\n}\n\nexport function ComboboxLabel({ children, className }: ComboboxLabelProps) {\n const groupId = useContext(ComboboxGroupContext);\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 {children}\n ) : (\n
{children}
\n );\n}\n\nexport interface ComboboxItemProps {\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 ComboboxItem({\n value,\n children,\n textValue,\n keywords = [],\n disabled = false,\n onSelect,\n className,\n}: ComboboxItemProps) {\n const context = useComboboxContext(\"ComboboxItem\");\n const groupId = useContext(ComboboxGroupContext);\n const id = useId();\n const itemRef = useRef(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.value === 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 {\n if (!disabled) context.setActiveValue(value);\n }}\n onPointerDown={(event) => event.preventDefault()}\n onClick={() => {\n if (disabled) return;\n onSelect?.(value);\n context.select(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 ? \"text-foreground\" : \"text-muted-foreground\",\n \"disabled:pointer-events-none disabled:opacity-45\",\n className,\n )}>\n {active ? (\n \n ) : null}\n {children}\n \n \n \n \n );\n}\n\nexport interface ComboboxEmptyProps {\n children?: ReactNode;\n className?: string;\n}\n\nexport function ComboboxEmpty({\n children = \"No options found.\",\n className,\n}: ComboboxEmptyProps) {\n const context = useComboboxContext(\"ComboboxEmpty\");\n if (context.visibleCount > 0) return null;\n return (\n \n {children}\n \n );\n}\n\nexport interface ComboboxSeparatorProps {\n className?: string;\n}\n\nexport function ComboboxSeparator({ className }: ComboboxSeparatorProps) {\n return (\n
\n );\n}\n" }, { "path": "components/motion/combobox/trigger.tsx", "type": "registry:component", "target": "components/motion/combobox/trigger.tsx", "content": "import { ChevronsUpDown, Search } from \"lucide-react\";\nimport type {\n InputHTMLAttributes,\n KeyboardEvent as ReactKeyboardEvent,\n ReactNode,\n Ref,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs, useComboboxContext } from \"./context\";\n\nexport interface ComboboxTriggerProps {\n children: ReactNode;\n className?: string;\n}\n\nexport function ComboboxTrigger({ children, className }: ComboboxTriggerProps) {\n const context = useComboboxContext(\"ComboboxTrigger\");\n\n return (\n {\n if (context.disabled || event.target === context.inputRef.current) return;\n event.preventDefault();\n context.inputRef.current?.focus({ preventScroll: true });\n context.setOpen(true);\n }}\n className={cn(\n \"relative z-20 flex h-10 w-full min-w-52 cursor-text items-center justify-between gap-3 rounded-xl border border-border bg-transparent px-3 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 {children}\n \n \n \n
\n );\n}\n\nexport interface ComboboxValueProps {\n placeholder?: ReactNode;\n children?:\n | ReactNode\n | ((value: string | undefined, label: string | undefined) => ReactNode);\n className?: string;\n}\n\nexport function ComboboxValue({\n placeholder = \"Select an option\",\n children,\n className,\n}: ComboboxValueProps) {\n const context = useComboboxContext(\"ComboboxValue\");\n const label = context.labelFor(context.value);\n const content =\n typeof children === \"function\"\n ? children(context.value, label)\n : children ?? label ?? placeholder;\n\n return (\n \n {content}\n \n );\n}\n\nexport interface ComboboxInputProps\n extends Omit<\n InputHTMLAttributes,\n \"defaultValue\" | \"value\"\n > {\n ref?: Ref;\n wrapperClassName?: string;\n}\n\nexport function ComboboxInput({\n ref,\n className,\n wrapperClassName,\n \"aria-label\": ariaLabel = \"Search options\",\n onChange,\n onClick,\n onFocus,\n onKeyDown,\n onPointerDown,\n placeholder = \"Search…\",\n ...props\n}: ComboboxInputProps) {\n const context = useComboboxContext(\"ComboboxInput\");\n const selectedLabel = context.labelFor(context.value);\n\n const handleKeyDown = (event: ReactKeyboardEvent) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n // Opening is the whole action. While closed the list is still filtering\n // by the query the last session left, so a step taken here would be\n // measured against rows the next render replacesand stamped with a\n // query it no longer has, which discards it. Open onto the selection,\n // and let the next key step through the list the user can see.\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.selectActive();\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 \n \n {\n onPointerDown?.(event);\n if (event.defaultPrevented || context.open) return;\n event.preventDefault();\n context.inputRef.current?.focus({ preventScroll: true });\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-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n className,\n )}\n />\n \n );\n}\n" }, { "path": "components/motion/popover-position.ts", "type": "registry:component", "target": "components/motion/popover-position.ts", "content": "import {\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,\n contentRef: MutableRefObject,\n active: boolean,\n) {\n const [layout, setLayout] = useState(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" }, { "path": "components/motion/combobox/use-active-option.ts", "type": "registry:component", "target": "components/motion/combobox/use-active-option.ts", "content": "import { 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(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" } ], "categories": [ "motion-kit" ], "meta": { "group": "motion", "docs": "https://dxuireact.com/docs/components/motion-combobox" } }