Skip to content

Sorter and Fill in the Blank Design Prototypes - #4162

Open
SonicScrewdriver wants to merge 30 commits into
mainfrom
dnd-design-prototypes
Open

Sorter and Fill in the Blank Design Prototypes#4162
SonicScrewdriver wants to merge 30 commits into
mainfrom
dnd-design-prototypes

Conversation

@SonicScrewdriver

@SonicScrewdriver SonicScrewdriver commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

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:

  • N/A so long as no other tests fail.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

npm Snapshot

Want to try this PR's changes before it merges? Comment /snapshot below and we'll publish an npm snapshot you can install right away.

@SonicScrewdriver SonicScrewdriver changed the title Add a Sorter render prototype: Scale and Timeline variants Sorter and Fill in the Blank Design Prototypes Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Size Change: +118 B (+0.02%)

Total Size: 514 kB

📦 View Changed
Filename Size Change
packages/perseus/dist/es/index.js 197 kB +118 B (+0.06%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 20.6 kB
packages/keypad-context/dist/es/index.js 1 kB
packages/kmath/dist/es/index.js 6.31 kB
packages/math-input/dist/es/index.js 98.7 kB
packages/math-input/dist/es/strings.js 1.63 kB
packages/perseus-core/dist/es/index.item-splitting.js 13.3 kB
packages/perseus-core/dist/es/index.js 28.7 kB
packages/perseus-editor/dist/es/index.js 106 kB
packages/perseus-linter/dist/es/index.js 10.3 kB
packages/perseus-score/dist/es/index.js 9.91 kB
packages/perseus-utils/dist/es/index.js 403 B
packages/perseus/dist/es/strings.js 12.9 kB
packages/pure-markdown/dist/es/index.js 1.39 kB
packages/simple-markdown/dist/es/index.js 6.12 kB

compressed-size-action

SonicScrewdriver and others added 18 commits September 3, 2026 23:48
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 and others added 12 commits September 3, 2026 23:57
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 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
Base automatically changed from dnd-kit-hookups to main September 4, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant