"use client"; import { Search, X } from "lucide-react"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; type LookupProps = { placeholder?: string; onSearch: (query: string) => Promise; renderResult: (item: T) => ReactNode; onSelect: (item: T) => void; minChars?: number; id?: string; "aria-describedby"?: string; "aria-invalid"?: true; }; // Generic async search-select used by the position lookup (hire wizard), // manager/superior lookup (create position), and the reorg workbench. // // Implements the combobox contract rather than just looking like one: it was // a plain text input with a div of clickable buttons underneath, so a // keyboard user could type but never reach a result, and a screen reader was // never told a list had appeared. Arrow keys move the selection, Enter takes // it, Escape closes without also closing the surrounding dialog. export function Lookup({ placeholder = "Suchen…", onSearch, renderResult, onSelect, minChars = 2, id: idProp, "aria-describedby": describedBy, "aria-invalid": invalid, }: LookupProps) { const generatedId = useId(); const id = idProp ?? generatedId; const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); // Derived from "have we finished searching for the current query yet", // rather than a separate state flag flipped synchronously at the top of // the effect below — the effect only ever sets state from the async // search's own completion callback now. const [lastSearchedQuery, setLastSearchedQuery] = useState(null); const containerRef = useRef(null); const listRef = useRef(null); const tooShort = query.trim().length < minChars; const loading = !tooShort && lastSearchedQuery !== query.trim(); useEffect(() => { if (tooShort) return; let cancelled = false; const timeout = setTimeout(() => { onSearch(query.trim()).then((res) => { if (cancelled) return; setResults(res); setActiveIndex(0); setOpen(true); setLastSearchedQuery(query.trim()); }); }, 200); return () => { cancelled = true; clearTimeout(timeout); }; }, [query, minChars, onSearch, tooShort]); // Derived rather than reset via effect: once the query drops below // minChars, hide the dropdown and any stale results immediately without // needing a synchronous setState inside the effect above. const showDropdown = open && !tooShort; const visibleResults = tooShort ? [] : results; const selectable = loading ? [] : visibleResults; useEffect(() => { function onClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } } document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, []); useEffect(() => { if (!showDropdown) return; listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); }, [activeIndex, showDropdown]); function reset() { setQuery(""); setResults([]); setOpen(false); setActiveIndex(0); } function choose(item: T) { onSelect(item); reset(); } function onKeyDown(e: KeyboardEvent) { if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (selectable.length === 0) return; e.preventDefault(); const delta = e.key === "ArrowDown" ? 1 : -1; setActiveIndex((i) => (i + delta + selectable.length) % selectable.length); } else if (e.key === "Enter") { if (showDropdown && selectable[activeIndex]) { e.preventDefault(); choose(selectable[activeIndex]); } } else if (e.key === "Escape") { if (showDropdown) { // Without this the Escape also reaches Modal/SlideOver and closes // the whole dialog behind the dropdown. e.stopPropagation(); setOpen(false); } } } const listId = `${id}-listbox`; return (
setQuery(e.target.value)} onKeyDown={onKeyDown} placeholder={placeholder} className="w-full text-sm outline-none placeholder:text-ink-muted" /> {query && ( )}
{/* Announces "Suche…" / result count without stealing focus. */} {showDropdown ? (loading ? "Suche läuft" : `${visibleResults.length} Treffer`) : ""} {showDropdown && (
{loading &&
Suche…
} {!loading && visibleResults.length === 0 &&
Keine Treffer
} {!loading && visibleResults.map((item, i) => ( ))}
)}
); }