Sorter and Fill in the Blank Design Prototypes - #4162
Open
SonicScrewdriver wants to merge 30 commits into
Open
Conversation
Contributor
npm SnapshotWant to try this PR's changes before it merges? Comment |
Contributor
|
Size Change: +118 B (+0.02%) Total Size: 514 kB 📦 View Changed
ℹ️ View Unchanged
|
SonicScrewdriver
marked this pull request as ready for review
September 2, 2026 02:09
SonicScrewdriver
force-pushed
the
dnd-kit-hookups
branch
from
September 3, 2026 23:22
1dcdffd to
982f18f
Compare
SonicScrewdriver
force-pushed
the
dnd-design-prototypes
branch
from
September 3, 2026 23:23
79480f3 to
96f4b9b
Compare
SonicScrewdriver
force-pushed
the
dnd-kit-hookups
branch
from
September 3, 2026 23:36
982f18f to
c31874b
Compare
SonicScrewdriver
force-pushed
the
dnd-design-prototypes
branch
from
September 3, 2026 23:37
96f4b9b to
817c7d5
Compare
… out into a separate component next)
The pieces every bank-and-blanks widget shares, split out of the prototype squash so the design prototypes can stack on top: - PerseusDndProvider gestures: pointer activation constraints that tell a click (menu) from a drag on the same tile, and removal of dnd-kit's Accessibility plugin in favor of the widgets' own announcements. This needs @dnd-kit/dom as a direct dependency, version-locked to @dnd-kit/react's range. - tile-placements: the placement model (place, swap-to-bank, clear, single/multi use) as pure functions. - drag-ids, useTileMoveActions, useWidestTileWidth: drag-instance ids, the shared move/clear actions with announcements and focus handling, and the widest-tile measurement that sizes empty blanks. - AnswerTile and Blank restyled per the design (hover and grabbed states, placed-tile modes, drag-enter highlight, narrow-mode rules) with additive props for the placed-tile treatments. - ChoiceBank: the whole card is the drop target, and rows center. The provider playground story is replaced by the component demos in the prototypes branch stacked above.
useTileMoveActions now owns the drag-end translation both widgets duplicated, and guards a tile dropped on its own blank: it stays put with no placement change and no announcement. After a menu action, focus falls back to the placed tile's menu when the bank has emptied, instead of dropping to the document body (WCAG 2.4.3). Widgets register placed menus through placedMenuRef. BlankComponent exposes data-display-type: the compiled CSS-module class names carry no trace of the local names, so outside rules that key on the display type need a build-stable attribute. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
A pen press on the menu button now stays a click: the click-vs-drag branch matched only mouse input, so a stylus press held 200ms became a drag and the menu never opened. preventActivation no longer disables dnd-kit's interactive-element guard everywhere. The menu button can still start a drag, but a press on a link inside tile content stays a click. A multi-use tile's bank copy dropped on a blank already holding that tile is a no-op: no placement change, no announcement. A pending menu focus request now clears when a later action supersedes it, so a declined controlled update cannot yank focus on the next change. The drag ids, the move actions, and the widest-tile hook get unit tests: they are this branch's densest logic and had none. The tileId and placedTileId docs state the encoded drag-id contract: following the old wording made every drop a silent no-op.
Drag ids encoded the tile and its blank into a string that the drop
handler decoded, which depended on blank ids never containing the
separator. Nothing enforced that, and a blank id with a separator
mis-parsed into the wrong blank.
A dragged tile now reports {tileId, fromBlankId} through dnd-kit's
typed data field, and the drop handler reads it. AnswerTile takes the
tile id and its blank and derives both the drag id and the payload, so
widgets no longer build encoded ids. Drag ids stay only to keep one
tile's bank and blank copies distinct.
Also unexports countPlacements, which has no caller outside its
module, and adds the changeset this PR needs.
dnd-kit types its data field as an open record, so both readers cast it. One readTileDragData function now checks the shape once, and returns null for a drag another component started.
Both components built inline style objects for a custom property, each with its own cast and lint exception, and the blank also branched on whether its width was a number or a string. cssVariable() holds the cast, matching how dashedStrokeStyle handles the same limitation in the graph code. The blank's minWidth is a number of pixels, which is all any caller passes, so the branch is gone.
The class list mixed three display-type ternaries with two state ternaries and two bracketed class names. A lookup maps the display type to its classes, the derived state has a name, and the CSS class names are camel case like the sibling module, so every class reads as a property. BlankComponent also takes keepsWidthWhenFilled here, beside the component it configures, rather than in the prototype above. Narrow mode sizes the blank against the answer zone with container units, so the renderer's shrink-to-fit wrapper no longer collapses it. The display type gets a named type, and the dead data-display-type attribute is replaced by the data-blank hook the line-rhythm rule actually uses.
Both components composed their classes inline in JSX, which pushed the element's real attributes below a long list of conditions. The class string is now a named value above the return, as sortable and the radio component do, so the markup reads as markup.
BlankComponent no longer takes placedTileId. A blank holds one tile and placements only change when a drag ends, so a drag that reports it came from this blank started from this blank's tile: the fromBlankId in the drag payload already answers the question the prop was there to answer. ChoiceBank no longer takes bankId, and holds its tiles as children rather than as an array of AnswerTile props. The bank had to take the tiles as data only so it could derive a droppable id from them; that id is now one exported constant, which the widgets' drag-end handling imports instead of each declaring its own. Every widget mounts its own PerseusDndProvider, and dnd-kit scopes ids per provider, so one shared value stays unique for two widgets on a page. The optional id it replaces could not be recognized by the widget that had to match it.
The helper was a module, a test, and an eslint-disable wrapped around a one-property object literal — a whole function for a TS cast. The radio widget already handles the same gap (CSSProperties has no keys for CSS custom properties) with a plain literal and a @ts-expect-error on the property line, which needs no cast and so no lint exception. Both call sites now match it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
handleMove and handleClear took a viaMenu boolean that every widget passed as a bare `true`, with only the internal drag path passing false. The flag is gone from the API: the two public functions are the menu path and always return focus to a menu, while handleDragEnd uses private variants that leave focus alone, since a drag is a pointer interaction. The pending-focus ref now carries a named MenuFocusRequest with an explicit fallbackBlankId instead of an empty-object sentinel, and both menu-ref callbacks keep a stable identity across renders, so React no longer detaches and reattaches them on every render. placeTile is rewritten in intent form — copy, remove the tile's old placements, set the target — instead of one loop whose three `continue` cases the reader had to invert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
Nothing on this branch uses useWidestTileWidth: its consumers are the FITB and Sorter prototypes. It also embodies the hidden-measurement approach that is still under discussion, so it should live and die with the prototypes rather than in the shared layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The shim extends MouseEvent, which only exists in the jsdom test environment. Suites that run in the node environment (the utils scripts) crashed at setup with "Class extends value undefined". Those suites have no DOM to dispatch events into, so they need no shim: guard on MouseEvent existing, the same way the file's older shims guard on Node. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The restack dropped the merge commits whose diffs adapted this branch to the lower branches' API changes: the tile's scoring prop and section renderers, and the dnd-kit shims' coexistence with the font-loading shim. This puts that content back, byte-identical to the branch before the rebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The prop moved down to the answer-tile branch with a fuller comment; carrying the older wording here would make this branch's diff read as rewriting it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
SonicScrewdriver
force-pushed
the
dnd-kit-hookups
branch
from
September 3, 2026 23:49
c31874b to
0413d1b
Compare
SonicScrewdriver
force-pushed
the
dnd-design-prototypes
branch
from
September 3, 2026 23:50
817c7d5 to
7dad3e0
Compare
From PR review: nothing exercised handleClear directly, so its guard — clearing an empty blank must not update placements or announce — was unvalidated, and the menu path's focus return after a clear had no coverage either. The new handleMove test names the one already-in-the-target case that is deliberately not a no-op: a multi-use tile's placed copy moved onto a blank holding the same tile empties the source blank, collapsing the copies. The bank-copy no-op guard was already covered by the drag-end tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The render side of the upcoming Fill in the Blank widget: an answer zone (content with inline blank widgets) above a choice bank. The parent owns the controlled placements and feeds the blank widgets through FillInTheBlankContext, which the blank widget consumes. Two filled-blank treatments render for design comparison, switchable per story: "hug" follows the spec (the blank shrinks to its tile and grows on hover to reveal the menu) and "fixed" keeps the widest-tile width so nothing reflows. The stories mirror the Figma frames; plan and edge-case notes live in widgets/fill-in-the-blank/notes/.
Learners order tiles from a choice bank into discrete blanks along a labeled legend. Scale spreads the blanks on a spectrum between two labels; Timeline hangs them off a time axis with connector stems, alternating above and below from five blanks (3/2, 3/3, 4/3). Placed tiles fill their blank and keep their menu visible, unlike Fill in the Blank. A horizontal sorter falls back to vertical when the row cannot house every slot at its minimum width. The stories mirror the Figma demo frames: the loans scale and the 1944-1946 timeline.
Both widgets use the shared drag-end handler, which guards a tile dropped on its own blank, and register their placed tiles' menus so focus survives an emptied bank (with a regression test). The narrow-mode wrapper rule keys on the blank's data-display-type instead of [class*="super-sub"]: the published build compiles CSS-module classes to hashed names, so the class-substring guard matched nothing outside Storybook. The staggered timeline zeroes both grid gaps: the column gap widened every 2-column slot span and doubled the spacing between same-row neighbors, which the item padding already provides. The FITB tests pass the values they assert on explicitly instead of relying on the shared generator defaults, per the repo testing guide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The staggered-timeline width formula matches the gapless grid the CSS actually renders: it double-counted the slot gap, so the vertical fallback fired 32-48px before the horizontal layout stopped fitting. The answer-zone typography rules get a TODO(LEMS-4304): they key on div.paragraph, which the new renderer no longer emits, so the 48px line rhythm needs a new hook when the renderer upgrade lands. The Sorter tests pass the values they assert on explicitly through a new sorter.testdata generator, per the repo testing guide. Three comments now match the code they describe (legend position, the small-value size check, a doubled word in the plan notes).
The blank ids come from perseus-core's getWidgetIdsFromContentByType instead of a local marker regex. The local copy accepted a narrower id than the renderer does, so the two could disagree. The measurement container's ref is stable, so React stops detaching and reattaching it every render, which re-measured every tile each time and defeated the hook's measure-once contract. Both prototypes pass the tile id and its blank to AnswerTile, which derives the drag id. The unused index barrels are removed, and this PR gets the changeset it needs.
The fixed-width variant used a data attribute on the answer zone that the tile and blank stylesheets reached back into. It now flows as props: the tile takes fillsBlank, the blank takes keepsWidthWhenFilled through the render info, and the answer zone reuses the class that already turns the narrow rules off. Narrow mode no longer restyles the renderer's widget wrapper. The blank sizes itself against the named container with cqi units, so the inline-block wrapper follows on its own. That removes the :has() rule that keyed on the blank's test id, and the display-type attribute it needed (LEMS-4448). The line-rhythm rule still keys on the test id. Making it unconditional spaces out the equation prompts, so the paragraphs that hold a blank must still be selectable.
A blank is 48px tall inside 22px text lines, so a line that holds one crowds the lines around it unless the whole paragraph takes the taller rhythm. Selecting those paragraphs needs a hook on the blank: CSS Modules compile class names to hashes, and the markup between the widget and the blank belongs to the renderer. The blank now carries data-blank for that. The rule keyed on the blank's test id before, which made styling depend on a test hook.
Nothing outside the module used the exported type once the unused index barrel went away.
Adapts Fill in the Blank and the Sorter to the move-actions hook's new surface: handleMove and handleClear lose their viaMenu boolean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The Fill in the Blank schema on LEMS-4321 types PerseusAnswerTile's imageHeight as number, with the editor's 24-96 presets documented rather than encoded. The prototype's literal union would reject valid schema data once the real widget feeds it in, so it matches the schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
The restack dropped the merge commits whose diffs adapted this branch to the shared layer's changes — the blank's placedTileId removal and derived drag-out check — and the merge that kept useWidestTileWidth here when the shared layer dropped it. This puts that content back, byte-identical to the branch before the rebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiPPDtbAjgqBXDnm8kL7JA
SonicScrewdriver
force-pushed
the
dnd-design-prototypes
branch
from
September 3, 2026 23:59
7dad3e0 to
d658fa3
Compare
SonicScrewdriver
added a commit
that referenced
this pull request
Sep 4, 2026
## Summary: This PR wires the Drag-and-Drop component family to dnd-kit, adding the shared drag layer the FITB/Sorter widgets will build on. The design prototypes that consume this live in the next PR up the stack. ([See here](#4162)) **New files/functions:** - **`PerseusDndProvider`** — our configured `DragDropProvider`. Currently only sets up the Pointer sensor (a still press on the menu button opens the menu; 5px of movement starts a drag; touch drags on long-press). - **`BlankComponent`** — the drop slot. While its own tile drags away it shows the empty blank underneath, so the slot looks like it was there all along. - **`tile-placements.ts`** — pure placement transitions (place with displacement, clear, remaining-uses/bank membership for single- and multi-use tiles). The widget owns the state; drag and menu paths call the same functions. - **`use-tile-move-actions`** — shared move/clear/drag-end handling: updates the controlled placements, announces each result via the WB Announcer, and (menu path only) returns focus to a menu after the update renders. - **`drag-ids.ts`** — the typed drag payload (`tileId` + optional `fromBlankId`) carried on dnd-kit's `data`, plus the unique-id scheme that lets one tile appear in the choice bank and a blank at the same time (for multi-use tiles). **Changes to existing components:** - `AnswerTile` is now draggable (scored tiles aren't), and gains presentation props for placed tiles (`hidesMenuAtRest`, `fillsBlank`, `compact`, `imageHeight`). - `ChoiceBank` is now the "return to bank" drop target, via one shared `CHOICE_BANK_DROP_ID`. - The `blank` widget renders `BlankComponent`, restyled per Figma. ## Notes for reviewers - Strings still live in `temp-strings.ts` until the surface stabilizes. Issue: LEMS-4369 ## Test plan: - Tests pass Author: SonicScrewdriver Reviewers: SonicScrewdriver, anakaren-rojas, nishasy, handeyeco, ivyolamit, catandthemachines Required Reviewers: Approved By: anakaren-rojas Checks: ✅ 10 checks were successful, ⏭️ 1 check has been skipped Pull Request URL: #4128
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
This code is still largely unreviewed. Feel free to add a review while I continue to take a look at it, but these prototypes are temporary examples of the full working functionality.
Test plan: