"use client"; import { Check, ChevronDown, Search, X } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger, } from "@/components/motion/popover-morph"; import { SPRING_PRESS } from "@/lib/ease"; import { useOnOpen } from "@/lib/hooks/use-on-open"; import { useRowCursor } from "@/lib/hooks/use-row-cursor"; import { cn } from "@/lib/utils"; export type SelectorOption = { id: string; name: string; description?: string; group?: string; metadata?: string; keywords?: string[]; icon?: ReactNode; preview?: ReactNode; details?: ReactNode; disabled?: boolean; }; /** Optional Tailwind overrides, merged after each slot's defaults. */ export type SelectorClassNames = { trigger?: string; content?: string; list?: string; option?: string; icon?: string; search?: string; details?: string; }; type SharedSelectorProps = { options: SelectorOption[]; defaultOpen?: boolean; placeholder?: string; searchPlaceholder?: string; label?: string; triggerVariant?: "pill" | "field" | "workspace"; layout?: "list" | "grid"; action?: { label: string; onSelect: () => void }; className?: string; /** Style individual parts independently; className styles the outer wrapper. */ classNames?: SelectorClassNames; }; export type SelectorProps = SharedSelectorProps & ( | { multiple?: false; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; } | { multiple: true; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void; } ); const SCROLLBAR_CLASS = "[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"; export function Selector(props: SelectorProps) { const { options, defaultOpen = false, placeholder = "Select an option", searchPlaceholder = "Search options", label = "Options", triggerVariant = "field", layout = "list", action, className, classNames, } = props; const uid = useId(); const reduce = useReducedMotion(); const [internalValue, setInternalValue] = useState(() => props.defaultValue ?? (props.multiple ? [] : options.find((option) => !option.disabled)?.id ?? ""), ); const currentValue = props.value ?? internalValue; const latestValue = useRef(currentValue); useLayoutEffect(() => { latestValue.current = currentValue; }); const selectedIds = new Set(Array.isArray(currentValue) ? currentValue : [currentValue]); const selectedOptions = options.filter((option) => !option.disabled && selectedIds.has(option.id)); const selected = selectedOptions[0]; const [open, setOpen] = useState(defaultOpen); const [query, setQuery] = useState(""); const listRef = useRef(null); const triggerRef = useRef(null); const inputRef = useRef(null); const detailRef = useRef(null); const detailAnchor = useRef(null); const detailTimer = useRef(undefined); const touchInteraction = useRef(false); const [detailId, setDetailId] = useState(null); const [detailPosition, setDetailPosition] = useState({ top: 12, left: 12 }); const groups = useMemo(() => { const result = new Map(); const search = query.trim().toLowerCase(); for (const option of options) { if (![option.name, option.description, option.group, option.metadata, ...(option.keywords ?? [])] .filter(Boolean).join(" ").toLowerCase().includes(search)) continue; const group = option.group ?? ""; const items = result.get(group) ?? []; items.push(option); result.set(group, items); } return Array.from(result, ([label, items]) => ({ label, items })); }, [options, query]); const rows = useMemo(() => groups.flatMap((group) => group.items).filter((option) => !option.disabled), [groups]); const { activeIndex, moveTo, moveActive } = useRowCursor(rows, query); const active = rows[activeIndex]; const detail = open ? rows.find((option) => option.id === detailId && option.details != null) : undefined; const clearDetailTimer = () => { window.clearTimeout(detailTimer.current); detailTimer.current = undefined; }; const hideDetail = () => { clearDetailTimer(); setDetailId(null); }; const scheduleDetailClose = () => { clearDetailTimer(); detailTimer.current = window.setTimeout(() => setDetailId(null), 100); }; const showDetail = (option: SelectorOption, anchor: HTMLElement) => { clearDetailTimer(); if (option.disabled || option.details == null || touchInteraction.current) { setDetailId(null); return; } if (detailId === option.id) return; setDetailId(null); detailTimer.current = window.setTimeout(() => { detailAnchor.current = anchor; setDetailId(option.id); }, 1500); }; useEffect(() => { if (!open || rows.length === 0) window.clearTimeout(detailTimer.current); return () => window.clearTimeout(detailTimer.current); }, [open, rows]); useLayoutEffect(() => { if (!detail) return; const place = () => { const rect = detailAnchor.current?.getBoundingClientRect(); const card = detailRef.current; if (!rect || !card) return; const rightSpace = window.innerWidth - rect.right - 20; const leftSpace = rect.left - 20; const left = rightSpace >= card.offsetWidth || rightSpace >= leftSpace ? rect.right + 8 : rect.left - card.offsetWidth - 8; setDetailPosition({ left: Math.max(12, Math.min(left, window.innerWidth - card.offsetWidth - 12)), top: Math.max(12, Math.min(rect.top, window.innerHeight - card.offsetHeight - 12)), }); }; place(); const observer = new ResizeObserver(place); if (detailRef.current) observer.observe(detailRef.current); window.addEventListener("resize", place); window.addEventListener("scroll", place, true); return () => { observer.disconnect(); window.removeEventListener("resize", place); window.removeEventListener("scroll", place, true); }; }, [detail]); useOnOpen(open, () => { setQuery(""); setDetailId(null); moveTo(selected?.id ?? null); }); useEffect(() => { if (!open) return; const frame = requestAnimationFrame(() => { listRef.current?.querySelector(`[data-index="${activeIndex}"]`) ?.scrollIntoView({ block: "nearest" }); }); return () => cancelAnimationFrame(frame); }, [open, activeIndex]); const changeOpen = (next: boolean) => { setOpen(next); if (!next) { hideDetail(); setQuery(""); } }; const close = () => { changeOpen(false); triggerRef.current?.focus(); }; const select = (option: SelectorOption) => { if (option.disabled) return; if (props.multiple) { const liveValue = props.value ?? latestValue.current; const liveIds = Array.isArray(liveValue) ? liveValue : []; const enabledIds = liveIds.filter((id) => options.some((item) => item.id === id && !item.disabled)); const next = enabledIds.includes(option.id) ? enabledIds.filter((id) => id !== option.id) : [...enabledIds, option.id]; if (props.value === undefined) { latestValue.current = next; setInternalValue(next); } props.onValueChange?.(next); hideDetail(); } else { if (props.value === undefined) setInternalValue(option.id); props.onValueChange?.(option.id); close(); } }; const onKeyDown = (event: KeyboardEvent) => { if (event.nativeEvent.isComposing) return; touchInteraction.current = false; if (event.key !== "Tab") hideDetail(); if (event.key === "Escape") { event.preventDefault(); close(); } else if (event.target !== inputRef.current && !(event.target instanceof HTMLElement && event.target.getAttribute("role") === "option")) { return; } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); moveActive(event.key === "ArrowDown" ? 1 : -1); } else if (event.key === "PageDown" || event.key === "PageUp") { event.preventDefault(); for (let step = 0; step < 12; step++) moveActive(event.key === "PageDown" ? 1 : -1); } else if (event.key === "Enter" && active) { event.preventDefault(); select(active); } }; return (
{props.multiple && selectedOptions.length ? (
{selectedOptions.map((option) => ( {option.name} ))}
) : null}
inputRef.current?.focus()} className={cn("flex w-[22rem] max-w-[calc(100vw-24px)] flex-col bg-background text-sm text-foreground", classNames?.content)}>
{ 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" /> {query ? : ESC}
{groups.length ? groups.map((group) => (
{group.label ? {group.label} : null}
{group.items.map((option) => { const index = rows.indexOf(option); const isActive = active?.id === option.id; const isSelected = !option.disabled && selectedIds.has(option.id); return ( ); })}
)) :

No options found.

}
{props.multiple ? {selectedOptions.length} selected : <>↑↓ Navigate↵ Select}
{action ? : null} {props.multiple ? : null}
{detail ? createPortal( , document.body, ) : null}
); }