feat(Board): widget selection and rigid group movement - #1286
Merged
Conversation
Pressing a widget selects it and arms a drag of the selection — selecting and grabbing are one gesture, so move and it drags, stay still and it was only a selection. Shift (or Cmd/Ctrl) toggles membership, dragging from empty canvas lassos, Space toggles the focused widget, Escape clears. With selectionMode="multiple", dragging any selected widget moves the whole selection as a rigid block: every widget travels by the same delta, the group clamps against the grid edge as a unit instead of collapsing into it, a frame that cannot be placed is rejected outright rather than partially applied, and the move commits through a single onLayoutChange. The group reflows by the board's own compaction rules, so it can no more be parked in empty space on a vertical board than a lone widget can. Selection behaves like focus: pressing another widget makes that the selection, and pressing a control inside a widget or moving focus off the board drops it. Also fixes three pre-existing Board bugs in the blast radius: - aria-roledescription sat on a role-less div, which is invalid, and its value was hardcoded English. Hosts are now role="group" with a localized roledescription and a name from the new Board.Widget aria-label prop. - useMove's pointer-down calls preventDefault(), so without a dragCancel an input inside a widget could not be focused or typed into. On a selectable board selectionCancel now gates dragging too. - A widget only clips its content when it is a card. A borderless widget had overflow: hidden, which cropped any outline a descendant drew for its own focus or active state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 69b4cbe The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📦 NPM canary releaseDeployed canary version 0.0.0-canary-ec74d45. |
Contributor
🧪 Storybook is successfully deployed!
|
Contributor
🏋️ Size limit report
Click here if you want to find out what is changed in this build |
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 59fd40d. Configure here.
AGENTS.md had no i18n section at all, despite `src/i18n/README.md` holding the rules and `locale-parity.test.ts` enforcing them in CI. Record the scope explicitly: strings a component renders — including `aria-label`, `aria-roledescription` and live-region announcements — are localized; stories, docs and tests use plain literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dget The lasso added every intersecting layout id to the selection without checking `isSelectable`, so it could pick up a widget that a press or Space would refuse. All three paths now resolve it through one helper. Also raises the "All" size budget to 466 kB. The feature costs ~4 kB gzipped — ~3.5 kB of engine plus ~0.5 kB for the six `board.*` strings across twelve eagerly-registered locales, measured by building with and without them. The Button budget is unchanged, so none of it reaches a consumer who does not import Board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ring Reverts the non-card `overflow: visible` default. A widget owns its grid cell and must not paint outside it: an auto-height container deliberately cannot grow while a drag is in flight, so a nested board reflowing to more rows spilled over its neighbours instead of being clipped. Clipping now holds regardless of `isCard` — a borderless widget has no drawn edge, but it still has a cell. The narrow problem that flip was meant to solve — a descendant's `outline` cropped at the widget edge — keeps a narrow fix: opt that widget into `overflow="visible"`, or draw the ring inset with a negative `outlineOffset`. Both are documented on `Board.Widget`. The board root also drew the browser's default focus ring. It takes focus programmatically (never by Tab — it is `tabIndex=-1`) as a parking spot, so that Escape and Delete still have somewhere to land after a delete unmounts the focused widget or a marquee leaves focus elsewhere. A ring on a parking spot announces a state the user cannot act on, so it is suppressed the same way `Dialog` suppresses it on its own focusable container. Re-adds the regression test for interactive descendants keeping native focus without a `dragCancel`, which was lost when the selection suite was rewritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged
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.

