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.

Prefix-XOR over [3,5,2,7,4,1]
history7 / 7
arr =[3, 5, 2, 7, 4, 1]
prefix =[0, 3, 6, 4, 3, 7, 6]
Reverse direction with the Undo button — XOR is its own inverse.
Snapshot 7 of 7.

Installation

npx shadcn@latest add https://craftbits.dev/r/undo-test.json

Usage

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

  1. Caller owns the history. snapshots is opaque to the component — it never inspects state beyond forwarding it to renderState. The state type is captured by the UndoTest<TState> generic so the caller's renderState signature stays strongly typed.
  2. Cursor is clamped. Out-of-range cursor values are silently clamped to the nearest valid index. Empty snapshots arrays render the empty-state copy.
  3. Tape states. Each pill carries a data-state of past, active, or future. 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".
  4. 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.
  5. Scrubbing is opt-in. If onScrub is provided, every pill becomes a <button> with a 44 by 44 hit area. Without it, pills are inert <div>s.
  6. Preview is opt-in. If renderState is provided, the active snapshot's state is rendered in a tone-tinted panel beneath the controls.
  7. Reduced motion. Pill entries, cursor chip pulse, and preview transitions all collapse to instant under prefers-reduced-motion: reduce.

Props

PropTypeDefaultDescription
snapshotsReadonlyArray<UndoTestSnapshot<TState>>requiredOrdered history. Each snapshot is { id, label, state, caption? }. Order is render order.
cursornumberrequiredIndex of the active snapshot. Clamped to a valid index.
renderState(state, snapshot) => ReactNodeRenders the active snapshot's state into the preview panel.
onUndo() => voidFires on Undo click. Omit to hide the Undo button.
onRedo() => voidFires on Redo click. Omit to hide the Redo button.
onScrub(index: number) => voidFires when a pill is clicked. Omit to make the tape read-only.
titleReactNodeContent rendered above the tape header.
footerReactNodeContent rendered below the preview panel.
tone"default" | "accent" | "success" | "warning" | "error""accent"Semantic accent for the cursor chip and the active pill.
undoLabelReactNode"Undo"Label content for the Undo button.
redoLabelReactNode"Redo"Label content for the Redo button.
emptyLabelstring"No history yet."Copy when snapshots is empty.
transitionTransitionSPRINGS.smoothOverride transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The tape is a role="list" with an aria-labelledby pointing at the "history" header; each pill is a role="listitem" with an aria-label naming the snapshot index and whether it is current, plus aria-current="step" on the active pill.
  • The cursor chip uses aria-live="polite" and carries an aria-label that names the cursor position and the total snapshot count.
  • When onScrub is 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-label strings, respect disabled state 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, and data-can-redo on the root, plus data-state and data-index on 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.