UndoTest
An undo / redo state inspector — a horizontal tape of history snapshots plus a preview of the active one. The caller passes an ordered snapshots array and a cursor index. UndoTest renders one pill per snapshot, ringed in the tone colour on the active step, and a preview panel below that renders the current state via a renderState function.
Pure visualisation primitive. The component does not own the history reducer, the keyboard shortcuts, or the audio cues the source lesson layered on. Generic enough for text-editor scrubbing, transaction journals, command-pattern playback, Floyd-rho cursor traces, prefix-sum or prefix-XOR reversal experiments, or any lesson that wants to make the "can you reverse this operation?" question tangible.
[3,5,2,7,4,1]Installation
npx shadcn@latest add https://craftbits.dev/r/undo-test.jsonUsage
import { UndoTest } from "@craft-bits/core";
const snapshots = [
{ id: "s0", label: "0", state: { value: 0 } },
{ id: "s1", label: "1", state: { value: 5 } },
{ id: "s2", label: "2", state: { value: 12 } },
];
<UndoTest
snapshots={snapshots}
cursor={2}
renderState={(s) => <span>value = {s.value}</span>}
/>Wire onUndo / onRedo to your own reducer to expose interactive controls:
const [cursor, setCursor] = useState(snapshots.length - 1);
<UndoTest
snapshots={snapshots}
cursor={cursor}
onUndo={() => setCursor((c) => Math.max(0, c - 1))}
onRedo={() => setCursor((c) => Math.min(snapshots.length - 1, c + 1))}
renderState={(s) => <pre>{JSON.stringify(s, null, 2)}</pre>}
/>Pass onScrub to make every pill on the tape clickable — handy for "jump to step N" scrubbing:
<UndoTest
snapshots={snapshots}
cursor={cursor}
onScrub={(i) => setCursor(i)}
renderState={(s) => <span>{s.value}</span>}
/>Drive it as a read-only display by omitting every callback — the tape becomes a non-interactive history strip:
<UndoTest
snapshots={snapshots}
cursor={snapshots.length - 1}
renderState={(s) => <span>{s.value}</span>}
/>Understanding the component
- Caller owns the history.
snapshotsis opaque to the component — it never inspectsstatebeyond forwarding it torenderState. The state type is captured by theUndoTest<TState>generic so the caller'srenderStatesignature stays strongly typed. - Cursor is clamped. Out-of-range
cursorvalues are silently clamped to the nearest valid index. Emptysnapshotsarrays render the empty-state copy. - Tape states. Each pill carries a
data-stateofpast,active, orfuture. The active pill is ringed in the tone colour and pulses on cursor change; past pills are muted; future pills are dimmed at 70% opacity so the eye reads "this is where you could redo to". - Controls are opt-in. Undo and Redo buttons only render when the matching callback is supplied. They are disabled when the cursor is at either end of the history.
- Scrubbing is opt-in. If
onScrubis provided, every pill becomes a<button>with a 44 by 44 hit area. Without it, pills are inert<div>s. - Preview is opt-in. If
renderStateis provided, the active snapshot's state is rendered in a tone-tinted panel beneath the controls. - Reduced motion. Pill entries, cursor chip pulse, and preview transitions all collapse to instant under
prefers-reduced-motion: reduce.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
snapshots | ReadonlyArray<UndoTestSnapshot<TState>> | required | Ordered history. Each snapshot is { id, label, state, caption? }. Order is render order. |
cursor | number | required | Index of the active snapshot. Clamped to a valid index. |
renderState | (state, snapshot) => ReactNode | — | Renders the active snapshot's state into the preview panel. |
onUndo | () => void | — | Fires on Undo click. Omit to hide the Undo button. |
onRedo | () => void | — | Fires on Redo click. Omit to hide the Redo button. |
onScrub | (index: number) => void | — | Fires when a pill is clicked. Omit to make the tape read-only. |
title | ReactNode | — | Content rendered above the tape header. |
footer | ReactNode | — | Content rendered below the preview panel. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Semantic accent for the cursor chip and the active pill. |
undoLabel | ReactNode | "Undo" | Label content for the Undo button. |
redoLabel | ReactNode | "Redo" | Label content for the Redo button. |
emptyLabel | string | "No history yet." | Copy when snapshots is empty. |
transition | Transition | SPRINGS.smooth | Override transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The tape is a
role="list"with anaria-labelledbypointing at the "history" header; each pill is arole="listitem"with anaria-labelnaming the snapshot index and whether it is current, plusaria-current="step"on the active pill. - The cursor chip uses
aria-live="polite"and carries anaria-labelthat names the cursor position and the total snapshot count. - When
onScrubis provided, every pill is rendered as a<button type="button">with a 44 by 44 hit target and a label describing the jump target. - Undo / Redo buttons render with descriptive
aria-labelstrings, respectdisabledstate at the ends of history, and meet the 44 by 44 hit-area floor. - An off-screen
aria-live="polite"summary repeats the current cursor position on every render. - The component exposes
data-tone,data-cursor,data-can-undo, anddata-can-redoon the root, plusdata-stateanddata-indexon each pill. - Motion respects
prefers-reduced-motion: reduce.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/observation/UndoTest.tsx). The source was a four-phase lesson coupling a prefix-XOR experiment, a prediction gate, four inverse-construction practice rounds with per-distractor feedback, a fill-the-blank code bridge, a completion screen, scoring, and audio cues. The library extract keeps only the state-history inspector primitive and lets the caller compose any reducer, prediction gate, code bridge, scoring, audio, or pedagogy on top.