ccr-platform / frontend /src /ConstructPicker.jsx
devaanand's picture
feat: LLM-generated construct items (design note ITEM_GENERATION.md)
1a28176
Raw
History Blame Contribute Delete
7.35 kB
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 (
<div className="picker" ref={rootRef}>
{!open ? (
<button
type="button"
className="picker-display"
aria-haspopup="listbox"
aria-expanded="false"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
>
{selected.size > 0 ? (
<span>
+ Add another construct ({selected.size} selected)
</span>
) : (
<span className="muted">
Select one or more constructs ({constructs.length} in library)
</span>
)}
<span className="picker-caret" aria-hidden="true"></span>
</button>
) : (
<>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
className="picker-search"
placeholder="Search by scale, construct, or category (e.g. empathy, GAD-7)"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
<div className="picker-panel" role="listbox" ref={listRef}>
{flat.length === 0 && (
<p className="small muted picker-empty">
No constructs match "{query}". Try a scale abbreviation or use + Custom construct.
</p>
)}
{groups.map(([label, items]) => (
<div key={label}>
<div className="picker-group">{label}</div>
{items.map((c) => {
index += 1;
const isActive = index === active;
const isSelected = selected.has(c.id);
return (
<div
key={c.id}
role="option"
aria-selected={isSelected}
data-active={isActive || undefined}
className={
"picker-option" +
(isActive ? " active" : "") +
(isSelected ? " selected" : "")
}
onMouseDown={(e) => {
e.preventDefault();
choose(c);
}}
>
<span className="picker-name">
{isSelected ? "✓ " : ""}
{c.name}
</span>
<span className="picker-meta">
{c.category ? `${c.category} · ` : ""}
{c.items.length} item{c.items.length === 1 ? "" : "s"}
{c.ai_generated
? " · AI-generated · not validated"
: c.verification_status !== "verified"
? " · unverified"
: ""}
{isSelected ? " · click to remove" : ""}
</span>
</div>
);
})}
</div>
))}
</div>
</>
)}
</div>
);
}