"use client"; // components/SearchableSelect.tsx // Reusable typeahead combobox for picking a single record from a PB collection. // Mirrors the search pattern used in LinkEntityModal (300ms debounce, PB filter // syntax with `~`) but exposes the selected record to the parent via onChange // instead of performing the link inline. // // Usage: // setSelectedDirector(record?.id ?? "")} // renderLabel={(r) => `${r.first_names ?? ""} ${r.surname ?? ""}`.trim()} // renderSub={(r) => r.email || r.id_no || ""} // perPage={20} // /> // // The selected record is preserved when the user clears the search box, so the // "Validate recipients" gate sees a real id even after the user refines the // query. If `required` is true and nothing is selected, an inline error appears. import { useEffect, useRef, useState, type CSSProperties } from "react"; import { createPortal } from "react-dom"; import { text } from "@/lib/format"; export interface SearchableSelectRecord { id: string; [key: string]: unknown; } interface Props { /** Field label shown above the input */ label: string; /** PB collection id, e.g. "ysterbaard_directors" */ collection: string; /** PB field names to fetch. Must include `id` and any field used by renderLabel/renderSub. */ fields: string[]; /** PB field names to OR-match against the query string. */ searchFields: string[]; /** Placeholder shown in the empty input */ placeholder?: string; /** Currently selected id (controlled). Pass `selected?.id ?? ""`. */ value: string; /** Called with the full record (or null on clear). Cast to your row type at the call site. */ onChange: (record: SearchableSelectRecord | null) => void; /** Optional label override (defaults to concatenating fields listed in `fields` minus `id`) */ renderLabel?: (record: SearchableSelectRecord) => string; /** Optional secondary line shown beneath the label */ renderSub?: (record: SearchableSelectRecord) => string; /** Number of results per page (default 20) */ perPage?: number; /** Show a required marker and inline error when value is empty after a blur */ required?: boolean; /** Stable id used for aria attributes */ id?: string; } export default function SearchableSelect({ label, collection, fields, searchFields, placeholder = "Type to search...", value, onChange, renderLabel, renderSub, perPage = 20, required = false, id, }: Props) { const inputId = id ?? `searchable-select-${collection}`; const listboxId = `${inputId}-listbox`; const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [selected, setSelected] = useState(null); const [searching, setSearching] = useState(false); const [error, setError] = useState(null); const [touched, setTouched] = useState(false); const [activeIdx, setActiveIdx] = useState(-1); const debounceRef = useRef>(); const containerRef = useRef(null); const inputRef = useRef(null); const listboxRef = useRef(null); /** Viewport coordinates for the portalled listbox. Positioned in `body` * via createPortal so it escapes any ancestor with `overflow-y-auto` * (the resignation/appointment modal scrolls, which used to clip the * dropdown to a single visible row). Recomputed whenever the dropdown * opens or the page scrolls/resizes under it. */ const [listboxStyle, setListboxStyle] = useState({}); // Default label/sub renderers: concatenate all non-id fields. const labelOf = (r: SearchableSelectRecord): string => { if (renderLabel) return renderLabel(r); return fields .filter((f) => f !== "id") .map((f) => text(String(r[f] ?? ""))) .filter(Boolean) .join(" "); }; const subOf = (r: SearchableSelectRecord): string => (renderSub ? renderSub(r) : ""); // When `value` changes from the outside (e.g. parent clears the modal), // sync the selected record. We do NOT fetch it again — the parent already // has it or can call onChange(null) to reset. useEffect(() => { if (!value) { setSelected(null); setQuery(""); setActiveIdx(-1); } }, [value]); // Click-outside to close. Must test the portalled listbox too — it is no // longer a DOM descendant of containerRef, so a naive `contains()` check // would close the dropdown the moment a result is clicked. useEffect(() => { const handler = (e: MouseEvent) => { const t = e.target as Node; const insideInput = containerRef.current?.contains(t); const insideList = listboxRef.current?.contains(t); if (!insideInput && !insideList) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); // Debounced search — mirrors LinkEntityModal useEffect(() => { if (!open) return; clearTimeout(debounceRef.current); const q = query.trim(); if (!q) { setResults([]); setSearching(false); return; } setSearching(true); setError(null); debounceRef.current = setTimeout(async () => { try { const safe = q.replace(/'/g, "''"); const filter = searchFields .map((f) => `${f} ~ '${safe}'`) .join(" || "); const url = `/api/pb/records/list?collection=${encodeURIComponent(collection)}&filter=${encodeURIComponent(filter)}&perPage=${perPage}&fields=${encodeURIComponent(fields.join(","))}`; const res = await fetch(url); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } const data = await res.json(); setResults((data.items ?? []) as SearchableSelectRecord[]); setActiveIdx((data.items ?? []).length > 0 ? 0 : -1); } catch (e: unknown) { setError(e instanceof Error ? e.message : "Search failed"); setResults([]); } finally { setSearching(false); } }, 300); return () => clearTimeout(debounceRef.current); }, [query, open, collection, searchFields, fields, perPage]); // Position the portalled listbox against the input. Drops downward when // there is room, otherwise flips above the input — so it never gets cut // off by the bottom of the viewport in the modal. useEffect(() => { if (!open) return; const reposition = () => { const el = containerRef.current; if (!el) return; const r = el.getBoundingClientRect(); const below = window.innerHeight - r.bottom - 8; const above = r.top - 8; const flip = below < 240 && above > below; const maxHeight = Math.max(160, Math.min(420, flip ? above : below)); setListboxStyle({ position: "fixed", left: r.left, width: r.width, maxHeight, ...(flip ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), }); }; reposition(); window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); return () => { window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); }; }, [open, results.length]); const choose = (record: SearchableSelectRecord) => { setSelected(record); setQuery(""); setResults([]); setOpen(false); setActiveIdx(-1); setTouched(true); onChange(record); }; const clear = () => { setSelected(null); setQuery(""); setResults([]); setTouched(true); setActiveIdx(-1); onChange(null); inputRef.current?.focus(); }; const onKeyDown = (e: React.KeyboardEvent) => { if (!open) return; if (e.key === "ArrowDown") { e.preventDefault(); setActiveIdx((i) => Math.min(results.length - 1, i + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIdx((i) => Math.max(0, i - 1)); } else if (e.key === "Enter") { e.preventDefault(); const r = results[activeIdx]; if (r) choose(r); } else if (e.key === "Escape") { setOpen(false); } }; const showError = required && touched && !value; const displayValue = open ? query : (selected ? labelOf(selected) : query); const subDisplay = !open && selected ? subOf(selected) : ""; return (
= 0 ? `${listboxId}-opt-${activeIdx}` : undefined} value={displayValue} placeholder={selected ? "" : placeholder} onFocus={() => { setOpen(true); setTouched(true); }} onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onKeyDown={onKeyDown} className={`w-full bg-surface border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent transition-colors pr-9 ${ showError ? "border-danger" : "border-border" }`} /> {/* Clear / chevron affordance */}
{selected && !open && ( )}
{/* Sub-line under the input when a record is selected and dropdown closed */} {!open && subDisplay && (

{subDisplay}

)} {/* Inline error / required message */} {showError && (

Please select a {label.toLowerCase()}.

)} {/* Dropdown listbox. Rendered into so ancestor scroll containers (the modal body) cannot clip it. */} {open && typeof document !== "undefined" && createPortal(
{error && (
{error}
)} {searching && (

Searching…

)} {!searching && !error && query.trim() && results.length === 0 && (

No matches found.

)} {!searching && !error && !query.trim() && (

Type to search…

)} {results.map((r, idx) => { const active = idx === activeIdx; return ( ); })}
, document.body )}
); }