363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
|
|
"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:
|
||
|
|
// <SearchableSelect
|
||
|
|
// label="Director"
|
||
|
|
// collection="ysterbaard_directors"
|
||
|
|
// fields={["id", "first_names", "surname", "email", "telephone"]}
|
||
|
|
// searchFields={["first_names", "surname", "id_no"]}
|
||
|
|
// placeholder="Search directors by name or ID..."
|
||
|
|
// value={selectedDirector}
|
||
|
|
// onChange={(record) => 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<SearchableSelectRecord[]>([]);
|
||
|
|
const [selected, setSelected] = useState<SearchableSelectRecord | null>(null);
|
||
|
|
const [searching, setSearching] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [touched, setTouched] = useState(false);
|
||
|
|
const [activeIdx, setActiveIdx] = useState<number>(-1);
|
||
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
|
const listboxRef = useRef<HTMLDivElement>(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<CSSProperties>({});
|
||
|
|
|
||
|
|
// 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<HTMLInputElement>) => {
|
||
|
|
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 (
|
||
|
|
<div ref={containerRef} className="relative">
|
||
|
|
<label htmlFor={inputId} className="block text-sm font-semibold text-fg mb-1.5">
|
||
|
|
{label}
|
||
|
|
{required && <span className="text-danger ml-0.5">*</span>}
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<div className="relative">
|
||
|
|
<input
|
||
|
|
ref={inputRef}
|
||
|
|
id={inputId}
|
||
|
|
type="text"
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={open}
|
||
|
|
aria-controls={listboxId}
|
||
|
|
aria-autocomplete="list"
|
||
|
|
aria-activedescendant={activeIdx >= 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 */}
|
||
|
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
|
||
|
|
{selected && !open && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={clear}
|
||
|
|
tabIndex={-1}
|
||
|
|
aria-label="Clear selection"
|
||
|
|
className="text-muted hover:text-fg p-1 rounded transition-colors"
|
||
|
|
>
|
||
|
|
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
className={`w-4 h-4 text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth="2"
|
||
|
|
>
|
||
|
|
<path d="M6 9l6 6 6-6" />
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Sub-line under the input when a record is selected and dropdown closed */}
|
||
|
|
{!open && subDisplay && (
|
||
|
|
<p className="mt-1 text-xs text-muted font-mono truncate">{subDisplay}</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Inline error / required message */}
|
||
|
|
{showError && (
|
||
|
|
<p className="mt-1 text-xs text-danger">Please select a {label.toLowerCase()}.</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Dropdown listbox. Rendered into <body> so ancestor scroll
|
||
|
|
containers (the modal body) cannot clip it. */}
|
||
|
|
{open &&
|
||
|
|
typeof document !== "undefined" &&
|
||
|
|
createPortal(
|
||
|
|
<div
|
||
|
|
ref={listboxRef}
|
||
|
|
id={listboxId}
|
||
|
|
role="listbox"
|
||
|
|
style={listboxStyle}
|
||
|
|
className="z-[60] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised"
|
||
|
|
>
|
||
|
|
{error && (
|
||
|
|
<div className="px-3 py-2 text-sm text-danger border-b border-border">
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{searching && (
|
||
|
|
<p className="px-3 py-3 text-sm text-muted text-center">Searching…</p>
|
||
|
|
)}
|
||
|
|
{!searching && !error && query.trim() && results.length === 0 && (
|
||
|
|
<p className="px-3 py-3 text-sm text-muted text-center">No matches found.</p>
|
||
|
|
)}
|
||
|
|
{!searching && !error && !query.trim() && (
|
||
|
|
<p className="px-3 py-3 text-sm text-muted text-center">Type to search…</p>
|
||
|
|
)}
|
||
|
|
{results.map((r, idx) => {
|
||
|
|
const active = idx === activeIdx;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={r.id}
|
||
|
|
id={`${listboxId}-opt-${idx}`}
|
||
|
|
role="option"
|
||
|
|
aria-selected={active}
|
||
|
|
type="button"
|
||
|
|
onMouseEnter={() => setActiveIdx(idx)}
|
||
|
|
onMouseDown={(e) => { e.preventDefault(); choose(r); }}
|
||
|
|
onClick={() => choose(r)}
|
||
|
|
className={`w-full text-left px-3 py-2.5 transition-colors flex flex-col gap-0.5 ${
|
||
|
|
active ? "bg-surface-warm" : "hover:bg-surface-warm"
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
<span className="text-sm font-semibold text-fg truncate">{labelOf(r)}</span>
|
||
|
|
{subOf(r) && (
|
||
|
|
<span className="text-xs text-muted font-mono truncate">{subOf(r)}</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>,
|
||
|
|
document.body
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|