Expression Builder
A drag-tokens-into-slots expression constructor. The caller defines a tokens bank (operands, operators, modifiers) and a fixed-length slots row; the student taps a token to select it, then taps a slot to drop it in. Tap a filled slot to clear it. Controlled (placements + onPlacementsChange) and uncontrolled (defaultPlacements) on the Radix pattern.
Generic enough to cover any "build the expression from fragments" interaction — bitwise composition, predicate assembly, invariant construction — without baking in scoring, audio, or validation. The caller compares the emitted placements array against its accepted sequences.
Installation
npx shadcn@latest add https://craftbits.dev/r/expression-builder.jsonUsage
import { ExpressionBuilder } from "@craft-bits/core";
<ExpressionBuilder
tokens={[
{ id: "x", label: "x", kind: "operand" },
{ id: "mask", label: "mask", kind: "operand" },
{ id: "and", label: "&", kind: "operator" },
{ id: "or", label: "|", kind: "operator" },
{ id: "not", label: "~", kind: "modifier" },
]}
slots={[{ id: "lhs" }, { id: "op" }, { id: "rhs" }]}
/>Controlled — parent owns placements and validates against accepted answers:
const [placements, setPlacements] = useState([null, null, null]);
<ExpressionBuilder
tokens={tokens}
slots={slots}
placements={placements}
onPlacementsChange={setPlacements}
/>Allow a token to occupy multiple slots at once (formulas with repeated operands):
<ExpressionBuilder
tokens={tokens}
slots={slots}
defaultPlacements={["x", "and", "x"]}
allowDuplicates
/>Read-only summary — show the current expression without letting the user edit:
<ExpressionBuilder
tokens={tokens}
slots={slots}
placements={["x", "and", "mask"]}
editable={false}
/>Understanding the component
- Tap, then drop. A token press selects the chip; the next slot press places it. Tapping the same token again clears the selection. Tapping a filled slot clears that slot.
- Controlled + uncontrolled.
placements+onPlacementsChangeis the Radix controlled pattern.defaultPlacementslets the component own placements internally. If neither is provided, all slots start empty. - Placement shape.
placementsis always one entry per slot —nullfor empty, otherwise the token ID. The component pads or truncates incoming arrays to matchslots.lengthso caller bugs never crash the layout. - Duplicates. By default, placing a token clears any earlier slot it occupied — one chip, one slot, the bank dims once it is in play. Set
allowDuplicatesto keep the chip live; multiple slots can hold the same token. - Token kinds.
operandandmodifierchips tint with the active tone;operatorchips stay neutral so the eye reads the expression asoperand op operand. Modifier tokens render in parentheses inside their slot so a wrapped operand reads(~x)even though only the~was placed. - Hit target. Every chip and slot enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) — short labels still tap on mobile.
- Reduced motion. Slot enter, chip bounce, and the placeholder breathing collapse to instant under
prefers-reduced-motion: reduce. The placements still update; only the motion drops.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tokens | ExpressionToken[] | required | Draggable bank items. Each carries an id, label, and optional kind. |
slots | ExpressionSlot[] | required | Slot row, left-to-right. Each carries an id and optional placeholder. |
placements | (string | null)[] | — | Controlled placements — one entry per slot. Pair with onPlacementsChange. |
defaultPlacements | (string | null)[] | — | Uncontrolled initial placements. |
onPlacementsChange | (next: (string | null)[]) => void | — | Fires with the next placements array on every placement or clear. |
allowDuplicates | boolean | false | Allow a single token to occupy multiple slots simultaneously. |
editable | boolean | true | When false, slots and bank are non-interactive. |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight palette for selection, fills, and focus ring. |
transition | Transition | SPRINGS.smooth | Override slot / chip transitions. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The slot row is
role="list"; each slot is arole="listitem"button with an explicitaria-labelthat announces both the slot index and either its current token or "select a token first". - The token bank is
role="listbox"witharia-activedescendantpointing at the currently held chip; each chip is arole="option"button witharia-selectedreflecting the held state. - Every chip and slot is keyboard activated — Tab to focus, Space or Enter to pick / place / clear — and renders a visible focus ring through
focus-visible:ring-cb-accent. - All interactive targets enforce a 44 × 44px minimum hit area (per WCAG 2.5.8 AAA) regardless of label width.
- Slots expose
data-state(empty/armed/filled); chips exposedata-state(rest/selected/placed) anddata-kind(operand/operator/modifier) so consumer apps can hook custom styles or assistive tooling. - Motion respects
prefers-reduced-motion: reduce— slot enter, chip bounce, and the empty-slot breathing collapse to instant.
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/construction/ExpressionBuilder.tsx). The source bundled scoring, audio cues, a wrong-answer shake, and bitwise-specific NOT-wrapping logic into a single lesson component. The library extract keeps only the construction primitive — token bank, slot row, controlled / uncontrolled placements, optional duplicates, five tones — and lets the caller compose any validation, sound, or shake behaviour on top.