"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; import "./word-bank.css"; export type WordBankProps = { /** What the learner is asked to do, e.g. "Write this in English". */ instruction?: string; /** The sentence shown in the speech bubble. */ prompt: string; /** Tiles, in the order they sit in the bank. Repeated words are fine. */ words: string[]; /** BCP 47 language of the prompt, e.g. "ar". */ promptLang?: string; /** Accepted answers; compared ignoring case, punctuation and extra spaces. */ answers: string[]; /** Called once per check with the result and the built sentence. */ onCheck?: (correct: boolean, answer: string) => void; onContinue?: (correct: boolean) => void; onSkip?: () => void; theme?: "light" | "dark"; motion?: "auto" | "reduced"; className?: string; }; const normal = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s']/gu, "").replace(/\s+/g, " ").trim(); const reduced = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; /** * Word Bank * Build the sentence from tiles. A tapped tile flies up onto the ruled line * and leaves its outline behind in the bank; tap it again to send it home. * Check slides up a bar that says whether you got it, and what was meant. */ export function WordBank({ instruction = "Write this in English", prompt, promptLang, words, answers, onCheck, onContinue, onSkip, theme = "light", motion = "auto", className = "" }: WordBankProps) { const id = useId(); const [picked, setPicked] = useState([]); const [result, setResult] = useState(null); const tiles = useRef(new Map()); const flight = useRef<{ index: number; from: DOMRect } | null>(null); const continueRef = useRef(null); const checkRef = useRef(null); // FLIP: the tile that moved animates from where it was to where it landed. useLayoutEffect(() => { const f = flight.current; flight.current = null; if (!f || motion === "reduced" || reduced()) return; const el = tiles.current.get(picked.includes(f.index) ? `a${f.index}` : `b${f.index}`); if (!el) return; const to = el.getBoundingClientRect(); el.animate([{ transform: `translate(${f.from.left - to.left}px, ${f.from.top - to.top}px)` }, { transform: "none" }], { duration: 280, easing: "cubic-bezier(.2,.8,.2,1)" }); }, [picked, motion]); useEffect(() => { if (result) continueRef.current?.focus(); }, [result]); const toggle = (index: number) => { if (result) return; const source = tiles.current.get(picked.includes(index) ? `a${index}` : `b${index}`); if (source) flight.current = { index, from: source.getBoundingClientRect() }; setPicked((p) => (p.includes(index) ? p.filter((i) => i !== index) : [...p, index])); }; const answer = picked.map((i) => words[i]).join(" "); const check = () => { if (!picked.length || result) return; const correct = answers.some((a) => normal(a) === normal(answer)); setResult({ correct }); onCheck?.(correct, answer); }; const next = () => { const correct = !!result?.correct; setResult(null); setPicked([]); onContinue?.(correct); requestAnimationFrame(() => checkRef.current?.focus()); }; const onKey = (e: KeyboardEvent) => { if ((e.target as Element).closest("input, textarea")) return; if (/^[1-9]$/.test(e.key) && !result) { const i = Number(e.key) - 1; if (i < words.length && !picked.includes(i)) { e.preventDefault(); toggle(i); } } else if (e.key === "Backspace" && picked.length && !result) { e.preventDefault(); toggle(picked[picked.length - 1]); } else if (e.key === "Enter" && (e.target as Element).tagName !== "BUTTON") { e.preventDefault(); if (result) next(); else check(); } }; const correctAnswer = answers[0]; return (

{instruction}

{prompt}

{picked.map((i) => ( ))}
{words.map((w, i) => ( {picked.includes(i) ? : } ))}
); }