Moves multi-select and group-move into
Board. Cube Cloud implemented both on top of Board in app code (cubedevinc/cubejs-enterprise#13729); that hook's own doc comment says why it shouldn't live there:Every workaround in it names a missing engine feature — driving the controlled
layoutsprop fromonDrag, suppressing Board's ownonLayoutChange, force-flippingcompact/preventCollision/allowOverlapfor the gesture, re-compacting by hand afterwards, and hit-testing the marquee with agetBoundingClientRectper widget per frame. It also clamps each widget'sxseparately, so a group dragged into the left wall collapses and never recovers.Selection
selectionMode="single" | "multiple", read viaselectedKeys/defaultSelectedKeys/onSelectionChange(keys are layout item ids, always returned in layout order).Pressing a widget selects it on pointer-down and arms a drag of the selection — selecting and grabbing are one gesture, so move the pointer and it drags, stay still and it was only a selection. That is how every canvas tool resolves the press-is-both-a-drag-and-a-selection ambiguity, and it needs no modifier. Shift (or Cmd/Ctrl) toggles membership, dragging from empty canvas lassos, Space toggles the focused widget, Escape clears.
The modifier is read off the pointer event, never from tracked key state: a missed
keydown(key already down at page load, pressed while another window had focus) would otherwise swallow a deliberate press with no feedback.Selection behaves like focus — pressing another widget makes that the selection, and pressing a control inside a widget or moving focus off the board drops it.
Board owns the marquee rather than exposing hooks for it, because deciding what a band covers needs every widget's box and the DOM is the wrong source: hosts transition
inset/width/heightwhile the board reflows, and a dragged host is swapped for anopacity: 0stand-in.calcGridItemPositionderives the same rectangles exactly, with no forced reflow.Group move
A new pure
moveElementsprimitive ingrid-core, plus group branches in the registry. The single-item path is kept verbatim as thelength === 1branch, so all 60 pre-existing Board tests pass unmodified.verticalboard it can no more be parked in empty space than a lone widget can, and neighbours close the gap in the same frame. Undercompact="free"/nullnothing compacts and the block stays where it was dropped.onLayoutChangecarries every mover plus every pushed neighbour.BoardInteractionInfogainsitems/oldItems/placeholders; the existing singular fields are unchanged.Three pre-existing bugs fixed in the blast radius
aria-roledescriptionsat on a role-lessdiv— invalid ARIA — and its value was hardcoded English, against the repo's i18n rule. Hosts are nowrole="group"with a localized roledescription and a name from the newBoard.Widgetaria-labelprop (falling back toqa, then the layout id).useMove's pointer-down callspreventDefault(), which cancels native focus, so without adragCancelaninputinside a widget could not be focused or typed into. On a selectable boardselectionCancelnow gates dragging too, so declaring interactive content once protects both.overflow: hidden, cropping anyoutlinea descendant drew for its focus or active state.Accessibility
Board deliberately does not fake a collection role:
aria-selectedis only valid onoption/gridcell/row/… , all of which require presentational children, and a Board widget hosts arbitrary interactive content. Instead each host is a namedgroup, a selected one is described as "Selected", and every selection change is announced through a polite live region (once per marquee gesture, not once per frame). The reasoning and the gap are both recorded under "Not yet supported".There was no announcement infrastructure to reuse —
@react-aria/live-announcerisn't a dependency and isn't resolvable — so the live region is hand-rolled on the patternNotificationItemalready uses. Sixboard.*keys added across all 12 locales.Styling
Selection is an edge treatment —
#primary-borderborder plus a1bw#primaryring — because it reads as a focus-like state.outlinestays reserved for the real focus ring, so the two compose without a combined-state key. Verified legible in light, dark and@hc.Notes for review
widgetProps={{ overflow: 'hidden' }}— called out in the changeset.pnpm test(1281 pass),pnpm lint,pnpm build,pnpm audit-defaults,pnpm audit-docsall clean. The one remaining docs-audit item (isChecked) is pre-existing onmain.🤖 Generated with Claude Code
Note
Medium Risk
Large, behavior-heavy changes to Board drag/selection/focus and layout commit paths; mistakes could break grid editing or keyboard/pointer handling, though extensive tests and unchanged single-widget paths mitigate risk.
Overview
Boardnow supports widget selection and moving multiple widgets as one block, replacing app-level workarounds that fought single-item drag and per-widget clamping.With
selectionMode="single" | "multiple", consumers drive selection throughselectedKeys/defaultSelectedKeys/onSelectionChange(ids in layout order). A pointer-down on a widget selects and arms drag in one gesture; Shift/Cmd/Ctrl toggles, empty-canvas drag lassos (allowMarqueeSelection), Space toggles focus, Escape clears, and focus leaving the board clears selection.selectionCancel(defaultBOARD_SELECTION_CANCEL) skips interactive descendants and, on selectable boards, also blocks drag so inputs inside widgets stay focusable without extradragCancel.onWidgetsDeletefires on Delete/Backspace without mutating layout.In multiple mode, dragging a selected widget moves the whole selection via new
moveElementsingrid-core: shared delta, group edge clamping, reject-or-commit frames, compaction like a single widget, oneonLayoutChange, and group drags stay on one board.BoardInteractionInfoadds pluralitems/oldItems/placeholders; singular fields unchanged. Placeholders are now an array (multi-preview during group drag).Widget hosts get
role="group", localizedaria-roledescription,Board.Widgetaria-label, selection styling via aselectedmodifier,data-board-widget-id/data-selected, and a board-owned polite live region for selection announcements. Sixboard.*strings land in all 12 locales; bundle size limit bumps ~4 kB for Board-only consumers.Docs, stories, and large test suites cover selection, marquee, group move, and regressions (nested board selection bubbling, vertical compaction during group drag).
Reviewed by Cursor Bugbot for commit 69b4cbe. Bugbot is set up for automated code reviews on this repo. Configure here.