"use client"; import { useEffect, useId, useRef, useState } from "react"; const field = "block w-full rounded-md bg-surface-1 border border-border " + "text-fg placeholder:text-fg-subtle " + "focus:border-accent-400 focus:outline-none " + "disabled:opacity-50 transition-colors h-9 px-3 text-sm"; /** * Type-ahead project filter. A text input whose value submits as `name` * (default "project") via the enclosing GET form, plus a dropdown of the * projects the user can read that narrows as they type. Selecting a * suggestion fills the box and submits the form so the filter applies * immediately; free text is still allowed (the input value is what * submits), so an arbitrary key keeps working even if it isn't listed. */ export function ProjectCombobox({ name = "project", defaultValue = "", options, className = "", }: { name?: string; defaultValue?: string; options: string[]; className?: string; }) { const [value, setValue] = useState(defaultValue); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const rootRef = useRef(null); const inputRef = useRef(null); const listId = useId(); // Case-insensitive substring match. An empty box shows the full list so // the control doubles as a "browse my projects" dropdown. const q = value.trim().toLowerCase(); const matches = q ? options.filter((o) => o.toLowerCase().includes(q)) : options; // Close when focus/click leaves the widget. useEffect(() => { if (!open) return; function onPointerDown(e: PointerEvent) { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); } document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); }, [open]); function commit(next: string) { setValue(next); setOpen(false); // Write the DOM value synchronously before submitting: setValue only // schedules a re-render (React batches it), so the input's serialized // value would still be the pre-selection text when requestSubmit reads // it. The upcoming render sets the same value, so there's no flicker. if (inputRef.current) inputRef.current.value = next; // requestSubmit fires a real submit (unlike form.submit()). inputRef.current?.form?.requestSubmit(); } function onKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); if (!open) { setOpen(true); setActive(0); } else { setActive((i) => Math.min(i + 1, matches.length - 1)); } } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { // Only intercept Enter to pick a highlighted suggestion; otherwise // let it fall through and submit the form with the typed value. if (open && matches[active]) { e.preventDefault(); commit(matches[active]); } } else if (e.key === "Escape") { if (open) { e.preventDefault(); setOpen(false); } } } const showList = open && matches.length > 0; return (
{ setValue(e.target.value); setOpen(true); setActive(0); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} /> {showList ? (
    {matches.map((opt, i) => (
  • { e.preventDefault(); commit(opt); }} onMouseEnter={() => setActive(i)} > {opt}
  • ))}
) : null}
); }