Workshop Kit · widget reference
Shippedmenu-builder
A reorderable dish list with inline name + price editing. Add / remove / reorder up to a configurable max (default 12); the min (default 3) keeps the operator from accidentally publishing an empty menu page. Same keyboard-first reordering model as drag-rank — up/down buttons, no HTML5 drag — plus inline editing for every field. Writes to dishes[]; the L14 generator renders the same array as menu.html.
Live demo
Click Add to grow the list; type into name/price fields; reorder with the up/down buttons; remove with ×. Saves on every change. The L8 lesson seeds operators with a "menu shortlist" framing — 5-8 dishes that demonstrate the kitchen's range, not the whole menu.
data-context-key="dishes" data-min="3" data-max="12"
Contract
Drop a <section> with the data-context-key and the widget renders the editable list. Optional data-min and data-max set the hard caps; the Add button disables at max, the Remove button disables at min.
| Attribute | Type | Purpose |
|---|---|---|
data-widgetRequired |
literal | Always "menu-builder". |
data-context-keyRequired |
string | Where the dish array lands in MuntinContext. The shape is [{ name: string, price: string }, …]. L8 uses "dishes" by convention; the L14 generator reads from that exact key. |
data-min |
integer | Minimum row count. Default 3. The Remove button on each row disables when length === min, preventing accidental deletion below the floor. |
data-max |
integer | Maximum row count. Default 12. The Add button disables when length === max. Why a cap: a generated menu page with 40 dishes is a wall of text that loses to a phone scroll; the operator prints the long version on paper. |
What it writes: the array commits to the named context key on every name/price edit, add, remove, and reorder. Price values pass through a strict regex (/^\$?\d{1,4}(?:\.\d{1,2})?$/ after whitespace stripping) at render time in the generator — the widget itself accepts any string, but malformed prices render blank on the deployed menu page.
Markup
Keyboard model
Same posture as drag-rank: no HTML5 drag-and-drop, every reorder goes through a button. Plus inline-editable fields:
- Tab walks the rows; within a row, Tab moves between name, price, up, down, remove in source order.
- Typing into name or price commits on every change (debounced 250ms via the engine).
- Up / down buttons are real
<button>elements. Pressing one moves the row one rank; focus stays on the same button after reorder (so you can keep pressing to move multiple positions, just like drag-rank). - Remove deletes the row immediately — no confirm. Operators can re-add; the polite-region announcement names which dish was removed so the action isn't silent.
- Add appends a new empty row at the end and focuses the name field. Disabled when length === max.
- Polite live region announces reorders ("Pollo asado moved to rank 1 of 6"), additions ("Added empty row, now 4 of 12"), and removals.
Why a shortlist, not the full menu
The temptation in L8 is to enter every single dish on the operator's printed menu. The widget's hard max of 12 nudges away from that. Reasons:
- Diners decide what to order in the dining room, not on the website. The website's job is to show range, not to be the menu.
- Long menus drift faster than short ones — a 12-item shortlist gets re-curated quarterly; a 40-item full menu never gets updated.
- The L14 generator emits
menu.htmlas a single column with name + price. 40 of those is a vertical wall; 8 is a scannable highlight reel. - The operator who wants the long version can print it. The website should make the diner want to come in.
Where it ships
L14's generator reads the same dishes array and renders menu.html in the operator's palette.
Source
/tools/_shared/workshop/menu-builder.js — ~290 LOC. Exports { tag, contextKeys, mount, serialize } per the Workshop Kit widget contract. Reads min/max caps, hydrates from saved state (or seeds with three empty rows on first mount), wires per-row event handlers for name/price edits + reorder buttons + remove, manages the Add button's disabled state, and announces every action via the polite live region.
Focus management on reorder: after pressing up/down, focus stays on the same direction button (not the row's first input). This matches drag-rank's pattern — operators who want to keep moving an item can keep pressing without re-tabbing.