{
  "slug": "selector",
  "name": "Selector",
  "description": "A searchable selector for models, people, workspaces and visual resources. Single or multiple selection, grouped lists or thumbnail grids, optional details, removable chips and keyboard navigation share one morphing surface.",
  "category": "blocks",
  "source_url": "https://asharca.github.io/ui/r/selector/raw.txt",
  "detail_url": "https://asharca.github.io/ui/r/selector/detail.json",
  "raw_url": "https://asharca.github.io/ui/r/selector/raw.txt",
  "page_url": "https://asharca.github.io/ui/components/blocks/selector",
  "markdown_url": "https://asharca.github.io/ui/components/blocks/selector.md",
  "published_at": "2026-09-28",
  "updated_at": "2026-10-16",
  "dependencies": [
    "clsx",
    "lucide-react",
    "motion",
    "react",
    "react-dom",
    "tailwind-merge"
  ],
  "internal": [
    "@/components/motion/popover-morph",
    "@/components/motion/popover-position",
    "@/components/motion/selector",
    "@/lib/ease",
    "@/lib/hooks/use-on-open",
    "@/lib/hooks/use-row-cursor",
    "@/lib/utils"
  ],
  "files": [
    {
      "path": "components/motion/selector.tsx",
      "type": "component",
      "content": "\"use client\";\n// asharca.github.io/ui/components/blocks/selector\n\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n\ttype KeyboardEvent,\n\ttype ReactNode,\n\tuseEffect,\n\tuseId,\n\tuseLayoutEffect,\n\tuseMemo,\n\tuseRef,\n\tuseState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n\tMorphPopover,\n\tMorphPopoverContent,\n\tMorphPopoverTrigger,\n} from \"@/components/motion/popover-morph\";\nimport { SPRING_PRESS } from \"@/lib/ease\";\nimport { useOnOpen } from \"@/lib/hooks/use-on-open\";\nimport { useRowCursor } from \"@/lib/hooks/use-row-cursor\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SelectorOption = {\n\tid: string;\n\tname: string;\n\tdescription?: string;\n\tgroup?: string;\n\tmetadata?: string;\n\tkeywords?: string[];\n\ticon?: ReactNode;\n\tpreview?: ReactNode;\n\tdetails?: ReactNode;\n\tdisabled?: boolean;\n};\n\n/** Optional Tailwind overrides, merged after each slot's defaults. */\nexport type SelectorClassNames = {\n\ttrigger?: string;\n\tcontent?: string;\n\tlist?: string;\n\toption?: string;\n\ticon?: string;\n\tsearch?: string;\n\tdetails?: string;\n};\n\ntype SharedSelectorProps = {\n\toptions: SelectorOption[];\n\tdefaultOpen?: boolean;\n\tplaceholder?: string;\n\tsearchPlaceholder?: string;\n\tlabel?: string;\n\ttriggerVariant?: \"pill\" | \"field\" | \"workspace\";\n\tlayout?: \"list\" | \"grid\";\n\taction?: { label: string; onSelect: () => void };\n\tclassName?: string;\n\t/** Style individual parts independently; className styles the outer wrapper. */\n\tclassNames?: SelectorClassNames;\n};\n\nexport type SelectorProps = SharedSelectorProps & (\n\t| {\n\t\t\tmultiple?: false;\n\t\t\tvalue?: string;\n\t\t\tdefaultValue?: string;\n\t\t\tonValueChange?: (value: string) => void;\n\t\t}\n\t| {\n\t\t\tmultiple: true;\n\t\t\tvalue?: string[];\n\t\t\tdefaultValue?: string[];\n\t\t\tonValueChange?: (value: string[]) => void;\n\t\t}\n);\n\n\nconst SCROLLBAR_CLASS =\n\t\"[scrollbar-width:thin] [scrollbar-gutter:stable] [scrollbar-color:color-mix(in_oklab,var(--foreground)_18%,transparent)_transparent] hover:[scrollbar-color:color-mix(in_oklab,var(--foreground)_30%,transparent)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-foreground/20 [&::-webkit-scrollbar-thumb:hover]:bg-foreground/30\";\n\nexport function Selector(props: SelectorProps) {\n\tconst {\n\t\toptions,\n\t\tdefaultOpen = false,\n\t\tplaceholder = \"Select an option\",\n\t\tsearchPlaceholder = \"Search options\",\n\t\tlabel = \"Options\",\n\t\ttriggerVariant = \"field\",\n\t\tlayout = \"list\",\n\t\taction,\n\t\tclassName,\n\t\tclassNames,\n\t} = props;\n\tconst uid = useId();\n\tconst reduce = useReducedMotion();\n\tconst [internalValue, setInternalValue] = useState<string | string[]>(() =>\n\t\tprops.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? \"\"),\n\t);\n\tconst currentValue = props.value ?? internalValue;\n\tconst latestValue = useRef(currentValue);\n\tuseLayoutEffect(() => {\n\t\tlatestValue.current = currentValue;\n\t});\n\tconst selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]);\n\tconst selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id));\n\tconst selected = selectedOptions[0];\n\tconst [open, setOpen] = useState(defaultOpen);\n\tconst [query, setQuery] = useState(\"\");\n\tconst listRef = useRef<HTMLDivElement>(null);\n\tconst triggerRef = useRef<HTMLButtonElement>(null);\n\tconst inputRef = useRef<HTMLInputElement>(null);\n\tconst detailRef = useRef<HTMLElement>(null);\n\tconst detailAnchor = useRef<HTMLElement | null>(null);\n\tconst detailTimer = useRef<number | undefined>(undefined);\n\tconst touchInteraction = useRef(false);\n\tconst [detailId, setDetailId] = useState<string | null>(null);\n\tconst [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 });\n\tconst groups = useMemo(() => {\n\t\tconst result = new Map<string, SelectorOption[]>();\n\t\tconst search = query.trim().toLowerCase();\n\t\tfor (const option of options) {\n\t\t\tif (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])]\n\t\t\t\t.filter(Boolean).join(\" \").toLowerCase().includes(search)) continue;\n\t\t\tconst group = option.group ?? \"\";\n\t\t\tconst items = result.get(group) ?? [];\n\t\t\titems.push(option);\n\t\t\tresult.set(group, items);\n\t\t}\n\t\treturn Array.from(result, ([label, items]) => ({ label, items }));\n\t}, [options, query]);\n\tconst rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]);\n\tconst { activeIndex, moveTo, moveActive } = useRowCursor(rows, query);\n\tconst active = rows[activeIndex];\n\tconst detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined;\n\n\tconst clearDetailTimer = () => {\n\t\twindow.clearTimeout(detailTimer.current);\n\t\tdetailTimer.current = undefined;\n\t};\n\tconst hideDetail = () => {\n\t\tclearDetailTimer();\n\t\tsetDetailId(null);\n\t};\n\tconst scheduleDetailClose = () => {\n\t\tclearDetailTimer();\n\t\tdetailTimer.current = window.setTimeout(() => setDetailId(null), 100);\n\t};\n\tconst showDetail = (option: SelectorOption, anchor: HTMLElement) => {\n\t\tclearDetailTimer();\n\t\tif (option.disabled || option.details == null || touchInteraction.current) {\n\t\t\tsetDetailId(null);\n\t\t\treturn;\n\t\t}\n\t\tif (detailId === option.id) return;\n\t\tsetDetailId(null);\n\t\tdetailTimer.current = window.setTimeout(() => {\n\t\t\tdetailAnchor.current = anchor;\n\t\t\tsetDetailId(option.id);\n\t\t}, 1500);\n\t};\n\tuseEffect(() => {\n\t\tif (!open || rows.length === 0) window.clearTimeout(detailTimer.current);\n\t\treturn () => window.clearTimeout(detailTimer.current);\n\t}, [open, rows]);\n\tuseLayoutEffect(() => {\n\t\tif (!detail) return;\n\t\tconst place = () => {\n\t\t\tconst rect = detailAnchor.current?.getBoundingClientRect();\n\t\t\tconst card = detailRef.current;\n\t\t\tif (!rect || !card) return;\n\t\t\tconst rightSpace = window.innerWidth - rect.right - 20;\n\t\t\tconst leftSpace = rect.left - 20;\n\t\t\tconst left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace\n\t\t\t\t? rect.right + 8 : rect.left - card.offsetWidth - 8;\n\t\t\tsetDetailPosition({\n\t\t\t\tleft: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)),\n\t\t\t\ttop: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)),\n\t\t\t});\n\t\t};\n\t\tplace();\n\t\tconst observer = new ResizeObserver(place);\n\t\tif (detailRef.current) observer.observe(detailRef.current);\n\t\twindow.addEventListener(\"resize\", place);\n\t\twindow.addEventListener(\"scroll\", place, true);\n\t\treturn () => {\n\t\t\tobserver.disconnect();\n\t\t\twindow.removeEventListener(\"resize\", place);\n\t\t\twindow.removeEventListener(\"scroll\", place, true);\n\t\t};\n\t}, [detail]);\n\tuseOnOpen(open, () => {\n\t\tsetQuery(\"\");\n\t\tsetDetailId(null);\n\t\tmoveTo(selected?.id ?? null);\n\t});\n\tuseEffect(() => {\n\t\tif (!open) return;\n\t\tconst frame = requestAnimationFrame(() => {\n\t\t\tlistRef.current?.querySelector<HTMLElement>(`[data-index=\"${activeIndex}\"]`)\n\t\t\t\t?.scrollIntoView({ block: \"nearest\" });\n\t\t});\n\t\treturn () => cancelAnimationFrame(frame);\n\t}, [open, activeIndex]);\n\n\tconst changeOpen = (next: boolean) => {\n\t\tsetOpen(next);\n\t\tif (!next) {\n\t\t\thideDetail();\n\t\t\tsetQuery(\"\");\n\t\t}\n\t};\n\tconst close = () => {\n\t\tchangeOpen(false);\n\t\ttriggerRef.current?.focus();\n\t};\n\tconst select = (option: SelectorOption) => {\n\t\tif (option.disabled) return;\n\t\tif (props.multiple) {\n\t\t\tconst liveValue = props.value ?? latestValue.current;\n\t\t\tconst liveIds = Array.isArray(liveValue) ? liveValue : [];\n\t\t\tconst enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled));\n\t\t\tconst next = enabledIds.includes(option.id)\n\t\t\t\t? enabledIds.filter((id) => id !== option.id)\n\t\t\t\t: [...enabledIds, option.id];\n\t\t\tif (props.value === undefined) {\n\t\t\t\tlatestValue.current = next;\n\t\t\t\tsetInternalValue(next);\n\t\t\t}\n\t\t\tprops.onValueChange?.(next);\n\t\t\thideDetail();\n\t\t} else {\n\t\t\tif (props.value === undefined) setInternalValue(option.id);\n\t\t\tprops.onValueChange?.(option.id);\n\t\t\tclose();\n\t\t}\n\t};\n\tconst onKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n\t\tif (event.nativeEvent.isComposing) return;\n\t\ttouchInteraction.current = false;\n\t\tif (event.key !== \"Tab\") hideDetail();\n\t\tif (event.key === \"Escape\") {\n\t\t\tevent.preventDefault();\n\t\t\tclose();\n\t\t} else if (event.target !== inputRef.current &&\n\t\t\t!(event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"option\")) {\n\t\t\treturn;\n\t\t} else if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n\t\t\tevent.preventDefault();\n\t\t\tmoveActive(event.key === \"ArrowDown\" ? 1 : -1);\n\t\t} else if (event.key === \"PageDown\" || event.key === \"PageUp\") {\n\t\t\tevent.preventDefault();\n\t\t\tfor (let step = 0; step < 12; step++) moveActive(event.key === \"PageDown\" ? 1 : -1);\n\t\t} else if (event.key === \"Enter\" && active) {\n\t\t\tevent.preventDefault();\n\t\t\tselect(active);\n\t\t}\n\t};\n\n\treturn (\n\t\t<MorphPopover open={open} onOpenChange={changeOpen} className={cn(\"max-w-full\", triggerVariant === \"pill\" ? \"w-auto\" : \"w-64\", className)}>\n\t\t\t<div className={cn(\"w-full border border-border bg-background shadow-sm\", triggerVariant === \"pill\" ? \"rounded-3xl\" : \"rounded-xl\")}>\n\t\t\t\t<MorphPopoverTrigger>\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tref={triggerRef}\n\t\t\t\t\t\tclassName={cn(\"flex min-h-11 w-full items-center gap-2 rounded-[inherit] px-3 py-2 text-left text-sm transition-colors hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring\", classNames?.trigger)}\n\t\t\t\t\t>\n\t\t\t\t\t\t{selected?.icon != null ? <span aria-hidden className={cn(\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted\", classNames?.icon)}>{selected.icon}</span> : null}\n\t\t\t\t\t\t<span className=\"min-w-0 flex-1\">\n\t\t\t\t\t\t\t<span className={cn(\"block truncate font-medium\", !selected && \"text-muted-foreground\")}>\n\t\t\t\t\t\t\t\t{props.multiple ? (selectedOptions.length ? `${selectedOptions.length} selected` : placeholder) : selected?.name ?? placeholder}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t{triggerVariant === \"workspace\" && selected?.description ? <span className=\"block truncate text-xs text-muted-foreground\">{selected.description}</span> : null}\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<motion.span\n\t\t\t\t\t\t\taria-hidden\n\t\t\t\t\t\t\tinitial={false}\n\t\t\t\t\t\t\tanimate={{ rotate: open ? 180 : 0 }}\n\t\t\t\t\t\t\ttransition={reduce ? { duration: 0 } : SPRING_PRESS}\n\t\t\t\t\t\t\tclassName=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<ChevronDown className=\"size-4\" />\n\t\t\t\t\t\t</motion.span>\n\t\t\t\t\t</button>\n\t\t\t\t</MorphPopoverTrigger>\n\t\t\t\t{props.multiple && selectedOptions.length ? (\n\t\t\t\t\t<div className=\"flex flex-wrap gap-1.5 px-2.5 pb-2.5\">\n\t\t\t\t\t\t{selectedOptions.map((option) => (\n\t\t\t\t\t\t\t<span key={option.id} className=\"inline-flex max-w-full items-center gap-1 rounded-md border border-border bg-muted/60 py-0.5 pr-0.5 pl-2 text-xs\">\n\t\t\t\t\t\t\t\t<span className=\"truncate\">{option.name}</span>\n\t\t\t\t\t\t\t\t<button type=\"button\" aria-label={`Remove ${option.name}`} onClick={() => { select(option); triggerRef.current?.focus(); }} className=\"rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring\"><X aria-hidden className=\"size-3\" /></button>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t\t<MorphPopoverContent align=\"start\" sideOffset={6} radius={16} onOpenAutoFocus={() => inputRef.current?.focus()} className={cn(\"flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground\", classNames?.content)}>\n\t\t\t\t<div className=\"shrink-0 p-2 pb-1\">\n\t\t\t\t\t<div className={cn(\"flex h-10 items-center gap-2 rounded-lg bg-muted/60 px-3\", classNames?.search)}>\n\t\t\t\t\t\t<Search aria-hidden className=\"size-4 shrink-0 text-muted-foreground\" />\n\t\t\t\t\t\t<input ref={inputRef} onKeyDown={onKeyDown} value={query} onChange={(event) => { hideDetail(); setQuery(event.target.value); }} role=\"combobox\" aria-label={searchPlaceholder} aria-expanded=\"true\" aria-autocomplete=\"list\" aria-controls={`${uid}-options`} aria-activedescendant={active ? `${uid}-option-${activeIndex}` : undefined} placeholder={searchPlaceholder} className=\"h-full min-w-0 flex-1 bg-transparent text-[inherit] outline-none placeholder:text-muted-foreground\" />\n\t\t\t\t\t\t{query ? <button type=\"button\" aria-label=\"Clear search\" onClick={() => { hideDetail(); setQuery(\"\"); inputRef.current?.focus(); }} className=\"rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring\"><X aria-hidden className=\"size-4\" /></button> : <kbd className=\"text-[10px] text-muted-foreground\">ESC</kbd>}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<div ref={listRef} id={`${uid}-options`} role=\"listbox\" aria-label={label} aria-multiselectable={props.multiple || undefined} onScroll={hideDetail} className={cn(\"max-h-[min(360px,calc(100dvh-176px))] min-h-0 overflow-y-auto overscroll-contain p-2\", SCROLLBAR_CLASS, classNames?.list)}>\n\t\t\t\t\t{groups.length ? groups.map((group) => (\n\t\t\t\t\t\t<fieldset key={group.label} className=\"mb-2 min-w-0 border-0 p-0 last:mb-0\">\n\t\t\t\t\t\t\t{group.label ? <legend className=\"px-2 py-2 text-[11px] font-medium text-muted-foreground\">{group.label}</legend> : null}\n\t\t\t\t\t\t\t<div className={cn(layout === \"grid\" ? \"grid grid-cols-2 gap-2\" : \"space-y-0.5\")}>\n\t\t\t\t\t\t\t\t{group.items.map((option) => {\n\t\t\t\t\t\t\t\t\tconst index = rows.indexOf(option);\n\t\t\t\t\t\t\t\t\tconst isActive = active?.id === option.id;\n\t\t\t\t\t\t\t\t\tconst isSelected = !option.disabled && selectedIds.has(option.id);\n\t\t\t\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t\t\t\t<button key={option.id} id={index >= 0 ? `${uid}-option-${index}` : undefined} type=\"button\" role=\"option\" aria-selected={isSelected} aria-disabled={option.disabled || undefined} disabled={option.disabled} data-index={index >= 0 ? index : undefined} onKeyDown={onKeyDown} tabIndex={-1} aria-describedby={detail?.id === option.id ? `${uid}-details` : undefined}\n\t\t\t\t\t\t\t\t\t\t\tonPointerEnter={(event) => { touchInteraction.current = event.pointerType === \"touch\"; if (touchInteraction.current || option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }}\n\t\t\t\t\t\t\t\t\t\t\tonPointerDown={(event) => { touchInteraction.current = event.pointerType === \"touch\"; if (touchInteraction.current) hideDetail(); }} onPointerLeave={scheduleDetailClose}\n\t\t\t\t\t\t\t\t\t\t\tonFocus={(event) => { if (option.disabled) return; moveTo(option.id); showDetail(option, event.currentTarget); }} onBlur={scheduleDetailClose} onClick={() => select(option)}\n\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\"relative flex min-h-12 w-full gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40\", layout === \"grid\" ? \"flex-col items-stretch border border-border\" : \"items-center\", layout === \"grid\" && isSelected && \"border-foreground\", isActive && \"bg-muted/70\", classNames?.option)}>\n\t\t\t\t\t\t\t\t\t\t\t{layout === \"grid\" ? <div className=\"flex aspect-[16/10] items-center justify-center overflow-hidden rounded-lg bg-muted/60 text-muted-foreground\">{option.preview ?? option.icon ?? <span className=\"text-2xl font-medium\">{option.name.slice(0, 1)}</span>}</div> : <span aria-hidden className={cn(\"flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-background text-muted-foreground\", classNames?.icon)}>{option.icon ?? <span className=\"text-xs font-medium\">{option.name.slice(0, 1)}</span>}</span>}\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"flex min-w-0 flex-1 items-center gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"min-w-0 flex-1\"><span className=\"block truncate font-medium\">{option.name}</span>{option.description ? <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span> : null}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{option.metadata ? <span className=\"shrink-0 text-[10px] text-muted-foreground\">{option.metadata}</span> : null}\n\t\t\t\t\t\t\t\t\t\t\t\t<span aria-hidden className={cn(\"flex size-4 shrink-0 items-center justify-center\", props.multiple && \"rounded border border-border\", props.multiple && isSelected && \"border-foreground bg-foreground text-background\")}>{isSelected ? <Check className=\"size-3.5\" /> : null}</span>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t})}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</fieldset>\n\t\t\t\t\t)) : <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">No options found.</p>}\n\t\t\t\t</div>\n\t\t\t\t<div className=\"flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2 text-[10px] text-muted-foreground\">\n\t\t\t\t\t<span className=\"flex flex-wrap gap-x-3 gap-y-1\">{props.multiple ? <span>{selectedOptions.length} selected</span> : <><span><kbd>↑↓</kbd> Navigate</span><span><kbd>↵</kbd> Select</span></>}</span>\n\t\t\t\t\t<div className=\"flex items-center gap-2\">\n\t\t\t\t\t\t{action ? <button type=\"button\" onClick={() => { close(); action.onSelect(); }} className=\"rounded-md px-2 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring\">{action.label}</button> : null}\n\t\t\t\t\t\t{props.multiple ? <button type=\"button\" onClick={close} className=\"rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background focus-visible:outline-2 focus-visible:outline-ring\">Done</button> : null}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</MorphPopoverContent>\n\t\t\t{detail ? createPortal(\n\t\t\t\t<aside ref={detailRef} id={`${uid}-details`} aria-label=\"Option details\" onKeyDown={onKeyDown} onPointerEnter={clearDetailTimer} onPointerLeave={scheduleDetailClose} onPointerDown={(event) => event.stopPropagation()} onFocus={clearDetailTimer} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) scheduleDetailClose(); }} className={cn(\"fixed z-[10000] max-h-[min(420px,70dvh)] w-[21rem] max-w-[calc(100vw-24px)] overflow-y-auto overscroll-contain rounded-2xl border border-border bg-background p-4 text-sm text-foreground shadow-xl\", SCROLLBAR_CLASS, classNames?.details)} style={{ ...detailPosition, backgroundColor: \"var(--background)\" }}>{detail.details}</aside>, document.body,\n\t\t\t) : null}\n\t\t</MorphPopover>\n\t);\n}\n"
    },
    {
      "path": "components/motion/popover-morph.tsx",
      "type": "util",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  animate,\n  useMotionValue,\n  usePresence,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"end\";\n\ntype MorphContextValue = {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  triggerId: string;\n  contentId: string;\n  /** The element the panel measures against — see `registerTrigger`. */\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  registerTrigger: (node: HTMLElement | null) => void;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\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 <MorphPopover>`);\n  return ctx;\n}\n\nexport interface MorphPopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  className?: string;\n}\n\n/**\n * A popover whose panel morphs open from the trigger corner: it's laid out at\n * full size but clipped to the corner nearest the trigger, then unclips as one\n * piece. Closes on outside pointer / Escape. Controlled or uncontrolled.\n */\nexport function MorphPopover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n}: MorphPopoverProps) {\n  const baseId = useId();\n  const [root, setRoot] = useState<HTMLDivElement | null>(null);\n  const [trigger, setTrigger] = useState<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  // A trigger normally registers itself through MorphPopoverTrigger. It can't\n  // when something else already clones the element — a Tooltip wrapping the\n  // button, say — and an unregistered trigger leaves the panel with nothing to\n  // measure against, so it renders permanently invisible. The root boxes the\n  // trigger exactly (the content portals out of it), so it stands in until a\n  // real trigger registers, and stands in again if that one unmounts. Both are\n  // state, so a trigger arriving while the panel is open re-anchors it.\n  const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(\n    () => ({ current: trigger ?? root }),\n    [root, trigger],\n  );\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: a dismissal hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded. When no trigger has\n  // registered, the root anchor stands in only if it can actually hold focus;\n  // there is nowhere better than where the keyboard already is, so leave it.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (!inPanel) return;\n    const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);\n    restore?.focus();\n  }, [root, setOpen, trigger]);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && close();\n    const onPointer = (e: PointerEvent) => {\n      const target = e.target as Node;\n      if (\n        root &&\n        !root.contains(target) &&\n        !contentRef.current?.contains(target)\n      )\n        close();\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, root, close]);\n\n  const ctx = useMemo<MorphContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      triggerId: `${baseId}-trigger`,\n      contentId: `${baseId}-content`,\n      triggerRef: anchorRef,\n      registerTrigger: setTrigger,\n      contentRef,\n    }),\n    [open, setOpen, toggle, baseId, anchorRef],\n  );\n\n  return (\n    <MorphContext.Provider value={ctx}>\n      <div ref={setRoot} className={cn(\"relative inline-flex\", className)}>\n        {children}\n      </div>\n    </MorphContext.Provider>\n  );\n}\n\nexport interface MorphPopoverTriggerProps {\n  children: ReactElement;\n}\n\nfunction 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 React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\n/** Wraps a single element, toggling the popover on click. */\nexport function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {\n  const ctx = useMorphContext(\"MorphPopoverTrigger\");\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childOnClick = child?.props?.onClick as\n    | ((e: unknown) => void)\n    | undefined;\n  const childRef = (child?.props as { ref?: Ref<HTMLElement> } | undefined)\n    ?.ref;\n  // Register once per actual ref change, not once per open-state render.\n  const mergedRef = useMemo(\n    () => mergeRefs(childRef, ctx.registerTrigger),\n    [childRef, ctx.registerTrigger],\n  );\n  if (!isValidElement(children)) return children;\n\n  return cloneElement(child, {\n    id: ctx.triggerId,\n    ref: mergedRef,\n    onClick: (e: unknown) => {\n      childOnClick?.(e);\n      ctx.toggle();\n    },\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n  });\n}\n\nconst originFor = (side: Side, align: Align) =>\n  `${side === \"bottom\" ? \"top\" : \"bottom\"} ${align === \"end\" ? \"right\" : \"left\"}`;\n\n// A clip that hides everything but the corner nearest the trigger, so the\n// panel appears to grow out of it. inset(top right bottom left).\nfunction clipAt(side: Side, align: Align, radius: number, inset: number) {\n  const top = side === \"bottom\" ? \"0%\" : `${inset}%`;\n  const bottom = side === \"bottom\" ? `${inset}%` : \"0%\";\n  const right = align === \"end\" ? \"0%\" : `${inset}%`;\n  const left = align === \"end\" ? `${inset}%` : \"0%\";\n  return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;\n}\n\n// Preserve the original spring character on the wrapper, but tween the complex\n// clip-path so it cannot snap when the spring resolves its final distance.\nconst MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;\n\nexport interface MorphPopoverContentProps {\n  children: ReactNode;\n  side?: Side;\n  align?: Align;\n  /** Gap between trigger and panel, in px. Default 8. */\n  sideOffset?: number;\n  /** Panel corner radius, in px. Default 16. */\n  radius?: number;\n  /** Draw the surface shadow. Default true. */\n  shadow?: boolean;\n  /** Runs after the portalled surface is positioned and visible. */\n  onOpenAutoFocus?: (content: HTMLDivElement) => void;\n  /** Override the panel landmark. Menus pass \"menu\" instead of the dialog default. */\n  role?: \"dialog\" | \"menu\";\n  className?: string;\n}\n\nexport function MorphPopoverContent(props: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const [portalReady, setPortalReady] = useState(false);\n  useEffect(() => setPortalReady(true), []);\n  if (!portalReady) return null;\n  return createPortal(\n    <AnimatePresence>\n      {ctx.open && <MorphPopoverSurface {...props} />}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\n// Measurement belongs to the mounted portal session: reopening must not start\n// an entrance at the previous session's coordinates before measuring this one.\nfunction MorphPopoverSurface({\n  children,\n  side = \"bottom\",\n  align = \"end\",\n  sideOffset = 8,\n  radius = 16,\n  shadow = true,\n  onOpenAutoFocus,\n  role = \"dialog\",\n  className,\n}: MorphPopoverContentProps) {\n  const ctx = useMorphContext(\"MorphPopoverContent\");\n  const reduce = useReducedMotion() ?? false;\n  const [isPresent, safeToRemove] = usePresence();\n  const layout = usePopoverPortalPosition(\n    ctx.triggerRef,\n    ctx.contentRef,\n    isPresent,\n  );\n\n  const preferredLeft = layout\n    ? align === \"end\"\n      ? layout.trigger.left + layout.trigger.width - layout.content.width\n      : layout.trigger.left\n    : 0;\n  // Larger compositions, such as calendars, use the roomier side if needed.\n  const gutter = 8;\n  const below = layout\n    ? Math.max(0, window.innerHeight - layout.trigger.top - layout.trigger.height - sideOffset - gutter)\n    : 0;\n  const above = layout ? Math.max(0, layout.trigger.top - sideOffset - gutter) : 0;\n  const requestedSpace = side === \"bottom\" ? below : above;\n  const otherSpace = side === \"bottom\" ? above : below;\n  const resolvedSide = layout && layout.content.height > requestedSpace && otherSpace > requestedSpace\n    ? side === \"bottom\" ? \"top\" : \"bottom\"\n    : side;\n  const availableHeight = resolvedSide === \"bottom\" ? below : above;\n  const left = layout\n    ? Math.max(gutter, Math.min(preferredLeft, window.innerWidth - layout.content.width - gutter))\n    : 0;\n  const top = layout\n    ? resolvedSide === \"bottom\"\n      ? layout.trigger.top + layout.trigger.height + sideOffset\n      : Math.max(gutter, layout.trigger.top - layout.content.height - sideOffset)\n    : 0;\n\n  // Both directions travel between the exact same hidden/show states. Exit\n  // targets \"hidden\" directly instead of introducing separate choreography.\n  const wrap = reduce\n    ? undefined\n    : {\n        hidden: { scale: 0.96, transition: SPRING_PANEL },\n        show: { scale: 1, transition: SPRING_PANEL },\n      };\n  const clip = reduce\n    ? undefined\n    : {\n        hidden: {\n          clipPath: clipAt(resolvedSide, align, radius, 92),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n        show: {\n          clipPath: clipAt(resolvedSide, align, radius, 0),\n          transition: MORPH_CLIP_TRANSITION,\n        },\n      };\n  // Animate the value directly so opacity stays in the inline style throughout\n  // the entrance. A native opacity animation can expose the initial inline 0\n  // for a frame when it finishes, before Motion writes the final value.\n  const opacity = useMotionValue(0);\n  const ready = layout !== null;\n  const focusedOnOpen = useRef(false);\n  useEffect(() => {\n    if (!ready || !isPresent || focusedOnOpen.current || !ctx.contentRef.current) return;\n    focusedOnOpen.current = true;\n    onOpenAutoFocus?.(ctx.contentRef.current);\n  }, [ready, isPresent, ctx.contentRef, onOpenAutoFocus]);\n  useEffect(() => {\n    if (!ready) {\n      if (!isPresent) safeToRemove?.();\n      return;\n    }\n    const animation = animate(opacity, isPresent ? 1 : 0, {\n      ...(reduce ? { duration: 0.12 } : SPRING_PANEL),\n      onComplete: () => {\n        if (!isPresent) safeToRemove?.();\n      },\n    });\n    return () => animation.stop();\n  }, [opacity, ready, isPresent, reduce, safeToRemove]);\n\n  return (\n    <motion.div\n      data-morph-popover-portal=\"\"\n      inert={!isPresent}\n      // Wrapper carries the shadow as a drop-shadow filter, which hugs the\n      // clipped shape below (box-shadow would just get clipped away).\n      variants={wrap}\n      initial=\"hidden\"\n      animate={layout ? \"show\" : \"hidden\"}\n      exit=\"hidden\"\n      style={{\n        left,\n        top,\n        opacity,\n        pointerEvents: isPresent ? \"auto\" : \"none\",\n        visibility: layout ? \"visible\" : \"hidden\",\n        transformOrigin: originFor(resolvedSide, align),\n      }}\n      className={cn(\"fixed z-[9999]\", shadow && \"[filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]\")}\n    >\n      <motion.div\n        ref={ctx.contentRef}\n        id={ctx.contentId}\n        role={role}\n        aria-labelledby={ctx.triggerId}\n        variants={clip}\n        style={{ borderRadius: radius, maxHeight: layout ? availableHeight : undefined, overflowY: \"auto\" }}\n        className={cn(\n          \"overflow-hidden border border-border bg-background\",\n          className,\n        )}\n      >\n        {children}\n      </motion.div>\n    </motion.div>\n  );\n}\n"
    },
    {
      "path": "lib/ease.ts",
      "type": "util",
      "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": "lib/hooks/use-on-open.ts",
      "type": "util",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\n\n/**\n * Runs `start` during the render in which `open` becomes true.\n *\n * Opening a list starts a fresh session — an empty query, the highlight back at\n * the top — and that is a resolution, not a side effect: a passive effect runs\n * after the commit, so the new session would carry the last one's state for a\n * window in which a key press can land.\n *\n * `start` may only set state belonging to the calling component. Anything that\n * reaches outside it — a consumer's callback, a DOM write, moving focus — is a\n * side effect, and React rejects a callback that sets another component's state\n * during this one's render. Put those in an effect keyed to `open`.\n */\nexport function useOnOpen(open: boolean, start: () => void) {\n  const [wasOpen, setWasOpen] = useState(open);\n  if (open !== wasOpen) {\n    setWasOpen(open);\n    if (open) start();\n  }\n}\n"
    },
    {
      "path": "lib/hooks/use-row-cursor.ts",
      "type": "util",
      "content": "\"use client\";\n\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\";\n\n/**\n * Where the keyboard or the pointer last moved to: the row's id, stamped with\n * the query it was placed under.\n */\ntype RowCursor = { id: string; query: string };\n\n/** The cursor's row, or -1 once the query has moved on or the row has left. */\nfunction indexOfCursor(\n  rows: readonly { id: string }[],\n  query: string,\n  cursor: RowCursor | null,\n) {\n  if (cursor === null || cursor.query !== query) return -1;\n  return rows.findIndex((row) => row.id === cursor.id);\n}\n\n/**\n * The highlighted row of a list whose rows can change under it.\n *\n * Which row is highlighted is resolved during render, never in a passive\n * effect: a passive effect runs after the commit, so a list that had just\n * changed would carry an `aria-activedescendant` naming a row that has left it,\n * and a key pressed in that window would commit the wrong row or nothing at\n * all.\n *\n * The cursor holds the row's id, not its position. A position alone cannot tell\n * a list that shrank from one that swapped its rows for a different set of the\n * same length, and the second case is the one that silently hands Enter to a\n * row the user never chose. A cursor whose row has left the list returns the\n * highlight to the first row rather than to the nearest surviving one: the row\n * the user aimed at is gone, and the first row is where a new query already\n * puts the highlight.\n *\n * Pass the query the list is filtered by. The cursor is stamped with it and\n * dropped when it changes, so a caller cannot forget to clear it — including a\n * caller whose query arrives as a prop and so never runs its own handler. Rows\n * that come back under a query that has moved on cannot revive it either.\n * `moveTo(null)` is for deliberate resets, such as reopening the list.\n *\n * Both callbacks keep one identity for the life of the component, and read the\n * rows and the query through a ref to do it. A caller will put them in an\n * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`\n * rebuilt on every keystroke would re-run that effect on every keystroke.\n */\nexport function useRowCursor(rows: readonly { id: string }[], query: string) {\n  const [cursor, setCursor] = useState<RowCursor | null>(null);\n  // Written after commit, not during render: a render React discards or has not\n  // finished still runs the component body, and an event handler that read this\n  // in that window would stamp the cursor with a query the committed tree does\n  // not have.\n  const latest = useRef({ rows, query });\n  useLayoutEffect(() => {\n    latest.current = { rows, query };\n  });\n\n  const cursorRow = indexOfCursor(rows, query, cursor);\n  // Cleared rather than ignored: React re-runs this render with the cursor\n  // already gone, so rows that come back cannot revive a highlight the user has\n  // stopped aiming at.\n  if (cursor !== null && cursorRow < 0) setCursor(null);\n\n  const moveTo = useCallback(\n    (id: string | null) =>\n      setCursor(id === null ? null : { id, query: latest.current.query }),\n    [],\n  );\n\n  const moveActive = useCallback((direction: 1 | -1) => {\n    const { rows: live, query: liveQuery } = latest.current;\n    const last = live.length - 1;\n    if (last < 0) return;\n    // Steps from the row the cursor is really on, inside the update, so that\n    // two keys landing in one batch move two rows rather than one.\n    setCursor((current) => {\n      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);\n      const next = Math.min(Math.max(at + direction, 0), last);\n      return { id: live[next].id, query: liveQuery };\n    });\n  }, []);\n\n  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };\n}\n"
    },
    {
      "path": "lib/utils.ts",
      "type": "util",
      "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": "components/motion/popover-position.ts",
      "type": "util",
      "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"
    },
    {
      "path": "components/previews/blocks/selector.preview.tsx",
      "type": "preview",
      "content": "\"use client\";\n\nimport { Bot, Brain, Building2, Circle, Sparkles } from \"lucide-react\";\nimport { useState } from \"react\";\nimport { Selector } from \"@/components/motion/selector\";\nimport type { SelectorOption } from \"@/components/motion/selector\";\n\nconst MODELS: SelectorOption[] = [\n  {\n    id: \"claude-sonnet\",\n    name: \"Claude Sonnet\",\n    group: \"Anthropic\",\n    description: \"Balanced reasoning and everyday coding\",\n    metadata: \"200K\",\n    keywords: [\"reasoning\", \"vision\", \"tools\"],\n    icon: <Brain className=\"size-4\" />,\n    details: (\n      <div className=\"space-y-2\">\n        <p className=\"font-medium\">Claude Sonnet</p>\n        <p className=\"text-sm text-muted-foreground\">\n          A balanced choice for writing, analysis, and everyday coding. Supports\n          images and tools with a 200K-token context window.\n        </p>\n      </div>\n    ),\n  },\n  {\n    id: \"claude-opus\",\n    name: \"Claude Opus\",\n    group: \"Anthropic\",\n    description: \"Deep reasoning for complex work\",\n    metadata: \"200K\",\n    keywords: [\"reasoning\", \"analysis\"],\n    icon: <Brain className=\"size-4\" />,\n  },\n  {\n    id: \"gpt-4o\",\n    name: \"GPT-4o\",\n    group: \"OpenAI\",\n    description: \"Fast, multimodal conversations\",\n    metadata: \"128K\",\n    keywords: [\"vision\", \"tools\"],\n    icon: <Sparkles className=\"size-4\" />,\n  },\n  {\n    id: \"gpt-4.1\",\n    name: \"GPT-4.1\",\n    group: \"OpenAI\",\n    description: \"Precise instructions and a larger context\",\n    metadata: \"1M\",\n    keywords: [\"coding\", \"tools\"],\n    icon: <Sparkles className=\"size-4\" />,\n  },\n  {\n    id: \"gemini-pro\",\n    name: \"Gemini Pro\",\n    group: \"Google\",\n    description: \"Reason across documents, images, and code\",\n    metadata: \"1M\",\n    keywords: [\"vision\", \"reasoning\"],\n    icon: <Bot className=\"size-4\" />,\n  },\n];\n\nexport function SelectorPreview() {\n  const [model, setModel] = useState(\"claude-sonnet\");\n\n  return (\n    <div className=\"w-full max-w-md space-y-4\">\n      <p className=\"font-medium text-sm\">Choose your model</p>\n      <Selector\n        options={MODELS}\n        value={model}\n        onValueChange={setModel}\n        triggerVariant=\"pill\"\n        label=\"Models\"\n        searchPlaceholder=\"Search models…\"\n      />\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        Using {MODELS.find((option) => option.id === model)?.name}.\n      </p>\n      <p className=\"text-xs text-muted-foreground\">\n        Search by model, provider, or capability. Hover or focus Claude Sonnet\n        for details. Customize the trigger and panel with classNames.\n      </p>\n    </div>\n  );\n}\n\nconst PEOPLE: SelectorOption[] = [\n  { id: \"ava\", name: \"Ava Thompson\", description: \"ava@studio.co\", metadata: \"Design\", group: \"Your team\" },\n  { id: \"noah\", name: \"Noah Williams\", description: \"noah@studio.co\", metadata: \"Engineering\", group: \"Your team\" },\n  { id: \"mia\", name: \"Mia Chen\", description: \"mia@studio.co\", metadata: \"Product\", group: \"Your team\" },\n  { id: \"leo\", name: \"Leo Martin\", description: \"leo@partner.co\", metadata: \"Guest\", group: \"Collaborators\" },\n].map((person) => ({\n  ...person,\n  icon: (\n    <span className=\"flex size-7 items-center justify-center rounded-full border bg-muted text-[10px] font-medium\">\n      {person.name.split(\" \").map((part) => part[0]).join(\"\")}\n    </span>\n  ),\n}));\n\nexport function SelectorPeopleSinglePreview() {\n  const [person, setPerson] = useState(\"\");\n\n  return (\n    <div className=\"w-full max-w-md space-y-4\">\n      <div className=\"space-y-1\">\n        <p className=\"font-medium text-sm\">Choose an assignee</p>\n        <p className=\"text-xs text-muted-foreground\">\n          Search by name or email and choose one person responsible for the task.\n        </p>\n      </div>\n      <Selector\n        options={PEOPLE}\n        value={person}\n        onValueChange={setPerson}\n        placeholder=\"Assign to someone\"\n        searchPlaceholder=\"Search people…\"\n        label=\"Assignee\"\n      />\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        {person ? `Assigned to ${PEOPLE.find((option) => option.id === person)?.name}.` : \"No assignee yet.\"}\n      </p>\n    </div>\n  );\n}\n\nexport function SelectorPeoplePreview() {\n  const [people, setPeople] = useState<string[]>([\"ava\", \"mia\"]);\n\n  return (\n    <div className=\"w-full max-w-md space-y-4\">\n      <div className=\"space-y-1\">\n        <p className=\"font-medium text-sm\">Share with your team</p>\n        <p className=\"text-xs text-muted-foreground\">\n          Search names or emails, then select everyone who needs access.\n        </p>\n      </div>\n      <Selector\n        multiple\n        options={PEOPLE}\n        value={people}\n        onValueChange={setPeople}\n        triggerVariant=\"field\"\n        placeholder=\"Add people\"\n        searchPlaceholder=\"Search people…\"\n        label=\"People\"\n      />\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        {people.length === 0\n          ? \"Only you have access.\"\n          : `Shared with ${people.length} ${people.length === 1 ? \"person\" : \"people\"}.`}\n      </p>\n      <p className=\"text-xs text-muted-foreground\">\n        Remove a chip to revoke access, or choose Done when finished.\n      </p>\n    </div>\n  );\n}\n\nconst WORKSPACES: SelectorOption[] = [\n  {\n    id: \"studio\",\n    name: \"Studio\",\n    description: \"Pro plan · 12 members\",\n    group: \"Team workspaces\",\n    icon: <Building2 className=\"size-4\" />,\n  },\n  {\n    id: \"engineering\",\n    name: \"Engineering\",\n    description: \"Pro plan · 8 members\",\n    group: \"Team workspaces\",\n    icon: <Building2 className=\"size-4\" />,\n  },\n  {\n    id: \"personal\",\n    name: \"Personal\",\n    description: \"Free plan · Just you\",\n    group: \"Personal workspaces\",\n    icon: <Circle className=\"size-4\" />,\n  },\n];\n\nexport function SelectorWorkspacePreview() {\n  const [options, setOptions] = useState(WORKSPACES);\n  const [workspace, setWorkspace] = useState(\"studio\");\n\n  function createWorkspace() {\n    const number = options.length - WORKSPACES.length + 1;\n    const id = `new-workspace-${number}`;\n    setOptions([\n      ...options,\n      {\n        id,\n        name: `New workspace ${number}`,\n        description: \"Free plan · Just you\",\n        group: \"Team workspaces\",\n        icon: <Building2 className=\"size-4\" />,\n      },\n    ]);\n    setWorkspace(id);\n  }\n\n  return (\n    <div className=\"w-full max-w-md space-y-4\">\n      <div className=\"space-y-1\">\n        <p className=\"font-medium text-sm\">Your work, in one place</p>\n        <p className=\"text-xs text-muted-foreground\">\n          Switch between team and personal workspaces.\n        </p>\n      </div>\n      <Selector\n        options={options}\n        value={workspace}\n        onValueChange={setWorkspace}\n        triggerVariant=\"workspace\"\n        searchPlaceholder=\"Find a workspace…\"\n        label=\"Workspaces\"\n        action={{ label: \"Create workspace\", onSelect: createWorkspace }}\n      />\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        Current workspace: {options.find((option) => option.id === workspace)?.name}.\n      </p>\n      <p className=\"text-xs text-muted-foreground\">\n        Create workspace adds a new workspace and switches to it immediately.\n        New workspaces live in this demo until the page is refreshed.\n      </p>\n    </div>\n  );\n}\n\nfunction TemplateThumbnail({ variant }: { variant: \"dashboard\" | \"editorial\" | \"board\" | \"portfolio\" }) {\n  return (\n    <div aria-hidden=\"true\" className=\"flex aspect-[16/10] w-full overflow-hidden rounded-lg border bg-background p-2 text-foreground\">\n      {variant === \"dashboard\" ? (\n        <>\n          <div className=\"mr-2 w-1/5 space-y-2 border-r pr-2\">\n            <div className=\"h-2 w-3 rounded-sm bg-foreground/60\" />\n            {[0, 1, 2, 3].map((item) => <div key={item} className=\"h-1 rounded bg-foreground/15\" />)}\n          </div>\n          <div className=\"flex flex-1 flex-col gap-2\">\n            <div className=\"h-1.5 w-1/2 rounded bg-foreground/40\" />\n            <div className=\"grid grid-cols-3 gap-1\">\n              {[0, 1, 2].map((item) => <div key={item} className=\"h-5 rounded border bg-foreground/5\" />)}\n            </div>\n            <div className=\"flex flex-1 items-end gap-1 rounded border p-1\">\n              {[30, 55, 40, 75, 60, 90, 70].map((height) => <div key={height} className=\"flex-1 rounded-t-sm bg-foreground/25\" style={{ height: `${height}%` }} />)}\n            </div>\n          </div>\n        </>\n      ) : variant === \"editorial\" ? (\n        <div className=\"flex w-full flex-col gap-2\">\n          <div className=\"h-1 w-full border-b\" />\n          <div className=\"mx-auto h-2 w-2/3 rounded-sm bg-foreground/60\" />\n          <div className=\"mx-auto h-1 w-1/3 rounded bg-foreground/20\" />\n          <div className=\"flex flex-1 gap-2\">\n            <div className=\"w-1/2 rounded bg-foreground/10\" />\n            <div className=\"flex-1 space-y-1.5 pt-1\">\n              {[0, 1, 2, 3, 4].map((item) => <div key={item} className=\"h-1 rounded bg-foreground/15 last:w-2/3\" />)}\n            </div>\n          </div>\n        </div>\n      ) : variant === \"board\" ? (\n        <div className=\"flex w-full flex-col gap-2\">\n          <div className=\"h-1.5 w-1/3 rounded bg-foreground/50\" />\n          <div className=\"grid flex-1 grid-cols-3 gap-1.5\">\n            {[2, 3, 1].map((count) => (\n              <div key={count} className=\"space-y-1 rounded bg-foreground/5 p-1\">\n                <div className=\"mb-2 h-1 w-2/3 rounded bg-foreground/25\" />\n                {[\"first\", \"second\", \"third\"].slice(0, count).map((card) => <div key={card} className=\"h-4 rounded-sm border bg-background\" />)}\n              </div>\n            ))}\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex w-full flex-col gap-2\">\n          <div className=\"flex justify-between\"><div className=\"h-1.5 w-6 rounded bg-foreground/50\" /><div className=\"h-1 w-8 rounded bg-foreground/15\" /></div>\n          <div className=\"mt-1 h-2 w-2/3 rounded-sm bg-foreground/60\" />\n          <div className=\"h-1 w-1/2 rounded bg-foreground/20\" />\n          <div className=\"grid flex-1 grid-cols-2 gap-2\"><div className=\"rounded bg-foreground/10\" /><div className=\"rounded bg-foreground/20\" /></div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst RESOURCES: SelectorOption[] = [\n  { id: \"dashboard\", name: \"Dashboard\", description: \"Metrics at a glance\", metadata: \"12 blocks\", preview: <TemplateThumbnail variant=\"dashboard\" />, keywords: [\"analytics\", \"charts\"] },\n  { id: \"editorial\", name: \"Editorial\", description: \"Stories worth reading\", metadata: \"8 blocks\", preview: <TemplateThumbnail variant=\"editorial\" />, keywords: [\"blog\", \"writing\"] },\n  { id: \"board\", name: \"Project board\", description: \"A little more organized\", metadata: \"6 blocks\", preview: <TemplateThumbnail variant=\"board\" />, keywords: [\"kanban\", \"tasks\"] },\n  { id: \"portfolio\", name: \"Portfolio\", description: \"Make your work the focus\", metadata: \"10 blocks\", preview: <TemplateThumbnail variant=\"portfolio\" />, keywords: [\"gallery\", \"personal\"] },\n];\n\nexport function SelectorResourcesPreview() {\n  const [resource, setResource] = useState(\"dashboard\");\n\n  return (\n    <div className=\"w-full max-w-md space-y-4\">\n      <div className=\"space-y-1\">\n        <p className=\"font-medium text-sm\">Start with a template</p>\n        <p className=\"text-xs text-muted-foreground\">\n          Browse the previews or search for a layout that fits your next project.\n        </p>\n      </div>\n      <Selector\n        options={RESOURCES}\n        value={resource}\n        onValueChange={setResource}\n        layout=\"grid\"\n        triggerVariant=\"field\"\n        classNames={{ content: \"w-[30rem]\", list: \"max-h-[26rem]\" }}\n        label=\"Templates\"\n        searchPlaceholder=\"Search templates…\"\n      />\n      <p aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        Selected template: {RESOURCES.find((option) => option.id === resource)?.name}.\n      </p>\n    </div>\n  );\n}\n"
    }
  ]
}
