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.

Customize
Highlight
1
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/expression-builder.json

Usage

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

  1. 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.
  2. Controlled + uncontrolled. placements + onPlacementsChange is the Radix controlled pattern. defaultPlacements lets the component own placements internally. If neither is provided, all slots start empty.
  3. Placement shape. placements is always one entry per slot — null for empty, otherwise the token ID. The component pads or truncates incoming arrays to match slots.length so caller bugs never crash the layout.
  4. 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 allowDuplicates to keep the chip live; multiple slots can hold the same token.
  5. Token kinds. operand and modifier chips tint with the active tone; operator chips stay neutral so the eye reads the expression as operand op operand. Modifier tokens render in parentheses inside their slot so a wrapped operand reads (~x) even though only the ~ was placed.
  6. Hit target. Every chip and slot enforces a 44 × 44px minimum hit area (per WCAG 2.5.8) — short labels still tap on mobile.
  7. 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

PropTypeDefaultDescription
tokensExpressionToken[]requiredDraggable bank items. Each carries an id, label, and optional kind.
slotsExpressionSlot[]requiredSlot 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)[]) => voidFires with the next placements array on every placement or clear.
allowDuplicatesbooleanfalseAllow a single token to occupy multiple slots simultaneously.
editablebooleantrueWhen false, slots and bank are non-interactive.
tone"default" | "accent" | "success" | "warning" | "error""accent"Highlight palette for selection, fills, and focus ring.
transitionTransitionSPRINGS.smoothOverride slot / chip transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The slot row is role="list"; each slot is a role="listitem" button with an explicit aria-label that announces both the slot index and either its current token or "select a token first".
  • The token bank is role="listbox" with aria-activedescendant pointing at the currently held chip; each chip is a role="option" button with aria-selected reflecting 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 expose data-state (rest / selected / placed) and data-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.