Template Forge
A fill-in-the-blanks template builder. The caller passes a single template string containing {{slot}} placeholders, plus an ordered list of slots — each slot carries a label and its own bank of option strings. The student taps an option to fill the active blank; tapping a filled blank clears it. Controlled (values + onValuesChange) and uncontrolled (defaultValues) on the Radix pattern.
Generic enough to cover any "build the template by picking values for named blanks" interaction — exact-match vs boundary-search comparison, loop-condition / hi-update / return-value triples, recurrence skeletons, DP base cases — without baking in scoring, phases, or audio. The caller compares the emitted values record against its accepted answers.
Installation
npx shadcn@latest add https://craftbits.dev/r/template-forge.jsonUsage
import {
TemplateForge,
type TemplateSlot,
} from "@craft-bits/core";
const template = `while (BLANK1) {
const mid = lo + Math.floor((hi - lo) / 2);
if (arr[mid] < target) lo = mid + 1;
else BLANK2;
}
return BLANK3;`;
const slots: TemplateSlot[] = [
{
id: "condition",
label: "Loop condition",
options: ["lo <= hi", "lo < hi"],
},
{
id: "hiUpdate",
label: "hi update",
options: ["hi = mid - 1", "hi = mid"],
},
{
id: "returnVal",
label: "Return value",
options: ["-1", "lo"],
},
];
<TemplateForge template={template} slots={slots} />(In the real template, replace BLANK1 / BLANK2 / BLANK3 with {{condition}} / {{hiUpdate}} / {{returnVal}} — placeholder syntax is omitted here so this code block stays valid MDX.)
Controlled — parent owns the values record and validates against accepted answers:
const [values, setValues] = useState({});
<TemplateForge
template={template}
slots={slots}
values={values}
onValuesChange={setValues}
/>Read-only summary — show the accepted template without letting the user edit:
<TemplateForge
template={template}
slots={slots}
values={acceptedAnswer}
editable={false}
tone="success"
/>Understanding the component
- Active slot, then fill. The first empty slot in template order is "active" — its option bank renders below the template. Tap an option to fill the active blank; the next empty slot becomes active automatically.
- Templates are strings, not trees. The
templateis a plain string; the component parses{{slot}}placeholders into drop targets and renders the surrounding text verbatim in a monospace block. Whitespace and newlines are preserved, so multi-line code reads as expected. - Each slot brings its own bank. Slots can offer completely different option sets — the bank refreshes when the active slot changes. This is what lets one component teach exact-match vs boundary-search by reusing the same shape with different option sets.
- Controlled + uncontrolled.
values+onValuesChangeis the Radix controlled pattern.defaultValueslets the component own values internally. If neither is provided, all slots start empty. - Values shape.
valuesis a record keyed by slot id —nullfor empty, otherwise the chosen option string. The component initialises missing keys tonullso caller bugs never crash the layout. - Unknown placeholders are visible. A
{{foo}}placeholder without a matching slot renders in the error tone instead of disappearing silently, so caller mismatches surface during development. - Hit target. Every blank and option enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) — short fills still tap on mobile.
- Reduced motion. Slot enter, fill bounce, and the placeholder breathing collapse to instant under
prefers-reduced-motion: reduce. The values still update; only the motion drops.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
template | string | required | Template string with {{slot}} placeholders. Each placeholder name must match a slots[].id. |
slots | TemplateSlot[] | required | Ordered list of slots — each carries an id, label, options, and optional placeholder. |
values | Record<string, string | null> | — | Controlled values keyed by slot id. Pair with onValuesChange. |
defaultValues | Record<string, string | null> | — | Uncontrolled initial values. |
onValuesChange | (next: Record<string, string | null>) => void | — | Fires with the next values record on every fill or clear. |
editable | boolean | true | When false, template and bank are non-interactive. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight palette for the active slot, filled fills, and focus ring. |
header | ReactNode | — | Content rendered above the template. |
footer | ReactNode | — | Content rendered below the option bank. |
transition | Transition | SPRINGS.smooth | Override slot / option transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The template wrapper is
role="group"with an explicitaria-label, so screen readers announce "code template with blanks to fill" before the line-by-line content. - Each blank is a
<button>with an explicitaria-labelthat includes the slot label and either its current value or "active, choose a value below." / "not yet reached." - The option bank is
role="listbox"with anaria-labelkeyed to the active slot's label; each option is arole="option"button keyboard-activated via Space or Enter. - Empty, not-yet-reached blanks are disabled — focus moves to the active blank or to the bank, so keyboard users never land on a dead target.
- All interactive targets enforce a 44 × 44px minimum hit area (per WCAG 2.5.8 AAA) regardless of label width.
- Blanks expose
data-state(empty/active/filled) anddata-slotcarrying the slot id, so consumer apps can hook custom styles or per-slot validation feedback. - Tone is never the only signal — active and filled states layer fill, stroke, and ring changes so colour-blind users see the distinction.
- Unknown
{{placeholder}}names render in the error tone so missing-slot bugs are visible to sighted reviewers; screen readers announce them as plain text. - Motion respects
prefers-reduced-motion: reduce— slot enter, fill bounce, and the placeholder breathing collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/construction/TemplateForge.tsx). The source was a 1500-line four-act lesson component bundling a linear-search pain phase, a tile-drag template assembly with distractors, a per-iteration trace with prediction gates, an exact-match vs boundary-search comparison, sound cues, and a weighted act-by-act scoring rollup. The library extract keeps only the construction primitive — a template string with{{slot}}placeholders, per-slot option banks, controlled / uncontrolled values, five tones — and lets the caller compose any acts, validation, traces, or sound on top via theheader/footerslots.