"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { components } from "@/lib/api/schema"; type Person = components["schemas"]["PersonRead"]; /** * A type-to-pick person picker. Two modes: * - client (`people`): filter a preloaded list in the browser. * - server (`onSearch`): query the backend (debounced) as you type — the * preferred mode for large trees, so the page doesn't * have to preload every person just to search. * Selecting one sets `value` (a person id) and fills the input with their name. */ export function PersonCombobox({ people, onSearch, value, onChange, onCreate, placeholder = "Search for a person…", className, }: { people?: Person[]; onSearch?: (q: string) => Promise; value: string; onChange: (id: string) => void; /** When set, the dropdown offers a "Create ''" action. */ onCreate?: (name: string) => void; placeholder?: string; className?: string; }) { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const wrapRef = useRef(null); // Names we've seen (from the list or search results), so a selected value // keeps displaying its name even in server mode. const known = useRef>(new Map()); const remember = useCallback((ps: Person[] | undefined) => { for (const p of ps ?? []) known.current.set(p.id, p.primary_name ?? "Unnamed"); }, []); useEffect(() => { remember(people); }, [people, remember]); const nameOf = useCallback((id: string) => known.current.get(id) ?? "", []); // Keep the input text in sync when the selection changes externally // (e.g. cleared to "" after a successful add). useEffect(() => { if (!value) setQuery(""); else if (!open) setQuery(nameOf(value)); }, [value, open, nameOf]); // Close on outside click. useEffect(() => { function onDoc(e: MouseEvent) { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); // Server search, debounced. Stale responses are dropped via `cancelled`. useEffect(() => { if (!onSearch) return; const q = query.trim(); if (!q) { setResults([]); setLoading(false); return; } setLoading(true); let cancelled = false; const t = setTimeout(async () => { try { const r = await onSearch(q); if (cancelled) return; remember(r); setResults(r); } finally { if (!cancelled) setLoading(false); } }, 160); return () => { cancelled = true; clearTimeout(t); }; }, [query, onSearch, remember]); const matches = useMemo(() => { if (onSearch) return results.slice(0, 10); const q = query.trim().toLowerCase(); const pool = q ? (people ?? []).filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : (people ?? []); return pool.slice(0, 10); }, [query, results, people, onSearch]); const base = "h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40"; const showDropdown = open && (matches.length > 0 || loading || (onCreate && query.trim())); return (
setOpen(true)} onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(""); // typing invalidates the prior pick }} /> {showDropdown && (
    {loading && matches.length === 0 && (
  • Searching…
  • )} {matches.map((p) => (
  • ))} {onCreate && query.trim() && (
  • 0 ? "border-t border-[var(--border)]" : ""}>
  • )}
)}
); }