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.
Installation
npx shadcn@latest add https://craftbits.dev/r/rule-assembler.jsonUsage
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
- One dropdown per step. Each
steprenders a labelled<select>with itsoptionsunderneath. The student commits to one value per step; the component emits the next selection on every change. - Controlled + uncontrolled.
selected+onSelectedChangeis the Radix controlled pattern.defaultSelectedlets the component own the selection internally. If neither is provided, every step starts unset. - Selection shape.
selectedis always one entry per step —nullwhen unset, otherwise the picked option'svalue. The component pads or truncates incoming arrays to matchsteps.lengthso caller bugs never crash the layout. - Summary slot. The optional
summaryrender-prop fires only when every step has a non-null selection; it receives aRecord<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. - 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. - Hit target. Every dropdown is 44px tall (WCAG 2.5.8) so the selects stay tappable on mobile.
- Reduced motion. The summary's enter transition collapses to instant under
prefers-reduced-motion: reduce. Selection state still updates; only the motion drops.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
steps | RuleAssemblerStep[] | required | Ordered 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)[]) => void | — | Fires with the next selection array on every change. |
summary | (rule: Record<string, string>) => ReactNode | — | Render-prop for a live summary row. Called only once every step has a value. |
editable | boolean | true | When false, dropdowns are disabled (read-only). |
tone | "default" | "accent" | "success" | "warning" | "error" | "accent" | Highlight palette for the selected-step ring and summary tint. |
transition | Transition | SPRINGS.smooth | Override summary transition. Reduced-motion users snap regardless. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The outer container exposes
data-editableanddata-completeso 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 explicitaria-labelset fromstep.label. - Each select is wrapped in a
<label>linked viahtmlFor/idso clicking the heading focuses the dropdown. - The summary banner uses
role="status"andaria-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.