188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
"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<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(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 (
|
|
<div ref={rootRef} className="relative">
|
|
<button
|
|
type="button"
|
|
id={id}
|
|
aria-label={ariaLabel}
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
onClick={toggle}
|
|
onKeyDown={onKeyDown}
|
|
className={cn(
|
|
"w-full h-10 flex items-center justify-between gap-2 rounded-md border border-border bg-background px-3 text-sm text-left",
|
|
"outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:opacity-50",
|
|
)}
|
|
>
|
|
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
|
{selected ? selected.label : placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="size-4 shrink-0 opacity-50" />
|
|
</button>
|
|
|
|
{open ? (
|
|
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-border bg-card shadow-lg animate-in fade-in-0 zoom-in-95">
|
|
{searchable ? (
|
|
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
|
<input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => {
|
|
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"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<ul role="listbox" className="max-h-56 overflow-y-auto py-1">
|
|
{filtered.length === 0 ? (
|
|
<li className="px-3 py-2 text-sm text-muted-foreground">
|
|
{emptyText}
|
|
</li>
|
|
) : (
|
|
filtered.map((o, i) => (
|
|
<li
|
|
key={o.value || "__empty__"}
|
|
role="option"
|
|
aria-selected={o.value === value}
|
|
onMouseEnter={() => 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" : "",
|
|
)}
|
|
>
|
|
<span className="truncate">{o.label}</span>
|
|
{o.value === value ? (
|
|
<Check className="size-4 shrink-0 text-primary" />
|
|
) : null}
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|