Rule Assembler

An N-step rule / algorithm constructor. The caller defines an ordered steps list — each step is one orthogonal decision the student commits to via a labelled dropdown — and the component emits a selected array of one entry per step. Controlled (selected + onSelectedChange) and uncontrolled (defaultSelected) on the Radix pattern.

Generic enough to cover any "build the rule / algorithm / policy by committing to N decisions" interaction — DFS traversal rules, sliding-window invariants, grammar drills, query planning — without baking in scoring, audio, or per-combo validation. The caller compares the emitted selection against its accepted answers and supplies the summary phrasing via a render-prop slot.

Customize
Highlight
1
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/rule-assembler.json

Usage

import { RuleAssembler } from "@craft-bits/core";
 
<RuleAssembler
  steps={[
    {
      id: "mark",
      label: "MARK timing",
      options: [
        { value: "entry", label: "on entry" },
        { value: "exit", label: "on exit" },
      ],
    },
    {
      id: "scope",
      label: "Scope",
      options: [
        { value: "branch", label: "current branch" },
        { value: "global", label: "global" },
      ],
    },
  ]}
/>

Controlled — parent owns the selection and validates against accepted answers:

const [selected, setSelected] = useState([null, null]);
 
<RuleAssembler
  steps={steps}
  selected={selected}
  onSelectedChange={setSelected}
/>

Render a summary row once every step has a value — the render-prop receives the step-id-keyed rule record:

<RuleAssembler
  steps={steps}
  summary={(rule) => (
    <>Rule: mark {rule.mark}, scope {rule.scope}.</>
  )}
/>

Read-only — render the assembled rule without letting the user edit:

<RuleAssembler
  steps={steps}
  selected={["entry", "global"]}
  editable={false}
  tone="success"
/>

Understanding the component

  1. One dropdown per step. Each step renders a labelled <select> with its options underneath. The student commits to one value per step; the component emits the next selection on every change.
  2. Controlled + uncontrolled. selected + onSelectedChange is the Radix controlled pattern. defaultSelected lets the component own the selection internally. If neither is provided, every step starts unset.
  3. Selection shape. selected is always one entry per step — null when unset, otherwise the picked option's value. The component pads or truncates incoming arrays to match steps.length so caller bugs never crash the layout.
  4. Summary slot. The optional summary render-prop fires only when every step has a non-null selection; it receives a Record<stepId, value> and renders whatever ReactNode the caller returns inside a tinted, aria-live="polite" status banner. The component never opines on what the rule means — the caller phrases it.
  5. Responsive grid. 1 column on narrow viewports; up to 4 columns on wider ones (capped so dropdowns stay readable). The grid auto-tightens based on steps.length.
  6. Hit target. Every dropdown is 44px tall (WCAG 2.5.8) so the selects stay tappable on mobile.
  7. Reduced motion. The summary's enter transition collapses to instant under prefers-reduced-motion: reduce. Selection state still updates; only the motion drops.

Props

PropTypeDefaultDescription
stepsRuleAssemblerStep[]requiredOrdered decisions. Each carries an id, label, optional description, and an options array.
selected(string | null)[]Controlled selection — one entry per step. Pair with onSelectedChange.
defaultSelected(string | null)[]Uncontrolled initial selection.
onSelectedChange(next: (string | null)[]) => voidFires with the next selection array on every change.
summary(rule: Record<string, string>) => ReactNodeRender-prop for a live summary row. Called only once every step has a value.
editablebooleantrueWhen false, dropdowns are disabled (read-only).
tone"default" | "accent" | "success" | "warning" | "error""accent"Highlight palette for the selected-step ring and summary tint.
transitionTransitionSPRINGS.smoothOverride summary transition. Reduced-motion users snap regardless.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The outer container exposes data-editable and data-complete so consumer apps can hook custom styles when every step is set.
  • Each dropdown is a real <select> element — full native keyboard, screen-reader, and platform-picker support comes for free. Each carries an explicit aria-label set from step.label.
  • Each select is wrapped in a <label> linked via htmlFor/id so clicking the heading focuses the dropdown.
  • The summary banner uses role="status" and aria-live="polite" — screen readers announce the assembled rule the moment it completes.
  • All selects enforce a 44px minimum height (per WCAG 2.5.8 AAA) so short labels still tap on mobile.
  • Selects expose data-state (empty / set) so consumer apps can hook validation styles per step.
  • Tone is never the only signal — set steps layer a thicker focus ring in the tone colour over the resting hairline border so colour-blind users see the distinction.
  • Motion respects prefers-reduced-motion: reduce — the summary enter transition collapses to instant.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/construction/RuleAssembler.tsx). The source bundled per-attempt scoring, audio cues, success/wrong feedback banners, and a "Try the rule" submit gate into a single lesson component. The library extract keeps only the construction primitive — N labelled dropdowns, controlled / uncontrolled selection, optional render-prop summary, five tones — and lets the caller compose any validation, sound, or submit-gate behaviour on top.