Skip to content

feat(Board): widget selection and rigid group movement - #1286

Merged
tenphi merged 4 commits into
mainfrom
feat-board-group-movement
Aug 7, 2026
Merged

feat(Board): widget selection and rigid group movement#1286
tenphi merged 4 commits into
mainfrom
feat-board-group-movement

Conversation

@tenphi

@tenphi tenphi commented Aug 7, 2026

Copy link
Copy Markdown
Member

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:

The ui-kit Board has no native multi-select or group-drag: it drags one widget at a time via React Aria useMove. This hook layers selection and a rigid group-move on top of it, without any upstream ui-kit change.

Every workaround in it names a missing engine feature — driving the controlled layouts prop from onDrag, suppressing Board's own onLayoutChange, force-flipping compact/preventCollision/allowOverlap for the gesture, re-compacting by hand afterwards, and hit-testing the marquee with a getBoundingClientRect per widget per frame. It also clamps each widget's x separately, so a group dragged into the left wall collapses and never recovers.

Selection

selectionMode="single" | "multiple", read via selectedKeys / 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/height while the board reflows, and a dragged host is swapped for an opacity: 0 stand-in. calcGridItemPosition derives the same rectangles exactly, with no forced reflow.

Group move

A new pure moveElements primitive in grid-core, plus group branches in the registry. The single-item path is kept verbatim as the length === 1 branch, so all 60 pre-existing Board tests pass unmodified.

  • Delta-based, group-clamped. Every mover gets the same delta and the delta is clamped against the whole group, so dragging into an edge parks the block instead of collapsing it.
  • All-or-nothing frames. A frame that cannot be placed is rejected, never partially applied — a partial delta is the shear bug.
  • Reflows by the board's own rules. The group is compacted like any other item, so on a vertical board it can no more be parked in empty space than a lone widget can, and neighbours close the gap in the same frame. Under compact="free"/null nothing compacts and the block stays where it was dropped.
  • One commit. A single onLayoutChange carries every mover plus every pushed neighbour.
  • Confined to one board. Cross-board group transfer is out of scope and enforced, rather than silently splitting a selection.

BoardInteractionInfo gains items / oldItems / placeholders; the existing singular fields are unchanged.

Three pre-existing bugs fixed in the blast radius

  • aria-roledescription sat on a role-less div — invalid ARIA — and its value was hardcoded English, against the repo's i18n rule. Hosts are now role="group" with a localized roledescription and a name from the new Board.Widget aria-label prop (falling back to qa, then the layout id).
  • useMove's pointer-down calls preventDefault(), which cancels native focus, 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, so declaring interactive content once protects both.
  • A widget clips its content only when it is a card. A borderless widget had overflow: hidden, cropping any outline a descendant drew for its focus or active state.

Accessibility

Board deliberately does not fake a collection role: aria-selected is only valid on option/gridcell/row/… , all of which require presentational children, and a Board widget hosts arbitrary interactive content. Instead each host is a named group, 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-announcer isn't a dependency and isn't resolvable — so the live region is hand-rolled on the pattern NotificationItem already uses. Six board.* keys added across all 12 locales.

Styling

Selection is an edge treatment — #primary-border border plus a 1bw #primary ring — because it reads as a focus-like state. outline stays 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

  • Behavior change: non-card widgets no longer clip. Opt out with widgetProps={{ overflow: 'hidden' }} — called out in the changeset.
  • The three regression tests for reported bugs were each verified to fail against the pre-fix code, not just pass after it.
  • pnpm test (1281 pass), pnpm lint, pnpm build, pnpm audit-defaults, pnpm audit-docs all clean. The one remaining docs-audit item (isChecked) is pre-existing on main.

🤖 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
Board now 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 through selectedKeys / 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 (default BOARD_SELECTION_CANCEL) skips interactive descendants and, on selectable boards, also blocks drag so inputs inside widgets stay focusable without extra dragCancel. onWidgetsDelete fires on Delete/Backspace without mutating layout.

In multiple mode, dragging a selected widget moves the whole selection via new moveElements in grid-core: shared delta, group edge clamping, reject-or-commit frames, compaction like a single widget, one onLayoutChange, and group drags stay on one board. BoardInteractionInfo adds plural items / oldItems / placeholders; singular fields unchanged. Placeholders are now an array (multi-preview during group drag).

Widget hosts get role="group", localized aria-roledescription, Board.Widget aria-label, selection styling via a selected modifier, data-board-widget-id / data-selected, and a board-owned polite live region for selection announcements. Six board.* 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.

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-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 69b4cbe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

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

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 7, 2026 3:05pm

Request Review

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-ec74d45.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 453.38 KB (+0.9% 🔺) Yes 🎉
Tree shaking (just a Button) 118.94 KB (0% 🟰) Yes 🎉

Click here if you want to find out what is changed in this build

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread src/components/layout/Board/Board.tsx
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>
@tenphi
tenphi merged commit 272a9e9 into main Aug 7, 2026
15 checks passed
@tenphi
tenphi deleted the feat-board-group-movement branch August 7, 2026 15:08
@tenphi tenphi mentioned this pull request Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant