import { useEffect, useMemo, useRef, useState } from "react"; // Searchable, grouped construct picker. A flat dropdown stops working past // ~15 options; the library is at 99 and growing. Researchers either know the // scale ("GAD-7") or the family ("empathy"), so search matches name, category, // and questionnaire, results group by category, and the last 5 used constructs // stay on top (researchers re-run the same scales constantly). // Keyboard: ArrowUp/Down move, Enter selects, Escape closes. const RECENT_KEY = "ccr_recent_constructs"; const RECENT_MAX = 5; function readRecent() { try { return JSON.parse(localStorage.getItem(RECENT_KEY) || "[]"); } catch { return []; } } export function rememberRecent(id) { const next = [id, ...readRecent().filter((x) => x !== id)].slice(0, RECENT_MAX); try { localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* storage unavailable: recents simply don't persist */ } } // Multi-construct runs: `selectedIds` is the ordered selection, `onToggle(id)` // adds/removes one. Picking closes the panel (same feel as the old // single-select); the Workspace shows the selection as removable chips. export default function ConstructPicker({ constructs, selectedIds, onToggle }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const rootRef = useRef(null); const inputRef = useRef(null); const listRef = useRef(null); const selected = new Set(selectedIds); const groups = useMemo(() => { const q = query.trim().toLowerCase(); const match = (c) => !q || c.name.toLowerCase().includes(q) || (c.category || "").toLowerCase().includes(q); const filtered = constructs.filter(match); const out = []; const used = new Set(); const recentIds = readRecent(); const recent = recentIds .map((id) => filtered.find((c) => c.id === id)) .filter(Boolean); if (recent.length) { out.push(["Recently used", recent]); recent.forEach((c) => used.add(c.id)); } const custom = filtered.filter((c) => !c.is_seed && !used.has(c.id)); if (custom.length) { out.push(["My custom constructs", custom]); custom.forEach((c) => used.add(c.id)); } const byCategory = new Map(); for (const c of filtered) { if (used.has(c.id)) continue; const cat = c.category || "Other"; if (!byCategory.has(cat)) byCategory.set(cat, []); byCategory.get(cat).push(c); } for (const cat of [...byCategory.keys()].sort((a, b) => a.localeCompare(b))) { out.push([cat, byCategory.get(cat).sort((a, b) => a.name.localeCompare(b.name))]); } return out; }, [constructs, query]); const flat = useMemo(() => groups.flatMap(([, items]) => items), [groups]); useEffect(() => setActive(0), [query, open]); // Close on outside click. useEffect(() => { if (!open) return undefined; const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, [open]); // Keep the active option scrolled into view. useEffect(() => { const el = listRef.current?.querySelector('[data-active="true"]'); el?.scrollIntoView({ block: "nearest" }); }, [active, open]); function choose(c) { onToggle(c.id); if (!selected.has(c.id)) rememberRecent(c.id); setOpen(false); setQuery(""); } function onKeyDown(e) { if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (flat[active]) choose(flat[active]); } else if (e.key === "Escape") { setOpen(false); } } let index = -1; // running index across groups for keyboard highlight return (
No constructs match "{query}". Try a scale abbreviation or use + Custom construct.
)} {groups.map(([label, items]) => (