"use client"; /** * Combobox — a shadcn-style single-select dropdown with optional search. * * Trigger button shows the selected label (or a placeholder); clicking it * opens a panel with an optional search box and a filtered, keyboard- * navigable list. Selecting an option calls `onChange(value)` and closes. * * Notes: * • All state changes happen in event handlers (click / keydown / input) * — never synchronously inside an effect — so opening can't trigger * React's "cascading renders" warning. The only effect just wires the * outside-pointerdown listener. * • `value === ""` is a valid selectable value (e.g. a "Not specified" * option), so the field can represent "cleared". * • The panel is absolutely positioned within a relative wrapper, so the * parent must not clip overflow (forms/cards here don't). */ import { useEffect, useMemo, useRef, useState } from "react"; import { Check, ChevronsUpDown, Search } from "lucide-react"; import { cn } from "@/lib/utils"; export type ComboboxOption = { value: string; label: string }; export function Combobox({ options, value, onChange, placeholder = "Select…", searchPlaceholder = "Search…", emptyText = "No results.", searchable = true, disabled = false, id, ariaLabel, }: { options: ComboboxOption[]; value: string; onChange: (value: string) => void; placeholder?: string; searchPlaceholder?: string; emptyText?: string; searchable?: boolean; disabled?: boolean; id?: string; ariaLabel?: string; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const rootRef = useRef(null); const inputRef = useRef(null); const selected = options.find((o) => o.value === value) ?? null; const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!searchable || q === "") return options; return options.filter((o) => o.label.toLowerCase().includes(q)); }, [options, query, searchable]); const active = filtered.length === 0 ? -1 : Math.min(activeIndex, filtered.length - 1); // Close on outside pointerdown. (Listener only — no setState in body.) useEffect(() => { if (!open) return; function onPointer(e: PointerEvent) { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("pointerdown", onPointer); return () => document.removeEventListener("pointerdown", onPointer); }, [open]); function openMenu() { setQuery(""); const i = options.findIndex((o) => o.value === value); setActiveIndex(i < 0 ? 0 : i); setOpen(true); if (searchable) { requestAnimationFrame(() => inputRef.current?.focus()); } } function toggle() { if (disabled) return; if (open) setOpen(false); else openMenu(); } function select(v: string) { onChange(v); setOpen(false); } function onKeyDown(e: React.KeyboardEvent) { if (!open) return; if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (active >= 0) select(filtered[active].value); } else if (e.key === "Escape") { e.preventDefault(); setOpen(false); } } return (
{open ? (
{searchable ? (
{ setQuery(e.target.value); setActiveIndex(0); }} onKeyDown={onKeyDown} placeholder={searchPlaceholder} className="w-full h-9 bg-transparent text-sm outline-none placeholder:text-muted-foreground" />
) : null}
    {filtered.length === 0 ? (
  • {emptyText}
  • ) : ( filtered.map((o, i) => (
  • setActiveIndex(i)} onClick={() => select(o.value)} className={cn( "flex items-center justify-between gap-2 px-3 py-2 text-sm cursor-pointer", i === active ? "bg-muted" : "", )} > {o.label} {o.value === value ? ( ) : null}
  • )) )}
) : null}
); }