Code Fill
A fill-in-the-blank code editor. Author the puzzle as a single template string with ___id___ markers — three underscores, the blank id, three underscores — and describe each blank's dropdown of options in the blanks array. The component renders the template through Shiki, drops an inline button at every marker, and checks every blank at once on submit. Per-option feedback surfaces below the failure banner so a wrong pick teaches instead of just buzzing.
Installation
npx shadcn@latest add https://craftbits.dev/r/code-fill.jsonshiki is an optional peer dependency — install it only if you want syntax highlighting. The component falls back to a plain-text render of the template when shiki isn't available.
Usage
Minimal — two blanks, plain submit, no completed-code reveal:
import { CodeFill, type CodeFillBlank } from "@craft-bits/core";
const blanks: CodeFillBlank[] = [
{
id: "ctor",
label: "data structure",
options: ["new Map()", "new Set()", "[]"],
correctIdx: 0,
},
{
id: "lookup",
label: "lookup",
options: ["seen.has(complement)", "seen.get(complement)"],
correctIdx: 0,
},
];
<CodeFill
templateCode={template}
lang="typescript"
blanks={blanks}
onComplete={() => console.log("solved")}
/>Surface per-option feedback so wrong picks land on a specific misconception:
const blanks: CodeFillBlank[] = [
{
id: "ctor",
label: "data structure",
options: ["new Map()", "new Set()", "[]"],
correctIdx: 0,
optionFeedback: {
1: "A Set only stores keys — we need key to index lookup.",
2: "Arrays do not give O(1) lookup by value.",
},
wrongFeedback: "We need key to index lookup.",
},
];Show the completed code on the success state by passing completedCode:
<CodeFill
templateCode={template}
completedCode={completed}
blanks={blanks}
/>Understanding the component
- Template + markers, not children. Authors describe the puzzle once as a
templateCodestring with___id___markers. The component splits each line's Shiki tokens around the markers and drops a blank button in place — no JSX gymnastics, no hand-rolled DSL. - Shiki is optional. The shared
useShikiTokenshook dynamic-importsshikionly on mount. When shiki isn't installed, the hook surfaces an error and the template falls back to a plain-text render with the same blank slots — the puzzle still works. - One submit, every blank checked. Picking an option in any dropdown is a live update; correctness is evaluated only when the learner taps the submit button. Every wrong blank then surfaces its
optionFeedbackentry (or falls back towrongFeedback) so each misconception lands on a specific note. - Live correct callback, fire once.
onBlankCorrectis the moment-of-discovery hook for accreting side panels. It fires at most once per blank — switching to a wrong option and back does not re-fire — which keeps progressive reveal layouts well behaved. - Self-resetting. After a wrong submit the learner can retry: every answer clears, every active dropdown closes, and the success/failure state resets. The retry button mirrors the submit button so the muscle memory is the same on both sides of the gate.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
templateCode | string | required | Template source with ___id___ markers. Each marker must match a blank id. |
completedCode | string | — | Final code shown on the success state. Omit to keep the template visible. |
lang | string | 'typescript' | Shiki language id for both template and completed code. |
blanks | readonly CodeFillBlank[] | required | Blank descriptors. Order doesn't matter — markers in the template drive layout. |
onComplete | () => void | — | Fires on a fully correct submit. |
onWrong | () => void | — | Fires on a submit with one or more wrong blanks. |
onBlankWrong | (blankId, pickedIdx) => void | — | Fires once per wrong blank on a wrong submit. |
onBlankCorrect | (blankId) => void | — | Fires the first time the learner picks the correct option for a blank. |
successMessage | ReactNode | 'All correct!' | Banner shown on success. |
successExtra | ReactNode | — | Extra content below the success banner. |
failMessage | ReactNode | 'Not quite — check the red blanks.' | Banner shown on failure. |
submitLabel | ReactNode | 'Check' | Submit button label. |
retryLabel | ReactNode | 'Try again' | Retry button label. |
codeClassName | string | — | Extra className on the code wrapper. |
className | string | — | Extra className on the outer wrapper. |
CodeFillBlank
| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Stable identifier — must match the ___id___ marker in templateCode. |
label | string | required | Short label shown when the blank is empty. |
options | readonly string[] | required | Choice strings in display order. |
correctIdx | number | required | Index of the correct option in options. |
wrongFeedback | ReactNode | — | Fallback feedback shown after a wrong submit on this blank. |
optionFeedback | Partial<Record<number, ReactNode>> | — | Feedback keyed by option index — wins over wrongFeedback when present. |
Accessibility
- Each blank is a real
<button>witharia-haspopup="listbox",aria-expanded, and anaria-labelthat announces the slot's label and current value (or "not filled"). - The dropdown is a
role="listbox"whose options userole="option"and reflect selection througharia-selected. Both blank buttons and options enforce a 44px minimum hit area (WCAG 2.5.8 AAA). - Submit and retry banners live inside an
aria-live="polite"region so screen readers announce the result when it appears. - Focus is visible across blanks, options, and CTA buttons via a token-driven
focus-visiblering (--cb-accent). - The wrong-blank shake animation is a single, brief horizontal shimmy with a damped spring — no flashing or sustained motion.
- Per-option feedback is rendered as text inside the live region so screen readers receive the explanation, not just the buzz.
Credits
- Extracted from:
AlgoFlashcards(src/lessons/primitives/interaction/CodeFill.tsx). The original was wired into the lesson runtime — phase gates,playSound,LessonButton.Pill, project-specificsplitTokensAroundPlaceholders, and a workbench dial schema. craft-bits generalises it to a stand-alone primitive: lesson chrome dropped, Shiki access routed through the shareduseShikiTokenshook, per-blank feedback unified underwrongFeedbackandoptionFeedback, the success-state code reveal made optional viacompletedCode, and the submit and retry buttons replaced with the canonical accent-pill recipe so the primitive ships withoutLessonButtonas a dependency.