"use client"; import { useEffect, useMemo, useRef, useState } from "react"; export type Vocab = { curated: string[]; compounds: string[]; custom: string[] }; type Props = { open: boolean; value: string; /** The ASR token, shown as the fallback when nothing has been chosen. */ token: string; /** This clip's own neighbours, so the right compound is one keystroke. */ neighbours: { before: string; after: string }; vocab: Vocab; onPick: (word: string, custom: boolean) => void; onOpen: (open: boolean) => void; }; type Group = { label: string; items: string[] }; export default function WordSelector({ open, value, token, neighbours, vocab, onPick, onOpen, }: Props) { const [q, setQ] = useState(""); const [cursor, setCursor] = useState(0); const inputRef = useRef(null); useEffect(() => { if (open) { setQ(""); setCursor(0); // Focus after paint, or the keystroke that opened the list types into the // deck's global handler instead of the box. requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); // This clip's own neighbours, built into compounds. The last pass produced // "do uh", "to uh", "when uhh" and "uhh literally" as free text; every one of // those is a neighbour of the clip that was being judged. const fromClip = useMemo(() => { const base = (token || "uh").toLowerCase().replace(/[^a-z-]/g, "") || "uh"; const out: string[] = []; const b = neighbours.before?.replace(/[^A-Za-z'-]/g, "").toLowerCase(); const a = neighbours.after?.replace(/[^A-Za-z'-]/g, "").toLowerCase(); if (b) out.push(`${b} ${base}`); if (a) out.push(`${base} ${a}`); if (b && a) out.push(`${b} ${base} ${a}`); return out; }, [token, neighbours.before, neighbours.after]); const groups: Group[] = useMemo(() => { const seen = new Set(); const uniq = (list: string[]) => list.filter((w) => { const k = w.toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); const g: Group[] = [ { label: "this clip", items: uniq(fromClip) }, { label: "words", items: uniq(vocab.curated) }, { label: "compounds", items: uniq(vocab.compounds) }, ]; if (vocab.custom.length) g.push({ label: "yours", items: uniq(vocab.custom) }); return g; }, [fromClip, vocab]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); return groups .map((g) => ({ ...g, items: needle ? g.items.filter((w) => w.toLowerCase().includes(needle)) : g.items })) .filter((g) => g.items.length); }, [groups, q]); const flat = useMemo(() => filtered.flatMap((g) => g.items), [filtered]); const typed = q.trim(); const canAdd = typed.length > 0 && !flat.some((w) => w.toLowerCase() === typed.toLowerCase()); const total = flat.length + (canAdd ? 1 : 0); useEffect(() => { if (cursor >= total) setCursor(Math.max(0, total - 1)); }, [total, cursor]); const commit = (i: number) => { if (canAdd && i === flat.length) { onPick(typed, true); } else if (flat[i]) { onPick(flat[i], false); } onOpen(false); }; const shown = value || token || "?"; return (
{open ? (
{ setQ(e.target.value); setCursor(0); }} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") { e.preventDefault(); onOpen(false); } else if (e.key === "ArrowDown") { e.preventDefault(); setCursor((c) => Math.min(total - 1, c + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setCursor((c) => Math.max(0, c - 1)); } else if (e.key === "Enter") { e.preventDefault(); commit(cursor); } }} placeholder="type to filter, Enter picks" className="w-full rounded-t-md border-b border-[var(--color-line)] bg-transparent px-3 py-2 text-sm outline-none" />
{filtered.map((g) => (
{g.label}
{g.items.map((w) => { const i = flat.indexOf(w); return ( ); })}
))} {canAdd ? ( ) : null} {!filtered.length && !canAdd ? (
nothing matches
) : null}
) : null}
); }