diff --git a/.changeset/board-selection-and-group-move.md b/.changeset/board-selection-and-group-move.md
new file mode 100644
index 000000000..816abd9e2
--- /dev/null
+++ b/.changeset/board-selection-and-group-move.md
@@ -0,0 +1,55 @@
+---
+'@cube-dev/ui-kit': minor
+---
+
+Add widget selection and rigid group movement to `Board`.
+
+Set `selectionMode="single" | "multiple"` and read the selection with
+`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. Shift (or Cmd/
+Ctrl) toggles membership, dragging from empty canvas lassos
+(`allowMarqueeSelection`), Space toggles the focused widget, and
+Escape clears.
+
+Selection behaves like focus: it tracks what the user is working with and moves on
+as soon as they touch something else — pressing another widget makes that the
+selection, and pressing an interactive control inside a widget or moving focus off
+the board drops it entirely.
+
+With `"multiple"`, dragging any selected widget moves the whole selection as a
+rigid block that reflows by the board's own rules — the same compaction a single
+widget gets, so a group can never be parked in empty space on a `vertical` board
+and the widgets around it close the gap in the same frame. 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`. Arrow keys move the group too. `BoardInteractionInfo`
+gains `items`, `oldItems` and `placeholders` describing the whole gesture; the
+existing `item` / `oldItem` / `placeholder` fields are unchanged, and a board with
+no selection behaves exactly as before.
+
+The `selectionCancel` selector (board- or widget-level, defaulting to the exported
+`BOARD_SELECTION_CANCEL`) marks interactive descendants; `[data-no-select]` opts
+out a custom control. On a selectable board it also gates dragging, which fixes a
+long-standing trap: `useMove`'s pointer-down calls `preventDefault()`, so without
+a `dragCancel` an `input` inside a widget could not be focused or typed into.
+Selected widgets are drawn with a `#primary-border` border and a `#primary` ring —
+an edge treatment rather than a fill, since selection reads as a focus-like state;
+`outline` stays reserved for the real focus ring. Widgets get a `selected`
+modifier you can restyle through `widgetProps.styles`.
+
+`onWidgetsDelete` reports a Delete/Backspace press with a
+non-empty selection. Board never mutates the layout itself, so removal stays
+yours to implement and to make undoable.
+
+Accessibility: widget hosts are now `role="group"` with an accessible name from
+the new `Board.Widget` `aria-label` prop (falling back to `qa`, then the layout
+id). `aria-roledescription` is now localized rather than hardcoded English — it
+was previously also invalid, sitting on a role-less element. Selected widgets are
+described as "Selected", and selection changes are announced through a polite live
+region.
+
+Widget hosts also expose `data-board-widget-id` and `data-selected`.
diff --git a/.size-limit.cjs b/.size-limit.cjs
index b4838b73a..05081d4d0 100644
--- a/.size-limit.cjs
+++ b/.size-limit.cjs
@@ -20,15 +20,20 @@ module.exports = [
}),
);
},
- // 460.16 kB at the time of writing. Raised from 460 kB for Tasty v3, which
- // it exceeded by 161 B — its new dev diagnostics ship in every bundle,
- // because `isDevEnv()` is evaluated at runtime so one build serves dev and
- // production. Headroom is deliberately small so real bloat still trips the
- // budget.
+ // 464.27 kB at the time of writing. Raised from 462 kB for Board selection
+ // and group movement: ~3.5 kB of engine (a rigid multi-item move primitive,
+ // selection state, marquee hit-testing, a live region) plus ~0.5 kB for the
+ // six `board.*` strings across twelve locales, which are all registered
+ // eagerly. Measured by building with and without the locale keys.
+ //
+ // The Button budget below is unchanged, which is the check that matters:
+ // none of this reaches a consumer who does not import `Board`.
+ //
+ // Headroom is deliberately small so real bloat still trips the budget.
//
// Note when checking locally: `size-limit` bundles the built `./dist`, it
// does not build. Run `pnpm build` first or you will measure a stale bundle.
- limit: '462kB',
+ limit: '466kB',
},
{
name: 'Tree shaking (just a Button)',
diff --git a/AGENTS.md b/AGENTS.md
index cca30c8f6..fc6a6d782 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -117,6 +117,28 @@ See `src/stories/CreateComponent.docs.mdx` (Storybook → **Getting Started / Cr
See `src/stories/Usage.docs.mdx` (Storybook → **Getting Started / Usage**) for units, base/spacing/size/shadow/layout tokens, color tokens, typography presets, themes, recipes, modifiers, state syntax, icons, and the form system.
+## i18n
+
+Full rules in [`src/i18n/README.md`](src/i18n/README.md). The short version:
+
+- **Scope: strings a component renders.** Anything the component itself puts in
+ front of a user — visible text, `aria-label`, `aria-roledescription`, live-region
+ announcements, `title` — goes through `useI18n()`:
+ `t('component.key', 'English default')`. The inline English stays as a
+ belt-and-braces fallback.
+- **Not for stories, docs, or tests.** Storybook stories, `.docs.mdx`, and specs are
+ demo and fixture copy, not product UI. Use plain literals there — a locale key
+ that exists only to feed a story is noise in twelve files, and a test that reads
+ its expectation from the bundle asserts nothing about the string.
+- **Component props that expose a label stay overrides** that win over the
+ translated default: `emptyLabel = t('...', 'No items')`.
+- **All 12 locales, every time.** `en-US` is the source of truth;
+ `locale-parity.test.ts` fails CI if any locale's key set or `{{interpolation}}`
+ tokens diverge. Interpolation is `{{double}}` braces with no ICU, so plurals need
+ separate keys rather than a plural rule.
+- **If a string doubles as a DOM selector**, build the selector from the same
+ `t(...)` value so the two cannot drift when the language changes.
+
## TypeScript & Exports
- **Module augmentation:** `src/tasty-augment.d.ts` extends `@tenphi/tasty` with project-specific color tokens, preset names, and theme names.
diff --git a/src/components/layout/Board/Board.docs.mdx b/src/components/layout/Board/Board.docs.mdx
index d24193c23..39bc61df8 100644
--- a/src/components/layout/Board/Board.docs.mdx
+++ b/src/components/layout/Board/Board.docs.mdx
@@ -40,6 +40,16 @@ clipped by an ancestor's `overflow: hidden`.
widget is placed exactly where you drop it and its neighbours are never pushed
or swapped; without `allowOverlap` a drop onto an occupied cell is blocked, and
with `allowOverlap` widgets may stack for a fully free canvas.
+- **Selection** — off by default. Pressing a widget selects it 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 just a selection. Shift (or
+ Cmd/Ctrl) toggles a widget's membership. Keys are layout
+ item ids, scoped to a single board, and always reported in layout order.
+- **Selection is focus-like** — it tracks what the user is working with, and
+ touching anything else moves it on: pressing another widget makes *that* the
+ selection, pressing a control inside a widget drops it, and so does focus
+ leaving the board. With `'multiple'`, pressing an already-selected widget
+ keeps the selection so a drag moves the whole block.
## Properties
@@ -66,6 +76,13 @@ clipped by an ancestor's `overflow: hidden`.
- **`dragHandle`** `string` — CSS selector for the only elements from which a pointer drag may start. Can be overridden per widget.
- **`showGridLines`** `boolean | 'drag'` (default: `false`) — Show grid lines behind the widgets. `true` always, `'drag'` only while a widget is being dragged or resized. A nested board that does not set this inherits an enabled ancestor's grid lines, showing its own while a drag is in progress.
- **`isAligned`** `boolean` (default: `false`) — Align a nested board with its ancestor `Board`'s layout. Only takes effect when the board is nested inside another `Board`'s widget. When set, every cell matches the parent's cell size exactly: the board inherits the parent's column pitch (deriving its own column count from its measured width so cells stay parent-sized as the container is resized) and uses the parent's row height verbatim. It never shrinks rows to fit — pair it with an `isAutoHeight` container so the widget grows to fit its rows at that height. `cols`/`rowHeight` then act as fallbacks used only until the parent metrics resolve.
+- **`selectionMode`** `'none' | 'single' | 'multiple'` (default: `'none'`) — Whether widgets can be selected, and how many at a time. `'multiple'` also enables the marquee and rigid group movement.
+- **`selectedKeys`** `string[]` — Controlled selection. Keys are layout item ids (`LayoutItem.i`).
+- **`defaultSelectedKeys`** `string[]` — Initial selection for uncontrolled usage.
+- **`onSelectionChange`** `(keys: string[]) => void` — Called when the selection changes. Keys are deduped and returned in the board's layout order, never in click order.
+- **`selectionCancel`** `string` (default: `BOARD_SELECTION_CANCEL`) — CSS selector marking interactive descendants. A press on one never selects and never starts a drag, so the control keeps its own click *and* its native focus; it also drops the selection, since interacting with a widget's content means the user has moved on. On a selectable board this doubles as the drag guard, so form controls stay usable without also configuring `dragCancel`. The default covers native form controls, links, and the common ARIA widget roles, plus `[data-no-select]` as an escape hatch. Pass `''` to disable the guard. Can be overridden per widget.
+- **`allowMarqueeSelection`** `boolean` (default: `selectionMode === 'multiple'`) — Draw a rubber-band selection when a drag starts on empty board space. A press on a widget selects and drags instead, so the lasso owns empty canvas only. Hold Shift or Cmd/Ctrl to add to the existing selection rather than replacing it.
+- **`onWidgetsDelete`** `(keys: string[]) => void` — Called when Delete/Backspace is pressed with a non-empty selection and focus is not in an editable field. **Board never mutates the layout itself** — removing the widgets is yours to do, which is what lets you make it undoable. Board only handles these keys when this handler is set.
- **`constraints`** `LayoutConstraint[]` — Grid/item layout constraints.
- **`width`** `number` — Explicit width (disables measurement; useful for SSR/tests).
- **`widgetProps`** `Partial` — Default props applied to every widget this board hosts (per-widget `Board.Widget` props override these). Use it to add a card border to every widget (`widgetProps={{ isCard: true }}`) or set shared `styles`/sizing defaults without repeating them on each widget.
@@ -90,6 +107,16 @@ clipped by an ancestor's `overflow: hidden`.
- **`isAutoHeight`** `boolean` (default: `false`) — Grow this widget's height in its board to fit its content (only ever increases, never shrinks). Pair it with a nested `Board isAligned` so the container expands until the inner board's rows fit at the parent's row height. It also pins the widget's resize floor: you cannot drag the widget shorter than the height its content currently needs.
- **`dragCancel`** `string` — Override the board's `dragCancel` selector for this widget.
- **`dragHandle`** `string` — Override the board's `dragHandle` selector for this widget.
+- **`isSelectable`** `boolean` — Disable selection for this widget while the board's `selectionMode` is on.
+- **`selectionCancel`** `string` — Override the board's `selectionCancel` selector for this widget.
+- **`aria-label`** `string` — Accessible name for the widget. Falls back to `qa`, then the layout item id — both developer-facing, so set this whenever the widget is user-visible. Also what the selection announcement reads out.
+
+A widget clips its content to its grid cell, card or not — otherwise a nested
+board with more rows than currently fit, or a mid-drag reflow, would paint over
+its neighbours. The cost is that a descendant's `outline` is cropped at the edge
+(an outline is clipped by an *ancestor's* overflow, not its own), so a widget
+whose content must paint outside — a control drawing its own active ring — sets
+`overflow="visible"`, or draws the ring inset with a negative `outlineOffset`.
### Board.Provider
@@ -127,6 +154,48 @@ container's measured width, mirroring react-grid-layout's `Responsive` +
A breakpoint with no layout is synthesized from the nearest available one,
corrected into bounds and compacted for the target column count.
+### Modifiers
+
+Every widget host exposes these as `data-*` attributes, and any style map passed
+through `widgetProps.styles` or a per-widget `styles` resolves against them.
+
+- **`selected`** — the widget is in the board's selection.
+- **`card`** — the widget draws a card border (`isCard`).
+- **`draggable`** — the widget can be dragged and no drag is in flight.
+- **`drag`** — the widget is being dragged.
+- **`floating`** — this is the clone floating in the drag overlay.
+- **`resizing`** — the widget is being resized.
+- **`static`** — the layout item is `static`.
+- **`hovered`**, **`focus-visible`** — pointer and keyboard-focus states.
+- **`settled`** — the board has painted its widgets once, so position changes animate.
+
+Selection is an *edge* treatment — a `#primary-border` border plus a `#primary`
+ring — because it reads as a focus-like state rather than a fill. `outline` stays
+reserved for the real focus ring, which is what keeps the two legible together:
+they use different tokens and the focus outline sits one border-width further
+out. To restyle it, override the same keys:
+
+```jsx
+
+```
+
+### Data attributes
+
+Supported hooks for tests, E2E, and building your own selection overlay:
+
+- **`data-board-id`** on the board element — the board's `id`.
+- **`data-board-widget-host`** on every widget host — presence only, "this is a widget host".
+- **`data-board-widget-id`** on every widget host — which widget.
+- **`data-selected`** on a selected host.
+
### Base Properties
Supports [Base properties](/docs/getting-started-base-properties--docs).
@@ -257,14 +326,84 @@ touch drags capture the pointer, so they do not trigger it.
+### Selection
+
+Click to select, Shift/Cmd/Ctrl-click to add or remove, and drag from empty board
+Press a widget to select it, Shift-press to add or remove one, and
+drag from empty board space to lasso. Because the press both selects and arms the
+drag, grabbing a widget you have not selected simply makes it the selection and
+moves it; grabbing one that *is* selected moves the whole block rigidly — keeping
+its shape horizontally, clamping against the grid edge as a unit rather than
+collapsing, and committing once.
+
+A group reflows exactly like a single widget: it is compacted by the same rules,
+so on a `vertical` board it can no more be parked in empty space than one widget
+can, and the widgets around it close the gap in the same frame. Under
+`compact="free"` (or `null`) nothing compacts, so the block stays precisely where
+it was dropped.
+
+
+
+### Interactive content keeps its clicks
+
+
+
+### Controlled selection and deleting widgets
+
+
+
+### Restyling the selection
+
+
+
## Accessibility
-- Widgets are focusable when draggable. Press Tab to focus a widget,
- then use the arrow keys to move it one cell at a time. Arrow keys only move
- the widget when the widget host itself is focused — not when focus is inside
- a nested control (e.g. an input or textarea).
-- Dragging, resizing, and keyboard movement are all handled by React Aria's
- `useMove`, which normalizes mouse, touch, and keyboard interactions.
+### Keyboard Navigation
+
+| Key | Action |
+| --- | --- |
+| Tab | Move focus to the next widget. |
+| Arrow keys | Move the focused widget one cell. With a selection, moves the whole selection. |
+| Space | Toggle the focused widget's selection (`selectionMode` only). |
+| Shift / Cmd / Ctrl + press | Add or remove a widget from the selection. |
+| Escape | Clear the selection. |
+| Delete / Backspace | Request deletion of the selection via `onWidgetsDelete`. |
+
+Arrow keys and Space only act when the widget host **itself** is
+focused, never when focus is inside a nested control, so an `input`,
+`textarea`, or `button` inside a widget keeps every key it needs. Board's own
+keys are handled on the board element rather than on `document`, and are only
+consumed when they actually do something — an Escape with nothing
+selected still reaches an ancestor Dialog, and Backspace stays
+available for text editing and browser-back.
+
+Every draggable or selectable widget is its own tab stop. Roving tabindex would
+be an improvement on a large board, but arrow keys are already claimed for
+movement, so it needs a navigation key set of its own — see below.
+
+### Screen Reader Support
+
+Each widget host is a `group` with an accessible name, taken from
+`aria-label`, then `qa`, then the layout item id — set `aria-label` on any
+user-visible widget, since the other two are developer-facing. Draggable widgets
+carry a localized `aria-roledescription`, and selectable ones advertise
+`aria-keyshortcuts="Space"`.
+
+Selection state is conveyed two ways: a selected widget is described as
+"Selected" via `aria-describedby`, and every selection change is announced
+through a polite live region owned by the board ("Revenue selected", "3 widgets
+selected", "Selection cleared"). A marquee announces once, on release — not once
+per pointer frame.
+
+### ARIA Properties
+
+Board deliberately does **not** use a collection role. `aria-selected` is only
+valid on `option`, `gridcell`, `row`, `treeitem` and friends, and every one of
+those requires presentational children — but a Board widget hosts arbitrary
+interactive content (nested boards, tabs, inputs), and a `grid`'s keyboard model
+would fight `useMove`'s arrow keys. Rather than ship ARIA that lies about the
+structure, selection is exposed through the name, description, and live region
+described above. Set `aria-label` on your widgets and this reads correctly.
## Not yet supported
@@ -276,6 +415,23 @@ touch drags capture the pointer, so they do not trigger it.
placeholder, and new `onDrop`/`droppingItem` props). Add widgets
programmatically (render a new `Board.Widget` and add its layout item), or drag
between boards with `Board.Provider`.
+- **Cross-board group transfer** — a group drag never leaves its source board.
+ Cross-board transfer is single-item throughout (`WidgetTransferInfo`, the
+ carried preview, the free-slot fallback), and landing a group on a board with a
+ different column count has no obvious right answer. Degrading to a
+ single-widget transfer would silently split a selection the user made, so the
+ gesture is confined instead. Drag widgets across one at a time.
+- **Collection ARIA semantics** — `role="grid"`/`option`, `aria-selected` and
+ `aria-multiselectable`, for the reason above.
+- **Roving tabindex** — needs a widget-navigation key set that does not collide
+ with `useMove`'s arrow keys.
+- **Cmd/Ctrl+A** — select all.
+- **Cross-board marquee and cross-board selection** — selection is per board; a
+ `Board.Provider` shares drag, not selection.
+- **Windows High Contrast Mode** — no component in the kit styles for
+ `forced-colors` yet. The selected state uses a tint, a border *and* an extra
+ ring, so it does not rely on hue alone, but a full forced-colors pass is a
+ system-wide change.
## Attribution
diff --git a/src/components/layout/Board/Board.stories.tsx b/src/components/layout/Board/Board.stories.tsx
index 9827cdbe1..357c94f2e 100644
--- a/src/components/layout/Board/Board.stories.tsx
+++ b/src/components/layout/Board/Board.stories.tsx
@@ -54,6 +54,25 @@ export default {
description: 'Show grid lines behind the widgets.',
table: { defaultValue: { summary: 'false' } },
},
+ selectionMode: {
+ control: { type: 'radio' },
+ options: ['none', 'single', 'multiple'],
+ description:
+ 'Whether widgets can be selected, and how many at a time. `multiple` also enables the marquee and group movement.',
+ table: { defaultValue: { summary: 'none' } },
+ },
+ allowMarqueeSelection: {
+ control: { type: 'boolean' },
+ description:
+ 'Draw a rubber-band selection when a drag starts on empty board space.',
+ table: { defaultValue: { summary: "selectionMode === 'multiple'" } },
+ },
+ selectionCancel: {
+ control: { type: 'text' },
+ description:
+ 'CSS selector for descendants whose clicks must never change the selection.',
+ table: { defaultValue: { summary: 'BOARD_SELECTION_CANCEL' } },
+ },
},
} as Meta;
@@ -85,24 +104,154 @@ const Template: StoryFn = (args) => (
defaultLayout={defaultLayout}
{...args}
>
-
+
-
+
-
+
-
+
-
+
);
+export const Selection = Template.bind({});
+Selection.args = {
+ selectionMode: 'multiple',
+ showGridLines: 'drag',
+};
+Selection.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Press a widget to select it and Shift-press to add or remove one — the same press also arms a drag, so move and it drags, stay still and it was just a selection. Grabbing an unselected widget makes it the selection; grabbing a selected one moves the whole block. Drag from empty canvas to lasso. Selection behaves like focus: pressing a control inside a widget, or moving focus off the board, drops it. Space toggles the focused widget, Escape clears.',
+ },
+ },
+};
+
+const SelectionCancelTemplate: StoryFn = (args) => (
+
+
+
+
+ Filters
+
+
+
+
+
+
+
+
+
+);
+
+export const SelectionCancel = SelectionCancelTemplate.bind({});
+SelectionCancel.args = { selectionMode: 'multiple' };
+SelectionCancel.parameters = {
+ docs: {
+ description: {
+ story:
+ "Interactive descendants keep their own clicks and their native focus: a press on one neither selects the widget nor starts a drag, and it drops the selection, because interacting with a widget's content means you have moved on. On a selectable board this doubles as the drag guard, so the input below is typeable without configuring `dragCancel`. The default `selectionCancel` selector covers native controls and ARIA widget roles; add `data-no-select` to opt a custom control out.",
+ },
+ },
+};
+
+const ControlledSelectionTemplate: StoryFn = (args) => {
+ const [layout, setLayout] = useState(defaultLayout);
+ const [selectedKeys, setSelectedKeys] = useState([]);
+
+ return (
+
+
+ Selected: {selectedKeys.length ? selectedKeys.join(', ') : 'nothing'}
+ {' — press Delete to remove'}
+
+
+ setLayout((prev) => prev.filter((it) => !keys.includes(it.i)))
+ }
+ {...args}
+ >
+ {layout.map((item) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+export const ControlledSelection = ControlledSelectionTemplate.bind({});
+ControlledSelection.args = { selectionMode: 'multiple' };
+ControlledSelection.parameters = {
+ docs: {
+ description: {
+ story:
+ 'A fully controlled selection. `onWidgetsDelete` fires on Delete/Backspace — Board never mutates the layout itself, so the app decides what removal means (and can make it undoable).',
+ },
+ },
+};
+
+const RestyledSelectionTemplate: StoryFn = (args) => (
+
+ {defaultLayout.map((item) => (
+
+
+
+ ))}
+
+);
+
+export const RestyledSelection = RestyledSelectionTemplate.bind({});
+RestyledSelection.args = { selectionMode: 'multiple' };
+
export const Default = Template.bind({});
Default.args = {};
diff --git a/src/components/layout/Board/Board.test.tsx b/src/components/layout/Board/Board.test.tsx
index 26e44bb1e..ce510cbc6 100644
--- a/src/components/layout/Board/Board.test.tsx
+++ b/src/components/layout/Board/Board.test.tsx
@@ -5,6 +5,7 @@ import {
renderWithRoot,
screen,
userEvent,
+ waitFor,
} from '../../../test';
import { Tab, Tabs } from '../../navigation/Tabs';
@@ -2035,4 +2036,1006 @@ describe('Board', () => {
}
});
});
+ describe('selection', () => {
+ // Deterministic geometry for the marquee: a 600px-wide, 6-column board with
+ // no margins, so column N starts at x = N * 100 and row N at y = N * 100.
+ const mockRect = (
+ left: number,
+ top: number,
+ width: number,
+ height: number,
+ ): DOMRect =>
+ ({
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ x: left,
+ y: top,
+ toJSON: () => ({}),
+ }) as DOMRect;
+
+ /**
+ * Additive press. user-event applies modifiers through the keyboard API,
+ * and a held key only survives across calls within one `setup()` session.
+ */
+ const shiftPress = async (el: HTMLElement) => {
+ const user = userEvent.setup();
+ await user.keyboard('{Shift>}');
+ await user.click(el);
+ await user.keyboard('{/Shift}');
+ };
+
+ const selectionLayout = [
+ { i: 'a', x: 0, y: 0, w: 2, h: 1 },
+ { i: 'b', x: 2, y: 0, w: 2, h: 1 },
+ { i: 'c', x: 0, y: 1, w: 2, h: 1 },
+ ];
+
+ function renderSelectableBoard(props: Record = {}) {
+ const utils = render(
+
+
+
+
+
+ B
+
+
+ C
+
+ ,
+ );
+
+ return { ...utils, widget: (qa: string) => screen.getByTestId(qa) };
+ }
+
+ it('selects a widget on a plain press', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('B'));
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['b']);
+ expect(widget('B')).toHaveAttribute('data-selected');
+ });
+
+ it('selects on pointer-down, before any drag begins', () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ // No pointerup, no click — the selection is already committed, which is
+ // what lets the drag that follows know what it is moving.
+ fireEvent.pointerDown(widget('B'), { button: 0, pointerId: 1 });
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['b']);
+ });
+
+ it('returns keys in layout order, not click order', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('C'));
+ await shiftPress(widget('A'));
+
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['a', 'c']);
+ });
+
+ it('toggles a widget on each additive press', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('A'));
+ await shiftPress(widget('B'));
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['a', 'b']);
+
+ await shiftPress(widget('A'));
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['b']);
+ });
+
+ it('replaces the selection on a plain press', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('A'));
+ await shiftPress(widget('B'));
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['a', 'b']);
+
+ await userEvent.click(widget('C'));
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['c']);
+ });
+
+ it('replaces instead of accumulating in single mode', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({
+ onSelectionChange,
+ selectionMode: 'single',
+ });
+
+ await userEvent.click(widget('A'));
+ await shiftPress(widget('B'));
+
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['b']);
+ });
+
+ it('does nothing when selectionMode is none', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({
+ onSelectionChange,
+ selectionMode: 'none',
+ });
+
+ await userEvent.click(widget('B'));
+
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ expect(widget('B')).not.toHaveAttribute('data-selected');
+ });
+
+ // Regression: on a selectable board an `input` inside a widget could not be
+ // focused or typed into unless `dragCancel` was also configured, because
+ // `useMove`'s pointer-down calls `preventDefault()` and only `dragCancel`
+ // gated it. `selectionCancel` already declares which descendants are
+ // interactive, so it gates the drag too.
+ it('keeps native focus on an interactive descendant without dragCancel', () => {
+ const onDragStart = vi.fn();
+ render(
+
+
+
+
+ ,
+ );
+
+ const field = screen.getByTestId('Field');
+ const event = new PointerEvent('pointerdown', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ pointerType: 'mouse',
+ });
+ fireEvent(field, event);
+
+ // `preventDefault()` here is what cancels the browser's focus-on-press.
+ expect(event.defaultPrevented).toBe(false);
+ expect(onDragStart).not.toHaveBeenCalled();
+ });
+
+ describe('clearing', () => {
+ it('replaces the selection when pressing a widget outside it', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('B'));
+ onSelectionChange.mockClear();
+
+ fireEvent.pointerDown(widget('C'), { button: 0, pointerId: 1 });
+
+ // The press grabs `c`, so a drag that follows moves exactly that.
+ expect(onSelectionChange).toHaveBeenCalledWith(['c']);
+ expect(widget('B')).not.toHaveAttribute('data-selected');
+ });
+
+ it('drops the selection when pressing an interactive descendant', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('A'));
+ onSelectionChange.mockClear();
+
+ // Inside the *selected* widget — interacting with its content is still
+ // interacting with something other than the selection.
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'Inner' }), {
+ button: 0,
+ pointerId: 1,
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith([]);
+ });
+
+ it('keeps the selection when pressing a widget inside it', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('B'));
+ onSelectionChange.mockClear();
+
+ // This press is the start of a group drag, not a change of mind.
+ fireEvent.pointerDown(widget('B'), { button: 0, pointerId: 1 });
+
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+
+ it('parks focus on the board without making it a tab stop', async () => {
+ const onWidgetsDelete = vi.fn();
+ const { widget } = renderSelectableBoard({ onWidgetsDelete });
+
+ await userEvent.click(widget('B'));
+ await userEvent.keyboard('{Delete}');
+
+ // Focus has to land somewhere the board's own Escape/Delete handler can
+ // still see, since the widget that had it is about to unmount...
+ const board = screen.getByTestId('Board');
+ expect(board).toHaveFocus();
+ // ...but the board is never reachable by Tab, so this is a parking spot
+ // rather than a control, and it draws no focus ring.
+ expect(board).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('does not let a nested widget press select its container', async () => {
+ const onOuter = vi.fn();
+ const onInner = vi.fn();
+ render(
+
+
+
+
+ inner
+
+
+
+ ,
+ );
+
+ // Pressing the inner widget twice: the second press is a no-op for the
+ // inner board, but it must still not bubble out and select the
+ // container widget on the outer one.
+ fireEvent.pointerDown(screen.getByTestId('Inner'), {
+ button: 0,
+ pointerId: 1,
+ });
+ fireEvent.pointerDown(screen.getByTestId('Inner'), {
+ button: 0,
+ pointerId: 1,
+ });
+
+ expect(onInner).toHaveBeenCalledWith(['inner']);
+ expect(onOuter).not.toHaveBeenCalled();
+ expect(screen.getByTestId('Outer')).not.toHaveAttribute(
+ 'data-selected',
+ );
+ });
+
+ it('drops the selection when focus leaves the board', async () => {
+ const onSelectionChange = vi.fn();
+ render(
+ <>
+
+
+
+ A
+
+
+ B
+
+
+ C
+
+
+ >,
+ );
+
+ await userEvent.click(screen.getByTestId('B'));
+ expect(screen.getByTestId('B')).toHaveAttribute('data-selected');
+ onSelectionChange.mockClear();
+
+ screen.getByRole('button', { name: 'Outside' }).focus();
+ await waitFor(() => expect(onSelectionChange).toHaveBeenCalledWith([]));
+ expect(screen.getByTestId('B')).not.toHaveAttribute('data-selected');
+ });
+ });
+
+ describe('keyboard', () => {
+ // No modifier here: focus already says which widget is meant, and Space
+ // cannot be mistaken for the start of a drag.
+ it('toggles the focused widget with Space', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ widget('B').focus();
+ await userEvent.keyboard(' ');
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['b']);
+
+ await userEvent.keyboard(' ');
+ expect(onSelectionChange).toHaveBeenLastCalledWith([]);
+ });
+
+ it('leaves Space alone inside a nested control', async () => {
+ const onSelectionChange = vi.fn();
+ renderSelectableBoard({ onSelectionChange });
+
+ screen.getByRole('button', { name: 'Inner' }).focus();
+ await userEvent.keyboard(' ');
+
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+
+ it('clears the selection on Escape', async () => {
+ const onSelectionChange = vi.fn();
+ const { widget } = renderSelectableBoard({ onSelectionChange });
+
+ await userEvent.click(widget('B'));
+ await userEvent.keyboard('{Escape}');
+
+ expect(onSelectionChange).toHaveBeenLastCalledWith([]);
+ expect(widget('B')).not.toHaveAttribute('data-selected');
+ });
+
+ it('reports a delete request without touching the layout', async () => {
+ const onWidgetsDelete = vi.fn();
+ const onLayoutChange = vi.fn();
+ const { widget } = renderSelectableBoard({
+ onWidgetsDelete,
+ onLayoutChange,
+ });
+
+ await userEvent.click(widget('B'));
+ onLayoutChange.mockClear();
+ await userEvent.keyboard('{Delete}');
+
+ expect(onWidgetsDelete).toHaveBeenCalledWith(['b']);
+ // Board reports; the consumer owns the data.
+ expect(onLayoutChange).not.toHaveBeenCalled();
+ expect(screen.getByTestId('B')).toBeInTheDocument();
+ });
+
+ it('does not delete while focus is in a text field', async () => {
+ const onWidgetsDelete = vi.fn();
+ render(
+
+
+
+
+ ,
+ );
+
+ screen.getByLabelText('Field').focus();
+ await userEvent.keyboard('{Delete}');
+
+ expect(onWidgetsDelete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('accessibility', () => {
+ it('names each widget as a group with a valid roledescription', () => {
+ renderSelectableBoard();
+
+ const host = screen.getByRole('group', { name: 'Alpha' });
+ // `aria-roledescription` is invalid on a role-less element, so the host
+ // must carry a real role for it to mean anything.
+ expect(host).toHaveAttribute(
+ 'aria-roledescription',
+ 'Draggable widget',
+ );
+ expect(host).toHaveAttribute('aria-keyshortcuts', 'Space');
+ });
+
+ it('prefers aria-label over qa and the layout id for the name', () => {
+ render(
+
+
+ A
+
+ ,
+ );
+
+ expect(
+ screen.getByRole('group', { name: 'QaName' }),
+ ).toBeInTheDocument();
+ });
+
+ it('describes a selected widget as selected', async () => {
+ const { widget } = renderSelectableBoard();
+
+ expect(widget('B')).not.toHaveAttribute('aria-describedby');
+ await userEvent.click(widget('B'));
+
+ const describedBy = widget('B').getAttribute('aria-describedby');
+ expect(describedBy).toBeTruthy();
+ expect(document.getElementById(describedBy!)).toHaveTextContent(
+ 'Selected',
+ );
+ });
+
+ it('announces the selection through a live region', async () => {
+ const { widget } = renderSelectableBoard();
+ const status = screen.getByRole('status');
+
+ await userEvent.click(widget('B'));
+ expect(status).toHaveTextContent('Beta selected');
+
+ await shiftPress(widget('A'));
+ expect(status).toHaveTextContent('2 widgets selected');
+
+ await userEvent.keyboard('{Escape}');
+ expect(status).toHaveTextContent('Selection cleared');
+ });
+
+ it('exposes no live region or hint when selection is off', () => {
+ renderSelectableBoard({ selectionMode: 'none' });
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('marquee', () => {
+ const pointer = (
+ type: string,
+ clientX: number,
+ clientY: number,
+ modifiers: { ctrlKey?: boolean; shiftKey?: boolean } = {},
+ ) => {
+ const event = new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ pointerType: 'mouse',
+ clientX,
+ clientY,
+ ...modifiers,
+ });
+ Object.defineProperty(event, 'clientX', { get: () => clientX });
+ Object.defineProperty(event, 'clientY', { get: () => clientY });
+ return event;
+ };
+
+ function setupMarquee(props: Record = {}) {
+ const utils = renderSelectableBoard(props);
+ const content = screen.getByTestId('A').parentElement as HTMLElement;
+ content.getBoundingClientRect = () => mockRect(0, 0, 600, 400);
+
+ return { ...utils, content };
+ }
+
+ it('selects every widget the band intersects', () => {
+ const onSelectionChange = vi.fn();
+ const { content } = setupMarquee({ onSelectionChange });
+
+ // Band over x:[0,250], y:[0,50] — covers `a` (0-200) and `b` (200-400).
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(onSelectionChange).toHaveBeenCalledWith(['a', 'b']);
+ });
+
+ it('commits once per gesture, not once per pointer frame', () => {
+ const onSelectionChange = vi.fn();
+ const { content } = setupMarquee({ onSelectionChange });
+
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 100, 50));
+ fireEvent(window, pointer('pointermove', 150, 50));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders the band while dragging and removes it on release', () => {
+ const { content } = setupMarquee();
+
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ expect(screen.getByTestId('BoardMarquee')).toBeInTheDocument();
+
+ fireEvent(window, pointer('pointerup', 250, 50));
+ expect(screen.queryByTestId('BoardMarquee')).not.toBeInTheDocument();
+ });
+
+ it('ignores a press below the movement threshold and clears instead', async () => {
+ const onSelectionChange = vi.fn();
+ const { content, widget } = setupMarquee({ onSelectionChange });
+
+ await userEvent.click(widget('B'));
+ onSelectionChange.mockClear();
+
+ fireEvent(content, pointer('pointerdown', 0, 300));
+ fireEvent(window, pointer('pointermove', 1, 300));
+ fireEvent(window, pointer('pointerup', 1, 300));
+
+ expect(screen.queryByTestId('BoardMarquee')).not.toBeInTheDocument();
+ expect(onSelectionChange).toHaveBeenCalledWith([]);
+ });
+
+ it('adds to the existing selection with Shift', async () => {
+ const onSelectionChange = vi.fn();
+ const { content, widget } = setupMarquee({ onSelectionChange });
+
+ await userEvent.click(widget('C'));
+
+ fireEvent(content, pointer('pointerdown', 0, 0, { shiftKey: true }));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['a', 'b', 'c']);
+ });
+
+ it('never starts on a widget — that press is a drag', () => {
+ const { widget } = setupMarquee();
+
+ fireEvent(widget('A'), pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+
+ expect(screen.queryByTestId('BoardMarquee')).not.toBeInTheDocument();
+ });
+
+ // Dragging is off while the modifier is held, so the whole board — widgets
+ // included — becomes one selection surface.
+ it('adds to the selection from the platform modifier flag', async () => {
+ const onSelectionChange = vi.fn();
+ const { content } = setupMarquee({ onSelectionChange });
+
+ fireEvent(content, pointer('pointerdown', 0, 0, { ctrlKey: true }));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['a', 'b']);
+ });
+
+ it('re-announces two consecutive selections that read the same', () => {
+ const { content } = setupMarquee();
+ const status = screen.getByRole('status');
+
+ // Band over a + b.
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+ const first = status.textContent;
+
+ // Band over a + c — a different selection that renders the same text.
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 50, 150));
+ fireEvent(window, pointer('pointerup', 50, 150));
+
+ // A screen reader skips a live-region update whose text is
+ // byte-identical to the one before it, so these must differ.
+ expect(first).toContain('2 widgets selected');
+ expect(status).toHaveTextContent('2 widgets selected');
+ expect(status.textContent).not.toBe(first);
+ });
+
+ it('skips a widget that opted out of selection', () => {
+ const onSelectionChange = vi.fn();
+ render(
+
+
+ A
+
+
+ B
+
+
+ C
+
+ ,
+ );
+ const content = screen.getByTestId('A').parentElement as HTMLElement;
+ content.getBoundingClientRect = () => mockRect(0, 0, 600, 400);
+
+ // A band over both `a` and `b`; only `a` may be picked up, matching what
+ // a press on `b` would (not) do.
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+ fireEvent(window, pointer('pointerup', 250, 50));
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['a']);
+ });
+
+ it('is disabled by allowMarqueeSelection={false}', () => {
+ const { content } = setupMarquee({ allowMarqueeSelection: false });
+
+ fireEvent(content, pointer('pointerdown', 0, 0));
+ fireEvent(window, pointer('pointermove', 250, 50));
+
+ expect(screen.queryByTestId('BoardMarquee')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('controlled selection', () => {
+ it('renders the controlled keys and does not self-update', async () => {
+ const onSelectionChange = vi.fn();
+ renderSelectableBoard({ selectedKeys: ['a'], onSelectionChange });
+
+ expect(screen.getByTestId('A')).toHaveAttribute('data-selected');
+
+ await userEvent.click(screen.getByTestId('B'));
+
+ // A plain press replaces, so the reported selection is just `b`.
+ expect(onSelectionChange).toHaveBeenLastCalledWith(['b']);
+ // The consumer owns the state; nothing moved without them.
+ expect(screen.getByTestId('A')).toHaveAttribute('data-selected');
+ expect(screen.getByTestId('B')).not.toHaveAttribute('data-selected');
+ });
+
+ it('ignores a key with no matching widget', () => {
+ renderSelectableBoard({ selectedKeys: ['ghost', 'b'] });
+
+ expect(screen.getByTestId('B')).toHaveAttribute('data-selected');
+ expect(screen.getByTestId('A')).not.toHaveAttribute('data-selected');
+ });
+ });
+ });
+
+ describe('group move', () => {
+ const mockRect = (
+ left: number,
+ top: number,
+ width: number,
+ height: number,
+ ): DOMRect =>
+ ({
+ left,
+ top,
+ width,
+ height,
+ right: left + width,
+ bottom: top + height,
+ x: left,
+ y: top,
+ toJSON: () => ({}),
+ }) as DOMRect;
+
+ const pointerEvent = (type: string, pageX: number, pageY: number) => {
+ const event = new PointerEvent(type, {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ pointerId: 1,
+ pointerType: 'mouse',
+ });
+ Object.defineProperty(event, 'pageX', { get: () => pageX });
+ Object.defineProperty(event, 'pageY', { get: () => pageY });
+ return event;
+ };
+
+ /**
+ * A 12-column, 100px-per-cell board with no margins, so grid cell N starts
+ * at exactly N * 100 px on both axes.
+ */
+ function setupGroupBoard(props: Record = {}) {
+ const layout = (props.defaultLayout as LayoutItem[]) ?? [
+ { i: 'a', x: 0, y: 0, w: 2, h: 1 },
+ { i: 'b', x: 6, y: 0, w: 2, h: 1 },
+ { i: 'far', x: 0, y: 4, w: 2, h: 1 },
+ ];
+ const utils = render(
+
+ {layout.map((item) => (
+
+ {item.i}
+
+ ))}
+ ,
+ );
+
+ const grabbed = screen.getByTestId('A');
+ const content = grabbed.parentElement as HTMLElement;
+ content.getBoundingClientRect = () => mockRect(0, 0, 1200, 800);
+ for (const item of layout) {
+ const el = screen.getByTestId(item.i.toUpperCase());
+ el.getBoundingClientRect = () =>
+ mockRect(item.x * 100, item.y * 100, item.w * 100, item.h * 100);
+ }
+
+ return { ...utils, grabbed };
+ }
+
+ /** Positions keyed by id, e.g. `{ a: '2,0' }`. */
+ const positions = (layout: LayoutItem[]) =>
+ Object.fromEntries(layout.map((it) => [it.i, `${it.x},${it.y}`]));
+
+ it('moves every selected widget by the same delta', () => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard({ onLayoutChange });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 200, 100));
+ fireEvent(window, pointerEvent('pointerup', 200, 100));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ expect(positions(committed)).toMatchObject({ a: '2,1', b: '8,1' });
+ });
+
+ it('commits exactly once, before onDragStop', () => {
+ const calls: string[] = [];
+ const { grabbed } = setupGroupBoard({
+ onLayoutChange: () => calls.push('layout'),
+ onDragStop: () => calls.push('stop'),
+ });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 200, 0));
+ fireEvent(window, pointerEvent('pointerup', 200, 0));
+
+ expect(calls.filter((c) => c === 'layout')).toHaveLength(1);
+ expect(calls).toEqual(['layout', 'stop']);
+ });
+
+ // The live bug in the app-level version this replaces: clamping each item
+ // separately collapses the group against the wall and it never recovers.
+ it('keeps the group shape when dragged into an edge', () => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard({ onLayoutChange });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', -400, 0));
+ fireEvent(window, pointerEvent('pointerup', -400, 0));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ const a = committed.find((it) => it.i === 'a')!;
+ const b = committed.find((it) => it.i === 'b')!;
+ expect(b.x - a.x).toBe(6);
+ expect(a.x).toBe(0);
+ });
+
+ it('never leaves a widget pinned after a group drop', () => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard({
+ onLayoutChange,
+ compact: 'vertical',
+ });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 200, 100));
+ fireEvent(window, pointerEvent('pointerup', 200, 100));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ // A leaked pin would freeze the widget forever — and consumers persist
+ // layouts, so it would survive a reload.
+ expect(committed.every((it) => !it.static)).toBe(true);
+ });
+
+ it('reports every mover through the drag callbacks', () => {
+ const onDragStart = vi.fn();
+ const { grabbed } = setupGroupBoard({ onDragStart });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 100, 0));
+ fireEvent(window, pointerEvent('pointerup', 100, 0));
+
+ const info = onDragStart.mock.lastCall![0];
+ expect(info.items.map((it: LayoutItem) => it.i)).toEqual(['a', 'b']);
+ expect(info.item).toBe(info.items[0]);
+ expect(info.placeholders).toHaveLength(2);
+ });
+
+ // Reported: dragging a group down on a compacting board shoved the widgets
+ // below it further down, and the board only caught up on the *next* pointer
+ // step. The group was being held in place while everything reflowed around
+ // it — something a single widget is never allowed to do under vertical
+ // compaction, which is why a single drag felt natural and a group did not.
+ it('compacts the group during the drag, like a single widget', () => {
+ const frames: LayoutItem[][] = [];
+ const { grabbed } = setupGroupBoard({
+ compact: 'vertical',
+ defaultLayout: [
+ { i: 'a', x: 0, y: 0, w: 2, h: 1 },
+ { i: 'b', x: 2, y: 0, w: 2, h: 1 },
+ { i: 'far', x: 0, y: 3, w: 2, h: 1 },
+ ],
+ onDrag: (info: { layout: LayoutItem[] }) =>
+ frames.push(info.layout.map((it) => ({ ...it }))),
+ });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 0, 600));
+
+ expect(frames.length).toBeGreaterThan(0);
+ for (const frame of frames) {
+ // `far` rises to the top, and the group packs in beneath it instead of
+ // hanging six rows down where the pointer is.
+ expect(positions(frame)).toEqual({ far: '0,0', a: '0,1', b: '2,0' });
+ }
+ });
+
+ it('renders one placeholder per moving widget', () => {
+ const { grabbed } = setupGroupBoard();
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 100, 0));
+
+ expect(screen.getAllByTestId('BoardPlaceholder')).toHaveLength(2);
+ });
+
+ it('moves the whole group with the arrow keys', () => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard({ onLayoutChange });
+
+ grabbed.focus();
+ fireEvent.keyDown(grabbed, { key: 'ArrowRight' });
+ fireEvent.keyUp(grabbed, { key: 'ArrowRight' });
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ expect(positions(committed)).toMatchObject({ a: '1,0', b: '7,0' });
+ });
+
+ it('leaves unselected widgets where they are', () => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard({ onLayoutChange });
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 100, 0));
+ fireEvent(window, pointerEvent('pointerup', 100, 0));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ expect(positions(committed).far).toBe('0,4');
+ });
+
+ it('drags only the grabbed widget when it is outside the selection', () => {
+ const onLayoutChange = vi.fn();
+ const onSelectionChange = vi.fn();
+ render(
+
+
+ a
+
+
+ b
+
+ ,
+ );
+
+ const grabbed = screen.getByTestId('A');
+ (grabbed.parentElement as HTMLElement).getBoundingClientRect = () =>
+ mockRect(0, 0, 1200, 800);
+ grabbed.getBoundingClientRect = () => mockRect(0, 0, 200, 100);
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 100, 0));
+ fireEvent(window, pointerEvent('pointerup', 100, 0));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ // The press grabbed `a`, so `a` became the selection and `b` stayed put.
+ expect(positions(committed)).toMatchObject({ a: '1,0', b: '6,0' });
+ expect(onSelectionChange).toHaveBeenCalledWith(['a']);
+ expect(screen.getByTestId('B')).not.toHaveAttribute('data-selected');
+ });
+
+ it('grabs the pressed widget as the new selection', () => {
+ const onSelectionChange = vi.fn();
+ render(
+
+
+ a
+
+
+ b
+
+ ,
+ );
+
+ fireEvent.pointerDown(screen.getByTestId('A'), {
+ button: 0,
+ pointerId: 1,
+ });
+
+ expect(onSelectionChange).toHaveBeenCalledWith(['a']);
+ expect(screen.getByTestId('B')).not.toHaveAttribute('data-selected');
+ });
+
+ it.each([
+ ['no prop', undefined],
+ ['an empty selection', [] as string[]],
+ ['a single selected widget', ['a']],
+ ])('drags identically with %s', (_label, keys) => {
+ const onLayoutChange = vi.fn();
+ const { grabbed } = setupGroupBoard(
+ keys === undefined
+ ? { onLayoutChange, selectionMode: 'none' }
+ : { onLayoutChange, selectedKeys: keys },
+ );
+
+ fireEvent(grabbed, pointerEvent('pointerdown', 0, 0));
+ fireEvent(window, pointerEvent('pointermove', 100, 0));
+ fireEvent(window, pointerEvent('pointerup', 100, 0));
+
+ const committed = onLayoutChange.mock.lastCall![0] as LayoutItem[];
+ expect(positions(committed)).toMatchObject({
+ a: '1,0',
+ b: '6,0',
+ far: '0,4',
+ });
+ });
+ });
});
diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx
index 798db6c33..6e91bf6d9 100644
--- a/src/components/layout/Board/Board.tsx
+++ b/src/components/layout/Board/Board.tsx
@@ -18,9 +18,11 @@ import {
useState,
useSyncExternalStore,
} from 'react';
+import { useFocusWithin } from 'react-aria';
import { useEvent } from '../../../_internal/hooks';
-import { useCombinedRefs } from '../../../utils/react';
+import { useI18n } from '../../../i18n';
+import { mergeProps, useCombinedRefs } from '../../../utils/react';
import { extractStyles } from '../../../utils/styles';
import {
@@ -56,6 +58,8 @@ import {
ResizeHandleAxis,
} from './grid-core';
import { useBoardLayout } from './use-board-layout';
+import { useBoardSelectModifierKey } from './use-board-select-modifier-key';
+import { BoardSelectionMode, useBoardSelection } from './use-board-selection';
import { ResizePhase, WidgetHost } from './WidgetHost';
import type { CubeBoardWidgetProps } from './Widget';
@@ -70,6 +74,14 @@ const BoardElement = tasty({
height: 'min 0',
fill: '#surface',
boxSizing: 'border-box',
+ // The board takes focus programmatically (never by Tab — it is `tabIndex=-1`)
+ // as a parking spot: after `onWidgetsDelete` the focused widget host is
+ // about to unmount, and after a marquee focus is nowhere near the board, so
+ // in both cases Escape and Delete — handled here rather than on `document` —
+ // would have nothing to reach. A focus ring on a parking spot is noise: it
+ // announces a state the user cannot act on and did not ask for. Same reason
+ // `Dialog` drops it on its own focusable container.
+ outline: 0,
},
});
@@ -80,15 +92,19 @@ const ContentLayer = tasty({
},
});
+// The drop-slot preview. `#primary` rather than the legacy `#purple` alias -
+// same hue, current token. It has to stay distinguishable from a *selected*
+// widget (a tint + solid border + ring, see `WidgetHost`) and from a live
+// marquee (dashed), since all three can be on screen at once.
const PlaceholderElement = tasty({
qa: 'BoardPlaceholder',
styles: {
position: 'absolute',
top: 0,
left: 0,
- fill: '#purple.10',
+ fill: '#primary.10',
radius: '1cr',
- border: '#purple.40',
+ border: '#primary.40',
zIndex: 2,
pointerEvents: 'none',
transition: 'inset 80ms linear, width 80ms linear, height 80ms linear',
@@ -96,6 +112,44 @@ const PlaceholderElement = tasty({
},
});
+// The rubber-band selection rectangle. Dashed is the discriminator: nothing else
+// on a board is dashed, and it reads as a transient lasso rather than a place
+// something is about to land. Sits above the widgets (1) and the placeholder (2).
+const MarqueeElement = tasty({
+ qa: 'BoardMarquee',
+ styles: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ fill: '#primary-accent-surface.06',
+ border: '1bw dashed #primary-border',
+ radius: '1cr',
+ zIndex: 3,
+ pointerEvents: 'none',
+ boxSizing: 'border-box',
+ },
+});
+
+// Visually-hidden live region. Selection changes are announced here because a
+// board widget cannot carry `aria-selected`: that attribute is only valid on
+// collection roles (`option`, `gridcell`, `row`, …) whose children must be
+// presentational, and widgets host arbitrary interactive content. This element
+// also owns the shared "Selected" hint every selected host points at via
+// `aria-describedby`.
+const A11yLayer = tasty({
+ styles: {
+ position: 'absolute',
+ width: '1px',
+ height: '1px',
+ padding: 0,
+ margin: '-1px',
+ overflow: 'hidden',
+ clipPath: 'inset(50%)',
+ whiteSpace: 'nowrap',
+ border: 0,
+ },
+});
+
// Grid overlay drawn behind the widgets. Each snap cell is painted as a faint
// block (column stripes intersected with a row-band alpha mask); the margin gaps
// between cells stay transparent. The element is positioned as an explicit
@@ -122,12 +176,23 @@ export type BoardCompactType = 'vertical' | 'horizontal' | 'free' | null;
* (falls back to `oldItem` if the item just left this board via a cross-board
* transfer), `oldItem` is that item as it was when the gesture started, and
* `placeholder` is the current drop-slot preview (if any).
+ *
+ * The plural fields describe the whole gesture: a group drag moves every
+ * selected widget at once, so `items`/`oldItems`/`placeholders` list them all,
+ * grabbed widget first. An ordinary drag or a resize produces exactly one entry,
+ * so `items[0] === item` and `placeholders[0] === placeholder` always hold.
*/
export interface BoardInteractionInfo {
layout: LayoutItem[];
item: LayoutItem;
oldItem: LayoutItem;
placeholder: LayoutItem | null;
+ /** Every item in this gesture, grabbed first. */
+ items: LayoutItem[];
+ /** Those items as they were when the gesture started, same order. */
+ oldItems: LayoutItem[];
+ /** Every drop-slot preview. Empty exactly when `placeholder` is `null`. */
+ placeholders: LayoutItem[];
}
/** Visibility of the internal grid-line overlay. */
@@ -230,6 +295,41 @@ export interface CubeBoardProps
* rows at that height. @default false
*/
isAligned?: boolean;
+ /**
+ * Whether widgets can be selected, and how many at a time. `'multiple'` also
+ * enables the marquee and rigid group movement: dragging any selected widget
+ * moves the whole selection. @default 'none'
+ */
+ selectionMode?: BoardSelectionMode;
+ /** Controlled selection. Keys are layout item ids (`LayoutItem.i`). */
+ selectedKeys?: string[];
+ /** Initial selection for uncontrolled usage. */
+ defaultSelectedKeys?: string[];
+ /**
+ * Called when the selection changes. Keys are deduped and returned in the
+ * board's layout order, never in click order.
+ */
+ onSelectionChange?: (keys: string[]) => void;
+ /**
+ * CSS selector for descendants whose clicks must never change the selection
+ * (form controls, buttons, links). Mirrors `dragCancel`; can be overridden per
+ * widget. Pass `''` to disable the guard entirely.
+ * @default BOARD_SELECTION_CANCEL
+ */
+ selectionCancel?: string;
+ /**
+ * Draw a rubber-band (marquee) selection when a drag starts on empty board
+ * space. Only meaningful with `selectionMode="multiple"`.
+ * @default selectionMode === 'multiple'
+ */
+ allowMarqueeSelection?: boolean;
+ /**
+ * Called when the user presses Delete/Backspace with a
+ * non-empty selection, and focus is inside the board but not in an editable
+ * field. Board never mutates the layout itself — removing the widgets is the
+ * consumer's job. Board only handles these keys when this handler is set.
+ */
+ onWidgetsDelete?: (keys: string[]) => void;
/** Grid/item layout constraints. */
constraints?: LayoutConstraint[];
/**
@@ -247,6 +347,33 @@ export interface CubeBoardProps
children?: ReactNode;
}
+/**
+ * Descendants whose clicks never change the selection. A widget is a large
+ * surface the user also clicks to *work with*, so anything interactive inside it
+ * must keep its click. `[data-no-select]` is the escape hatch for an app's own
+ * custom controls.
+ */
+export const BOARD_SELECTION_CANCEL =
+ 'input,textarea,select,button,a,[role="button"],[role="menuitem"],' +
+ '[role="checkbox"],[role="switch"],[role="tab"],[contenteditable="true"],' +
+ '[data-no-select]';
+
+/** Manhattan distance a pointer must travel before a press becomes a marquee. */
+const MARQUEE_THRESHOLD = 4;
+
+/**
+ * Whether an event landed in a text-editing context. Checking the *event target*
+ * beats `document.activeElement`: it stays correct for content rendered through
+ * a portal, and it cannot be fooled by focus that moved between the keystroke
+ * and the handler.
+ */
+function isEditableTarget(target: EventTarget | null): boolean {
+ const el = target as HTMLElement | null;
+ if (!el || typeof el.closest !== 'function') return false;
+
+ return !!el.closest('input,textarea,select,[contenteditable="true"]');
+}
+
function compactTypeToCore(compact: BoardCompactType): CompactType {
if (compact === 'free') return null;
return compact;
@@ -283,6 +410,13 @@ function BoardInner(
dragHandle,
showGridLines,
isAligned = false,
+ selectionMode = 'none',
+ selectedKeys,
+ defaultSelectedKeys,
+ onSelectionChange,
+ selectionCancel = BOARD_SELECTION_CANCEL,
+ allowMarqueeSelection = selectionMode === 'multiple',
+ onWidgetsDelete,
constraints,
width: providedWidth,
widgetProps,
@@ -311,6 +445,10 @@ function BoardInner(
showGridLines ?? (inheritedGridLines ? 'drag' : false);
const generatedId = useId();
const boardId = providedId ?? generatedId;
+ // One shared description node for every selected widget, so marking a widget
+ // selected costs no per-widget DOM.
+ const selectedHintId = `${generatedId}-selected`;
+ const { t } = useI18n();
const containerRef = useCombinedRefs(ref);
const contentRef = useRef(null);
@@ -346,9 +484,11 @@ function BoardInner(
const {
layout,
layoutRef,
+ placeholders,
placeholder,
+ placeholdersRef,
placeholderRef,
- setPlaceholder,
+ setPlaceholders,
applyLayout,
} = useBoardLayout({
layout: controlledLayout,
@@ -356,6 +496,39 @@ function BoardInner(
onLayoutChange,
});
+ // The accessible name a widget announces under. Mirrors `WidgetHost`'s own
+ // fallback chain so the live region and the host never disagree.
+ const getWidgetLabel = useEvent((key: string) => {
+ const registration = registry.store.get(key);
+
+ return registration?.['aria-label'] ?? registration?.qa ?? key;
+ });
+
+ // Whether a widget accepts selection at all. Every selection path resolves it
+ // here — press, keyboard and marquee — so a lasso can never pick up a widget
+ // that a press cannot.
+ const isWidgetSelectable = (key: string) =>
+ (registry.store.get(key)?.isSelectable ?? widgetProps?.isSelectable) !==
+ false;
+
+ const {
+ selectedKeySet,
+ selectedKeysRef,
+ setSelection,
+ select,
+ clearSelection,
+ announcement,
+ } = useBoardSelection({
+ selectionMode,
+ selectedKeys,
+ defaultSelectedKeys,
+ onSelectionChange,
+ layout,
+ getLabel: getWidgetLabel,
+ });
+
+ const selectModifierKey = useBoardSelectModifierKey();
+
// Re-render when any widget's registered content/config changes.
useSyncExternalStore(registry.store.subscribe, registry.store.getVersion);
@@ -376,7 +549,8 @@ function BoardInner(
const rows = Math.max(
bottom(layout),
- placeholder ? placeholder.y + placeholder.h : 0,
+ ...placeholders.map((p) => p.y + p.h),
+ 0,
);
// Derive the aligned column count so each column keeps the parent's pixel
@@ -487,7 +661,7 @@ function BoardInner(
};
const applyLayoutEvent = useEvent(applyLayout);
- const setPlaceholderEvent = useEvent(setPlaceholder);
+ const setPlaceholdersEvent = useEvent(setPlaceholders);
const entryRef = useRef(null);
if (!entryRef.current) {
@@ -501,8 +675,12 @@ function BoardInner(
getMaxRows: () => liveRef.current.maxRows,
getContainerHeight: () => liveRef.current.containerHeight,
getLayout: () => layoutRef.current,
+ // The registry reads this synchronously at drag start, so it must be the
+ // ref rather than the rendered value.
+ getSelectedKeys: () =>
+ selectedKeysRef.current.size > 0 ? selectedKeysRef.current : null,
applyLayout: (next, commit) => applyLayoutEvent(next, commit),
- setPlaceholder: (item) => setPlaceholderEvent(item),
+ setPlaceholders: (items) => setPlaceholdersEvent(items),
isDroppable: () => liveRef.current.isDroppable,
};
}
@@ -633,12 +811,15 @@ function BoardInner(
accX: 0,
accY: 0,
};
- setPlaceholder({ ...item });
+ setPlaceholders([{ ...item }]);
onResizeStart?.({
layout: layoutRef.current,
item: { ...item },
oldItem: { ...item },
placeholder: { ...item },
+ items: [{ ...item }],
+ oldItems: [{ ...item }],
+ placeholders: [{ ...item }],
});
return;
}
@@ -649,12 +830,16 @@ function BoardInner(
if (phase === 'end') {
const finalLayout = [...layoutRef.current];
applyLayout(finalLayout, true);
- setPlaceholder(null);
+ setPlaceholders([]);
+ const resizedItem = getLayoutItem(finalLayout, id) ?? rs.item;
onResizeStop?.({
layout: finalLayout,
- item: getLayoutItem(finalLayout, id) ?? rs.item,
+ item: resizedItem,
oldItem: rs.item,
placeholder: null,
+ items: [resizedItem],
+ oldItems: [rs.item],
+ placeholders: [],
});
resizeStateRef.current = null;
return;
@@ -735,12 +920,16 @@ function BoardInner(
const compacted = [...compactor.compact(working, pp.cols)];
applyLayout(compacted, false);
const nextPlaceholder = getLayoutItem(compacted, id) ?? newItem;
- setPlaceholder(nextPlaceholder);
+ setPlaceholders([nextPlaceholder]);
+ const resizedItem = getLayoutItem(compacted, id) ?? newItem;
onResizeProp?.({
layout: compacted,
- item: getLayoutItem(compacted, id) ?? newItem,
+ item: resizedItem,
oldItem: rs.item,
placeholder: nextPlaceholder,
+ items: [resizedItem],
+ oldItems: [rs.item],
+ placeholders: [nextPlaceholder],
});
},
);
@@ -773,6 +962,9 @@ function BoardInner(
// original position throughout (and after a cross-board transfer removes the
// item from this board's layout).
const dragOldItemRef = useRef(null);
+ // The same, for every other member of a group drag. Empty for an ordinary
+ // drag, which is what keeps `items`/`oldItems` single-entry there.
+ const dragOldItemsRef = useRef([]);
const handleDragLifecycle = useEvent((id: string, phase: ResizePhase) => {
const currentLayout = layoutRef.current;
@@ -780,26 +972,202 @@ function BoardInner(
if (phase === 'start') {
dragOldItemRef.current = liveItem ? { ...liveItem } : null;
+ // The registry has already resolved the group by the time this fires, so
+ // its drag state is the authority on who is moving.
+ const ds = registry.getDragState();
+ dragOldItemsRef.current =
+ ds && ds.itemId === id ? ds.items.map((it) => ({ ...it })) : [];
}
const oldItem = dragOldItemRef.current ?? liveItem;
if (!oldItem) return;
const item = liveItem ?? oldItem;
+ const oldItems = dragOldItemsRef.current.length
+ ? dragOldItemsRef.current
+ : [oldItem];
+ const items = oldItems.map(
+ (old) => getLayoutItem(currentLayout, old.i) ?? old,
+ );
+
const info: BoardInteractionInfo = {
layout: currentLayout,
item,
oldItem,
- // Read the live ref, not render-time state: the registry calls
- // `setPlaceholder` synchronously right before this fires, and that only
- // schedules a re-render, so `placeholder` state still holds the previous
- // value (or a stale preview after the drop clears it to `null`).
+ // Read the live refs, not render-time state: the registry calls
+ // `setPlaceholders` synchronously right before this fires, and that only
+ // schedules a re-render, so `placeholders` state still holds the previous
+ // value (or a stale preview after the drop clears it).
placeholder: placeholderRef.current,
+ items,
+ oldItems,
+ placeholders: placeholdersRef.current,
};
if (phase === 'start') onDragStart?.(info);
else if (phase === 'move') onDrag?.(info);
else {
onDragStop?.(info);
dragOldItemRef.current = null;
+ dragOldItemsRef.current = [];
+ }
+ });
+
+ // ---- Marquee (rubber-band) selection --------------------------------------
+ //
+ // Board owns this rather than exposing hooks for an app to build it, because
+ // deciding which widgets a band covers needs every widget's box — and the DOM
+ // is the wrong place to read them from. Widget hosts transition `inset`/`width`
+ // /`height` while the board reflows, and a host being dragged is swapped for an
+ // `opacity: 0` stand-in with a fixed-position clone in the overlay, so
+ // `getBoundingClientRect` during a marquee returns interpolated or misleading
+ // boxes. `calcGridItemPosition` derives the same rectangles exactly, from the
+ // layout, with no forced reflow and no sensitivity to ancestor transforms.
+ const [marqueeRect, setMarqueeRect] = useState(null);
+
+ const handleContentPointerDown = useEvent((event: React.PointerEvent) => {
+ if (
+ selectionMode !== 'multiple' ||
+ !allowMarqueeSelection ||
+ event.button !== 0 ||
+ registry.dragState
+ ) {
+ return;
+ }
+
+ const target = event.target as HTMLElement | null;
+ // A press on a widget selects it and arms a drag; the lasso owns empty
+ // canvas only.
+ if (target?.closest('[data-board-widget-host]')) return;
+ if (selectionCancel && target?.closest(selectionCancel)) return;
+
+ const content = contentRef.current;
+ if (!content) return;
+
+ const origin = content.getBoundingClientRect();
+ const startX = event.clientX;
+ const startY = event.clientY;
+ // Holding the modifier means "add to what I have"; a bare lasso replaces.
+ const additive = event[selectModifierKey] || event.shiftKey;
+ const base = additive ? [...selectedKeysRef.current] : [];
+ let passedThreshold = false;
+
+ const hitTest = (clientX: number, clientY: number) => {
+ const left = Math.min(startX, clientX) - origin.left;
+ const right = Math.max(startX, clientX) - origin.left;
+ const top = Math.min(startY, clientY) - origin.top;
+ const bottom = Math.max(startY, clientY) - origin.top;
+ const next = new Set(base);
+
+ for (const it of layoutRef.current) {
+ if (!isWidgetSelectable(it.i)) continue;
+ const pos = calcGridItemPosition(
+ liveRef.current.positionParams,
+ it.x,
+ it.y,
+ it.w,
+ it.h,
+ );
+ if (
+ pos.left < right &&
+ pos.left + pos.width > left &&
+ pos.top < bottom &&
+ pos.top + pos.height > top
+ ) {
+ next.add(it.i);
+ }
+ }
+
+ return {
+ next,
+ box: { left, top, width: right - left, height: bottom - top },
+ };
+ };
+
+ const handleMove = (e: PointerEvent) => {
+ if (
+ !passedThreshold &&
+ Math.abs(e.clientX - startX) + Math.abs(e.clientY - startY) <
+ MARQUEE_THRESHOLD
+ ) {
+ return;
+ }
+ passedThreshold = true;
+ setMarqueeRect(hitTest(e.clientX, e.clientY).box);
+ };
+
+ const finish = (e: PointerEvent) => {
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', finish);
+ window.removeEventListener('pointercancel', finish);
+ setMarqueeRect(null);
+
+ // One commit per gesture: `onSelectionChange` and the announcement fire on
+ // release, never once per pointer frame.
+ if (passedThreshold) {
+ setSelection(hitTest(e.clientX, e.clientY).next);
+ } else if (!additive) {
+ // A plain click on empty board space clears.
+ clearSelection();
+ }
+ // Keep focus somewhere inside the board so Escape has a handler to reach.
+ containerRef.current?.focus({ preventScroll: true });
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', finish);
+ window.addEventListener('pointercancel', finish);
+ });
+
+ const marqueeStyle = marqueeRect
+ ? {
+ left: `${marqueeRect.left}px`,
+ top: `${marqueeRect.top}px`,
+ width: `${marqueeRect.width}px`,
+ height: `${marqueeRect.height}px`,
+ }
+ : null;
+
+ // Selection is focus-like, so it does not outlive focus leaving the board.
+ // Tabbing away, or clicking any focusable thing elsewhere on the page, drops
+ // it — the same way a text selection or a focus ring would go.
+ const { focusWithinProps: boardFocusWithinProps } = useFocusWithin({
+ isDisabled: selectionMode === 'none',
+ onBlurWithin: () => clearSelection(),
+ });
+
+ // ---- Board-level keys -----------------------------------------------------
+ //
+ // On the board element, never on `document`: a library-owned document listener
+ // fires for keystrokes that never went near a board, and two boards on a page
+ // would both react.
+ const handleBoardKeyDown = useEvent((event: React.KeyboardEvent) => {
+ if (selectionMode === 'none') return;
+
+ const selected = selectedKeysRef.current;
+ if (selected.size === 0) return;
+
+ if (event.key === 'Escape') {
+ // `preventDefault` only when we actually consumed the key, so an Escape
+ // with nothing selected still closes an ancestor Dialog or Popover.
+ event.preventDefault();
+ event.stopPropagation();
+ clearSelection();
+
+ return;
+ }
+
+ if (
+ (event.key === 'Delete' || event.key === 'Backspace') &&
+ onWidgetsDelete &&
+ !isEditableTarget(event.target)
+ ) {
+ event.preventDefault();
+ event.stopPropagation();
+ const keys = [...selected];
+ clearSelection();
+ onWidgetsDelete(keys);
+ // The deleted widgets' hosts are about to unmount, so focus would be left
+ // on a detached node. Park it on the board.
+ containerRef.current?.focus({ preventScroll: true });
}
});
@@ -838,25 +1206,23 @@ function BoardInner(
const hostWidgetIsDragging =
!!dragState && dragState.nestedBoardIds.has(boardId);
- const placeholderStyle = placeholder
- ? (() => {
- const pos = calcGridItemPosition(
- positionParams,
- placeholder.x,
- placeholder.y,
- placeholder.w,
- placeholder.h,
- );
+ const placeholderStyles = useMemo(
+ () =>
+ placeholders.map((p) => {
+ const pos = calcGridItemPosition(positionParams, p.x, p.y, p.w, p.h);
+
return {
- left: `${pos.left}px`,
- top: `${pos.top}px`,
- width: `${pos.width}px`,
- height: `${pos.height}px`,
+ i: p.i,
+ style: {
+ left: `${pos.left}px`,
+ top: `${pos.top}px`,
+ width: `${pos.width}px`,
+ height: `${pos.height}px`,
+ },
};
- })()
- : null;
-
- const showPlaceholder = placeholder && placeholderStyle;
+ }),
+ [placeholders, positionParams],
+ );
const gridLinesVisible =
!hostWidgetIsDragging &&
@@ -927,9 +1293,17 @@ function BoardInner(
}
>
-
+
{ready && gridOverlayStyle ? (
) : null}
@@ -987,6 +1364,12 @@ function BoardInner(
widgetProps?.isAutoHeight ??
false;
const widgetQa = registration?.qa ?? widgetProps?.qa;
+ const widgetSelectable =
+ selectionMode !== 'none' && isWidgetSelectable(item.i);
+ const widgetSelectionCancel =
+ registration?.selectionCancel ??
+ widgetProps?.selectionCancel ??
+ selectionCancel;
// Merge board-level `widgetProps` styles (its `styles` object
// plus direct style props) with the per-widget styles so
// shared defaults survive when a widget sets even a single
@@ -1014,6 +1397,13 @@ function BoardInner(
qa={widgetQa}
dragCancel={widgetDragCancel}
dragHandle={widgetDragHandle}
+ isSelectable={widgetSelectable}
+ isSelected={selectedKeySet.has(item.i)}
+ selectionCancel={widgetSelectionCancel}
+ selectedHintId={selectedHintId}
+ onSelect={select}
+ onSelectionReset={clearSelection}
+ selectModifierKey={selectModifierKey}
registry={registry}
dragState={dragState}
settled={settled}
@@ -1024,13 +1414,21 @@ function BoardInner(
);
})
: null}
- {showPlaceholder ? (
-
+ {placeholderStyles.map(({ i, style }) => (
+
+ ))}
+ {marqueeStyle ? (
+
) : null}
+ {selectionMode !== 'none' ? (
+
+ {t('board.selected', 'Selected')}
+
+ {announcement}
+
+
+ ) : null}
{children}
diff --git a/src/components/layout/Board/Widget.tsx b/src/components/layout/Board/Widget.tsx
index ffb39b0fd..9dfcaa71a 100644
--- a/src/components/layout/Board/Widget.tsx
+++ b/src/components/layout/Board/Widget.tsx
@@ -60,6 +60,22 @@ export interface CubeBoardWidgetProps extends ContainerStyleProps {
* inside this widget (overrides the board's `dragHandle`).
*/
dragHandle?: string;
+ /**
+ * Disable selection for this widget while the board's `selectionMode` is on.
+ * Symmetric with `isDraggable` / `isResizable`.
+ */
+ isSelectable?: boolean;
+ /**
+ * CSS selector for descendants whose clicks must not change the selection
+ * inside this widget (overrides the board's `selectionCancel`).
+ */
+ selectionCancel?: string;
+ /**
+ * Accessible name for the widget. Falls back to `qa`, then the layout item id
+ * — both of which are developer-facing, so set this whenever the widget is
+ * user-visible. Also used for the single-selection announcement.
+ */
+ 'aria-label'?: string;
}
/**
@@ -106,6 +122,9 @@ export function Widget(props: CubeBoardWidgetProps) {
isAutoHeight,
dragCancel,
dragHandle,
+ isSelectable,
+ selectionCancel,
+ 'aria-label': ariaLabel,
} = props;
const registry = useBoardRegistry();
@@ -153,6 +172,9 @@ export function Widget(props: CubeBoardWidgetProps) {
isAutoHeight,
dragCancel,
dragHandle,
+ isSelectable,
+ selectionCancel,
+ 'aria-label': ariaLabel,
},
ownerRef.current,
);
diff --git a/src/components/layout/Board/WidgetHost.tsx b/src/components/layout/Board/WidgetHost.tsx
index c8c6194e2..5f330118f 100644
--- a/src/components/layout/Board/WidgetHost.tsx
+++ b/src/components/layout/Board/WidgetHost.tsx
@@ -4,6 +4,7 @@ import { useFocusRing, useFocusWithin, useHover, useMove } from 'react-aria';
import { createPortal } from 'react-dom';
import { useEvent } from '../../../_internal/hooks';
+import { useI18n } from '../../../i18n';
import { mergeProps } from '../../../utils/react';
import {
@@ -21,6 +22,7 @@ import {
PositionParams,
ResizeHandleAxis,
} from './grid-core';
+import { BoardSelectModifierKey } from './use-board-select-modifier-key';
export type ResizePhase = 'start' | 'move' | 'end';
@@ -42,13 +44,22 @@ const WidgetElement = tasty({
// their columns flush with the parent grid.
fill: '#surface-2',
radius: '1cr',
+ // Selection reads as a focus-like state here - it is transient, it follows
+ // what the user is working with, and it is dropped the moment they touch
+ // something else - so it is drawn as an edge treatment rather than as a
+ // fill. `outline` stays reserved for the real focus ring (the kit's
+ // convention everywhere), and the two are kept legible side by side by
+ // token: selection is a saturated `#primary` ring, focus a `#primary-text`
+ // outline sitting one border-width further out (`outlineOffset`).
border: {
'': false,
card: true,
+ selected: '#primary-border',
},
shadow: {
'': false,
'hovered & !card & (draggable | resizing)': '0 0 0 1bw #border',
+ selected: '0 0 0 1bw #primary',
// `$dialog-shadow` uses Glaze `#shadow-lg`, which adapts to dark / high-contrast schemes.
'drag | resizing': '$dialog-shadow',
},
@@ -92,6 +103,18 @@ const WidgetElement = tasty({
drag: 'grabbing',
},
touchAction: 'none',
+ // A widget owns its grid cell and must not paint outside it: a nested board
+ // with more rows than currently fit, a mid-drag reflow (an auto-height
+ // container deliberately cannot grow while a drag is in flight), or a long
+ // unbreakable string would otherwise spill over its neighbours. Clipping
+ // holds regardless of `isCard` — a borderless widget has no drawn edge, but
+ // it still has a cell.
+ //
+ // The cost is that a descendant's `outline` is cropped at the edge, since an
+ // outline is clipped by an *ancestor's* overflow rather than its own. A
+ // widget whose content needs to paint outside — a control drawing its own
+ // active ring — opts out with `overflow="visible"`, or draws the ring inset
+ // with a negative `outlineOffset`.
overflow: 'hidden',
},
});
@@ -386,6 +409,19 @@ export interface WidgetHostProps {
* public drag callbacks. Fires after the registry has updated drag state.
*/
onDragLifecycle?: (id: string, phase: ResizePhase) => void;
+ /** Whether this widget can be selected (board selection on and not opted out). */
+ isSelectable?: boolean;
+ isSelected?: boolean;
+ /** CSS selector for descendants whose clicks must not change the selection. */
+ selectionCancel?: string;
+ /** Id of the board-owned node holding the shared "Selected" description. */
+ selectedHintId?: string;
+ /** Apply a selection gesture. `additive` toggles instead of replacing. */
+ onSelect?: (id: string, additive: boolean) => void;
+ /** Drop the whole selection. Called when the user interacts elsewhere. */
+ onSelectionReset?: () => void;
+ /** Pointer-event property carrying the platform additive-selection modifier. */
+ selectModifierKey?: BoardSelectModifierKey;
}
/**
@@ -415,10 +451,24 @@ export function WidgetHost(props: WidgetHostProps) {
onResize,
onAutoHeight,
onDragLifecycle,
+ isSelectable = false,
+ isSelected = false,
+ selectionCancel,
+ selectedHintId,
+ onSelect,
+ onSelectionReset,
+ selectModifierKey = 'metaKey',
} = props;
+ const { t } = useI18n();
+ const ariaLabel = registration?.['aria-label'];
const hostRef = useRef(null);
const isActiveDrag = dragState?.itemId === item.i;
+ // Every member of a group drag floats, not just the grabbed one — otherwise
+ // the rest sit motionless while their placeholders move, which reads as
+ // broken. Only the grabbed host owns a live `useMove` gesture, so hiding the
+ // others is trivially safe.
+ const isDragMember = !!dragState?.itemIds.includes(item.i);
// Translate a nested board's reported height deficit (signed px: positive when
// it is squeezed, negative when it has slack) into the absolute number of rows
@@ -523,10 +573,97 @@ export function WidgetHost(props: WidgetHostProps) {
},
});
+ // `role="group"` is what makes `aria-roledescription` legal here - it is
+ // invalid on a role-less `div`, which is what this element used to be. A
+ // collection role (`option`, `gridcell`, ...) would be the only way to carry a
+ // real `aria-selected`, but those require presentational children and a
+ // widget hosts arbitrary interactive content, so selection is conveyed by the
+ // board's live region plus the shared description below instead.
const a11yProps = {
- tabIndex: isDraggable ? 0 : undefined,
- 'aria-roledescription': isDraggable ? 'Draggable widget' : undefined,
- 'aria-label': qa ?? item.i,
+ role: 'group',
+ tabIndex: isDraggable || isSelectable ? 0 : undefined,
+ 'aria-roledescription': isDraggable
+ ? t('board.draggableWidget', 'Draggable widget')
+ : t('board.widget', 'Widget'),
+ 'aria-label': ariaLabel ?? qa ?? item.i,
+ 'aria-describedby': isSelected ? selectedHintId : undefined,
+ 'aria-keyshortcuts': isSelectable ? 'Space' : undefined,
+ };
+
+ // True from the moment a real move begins until the click that ends the
+ // gesture has been swallowed. `useMove` binds only pointerdown/keydown, never
+ // click, so a click handler composes with it cleanly - but the click that
+ // *terminates* a drag would otherwise land as a selection. Event order is
+ // pointerdown -> onMove* -> pointerup -> onMoveEnd -> click, so this ref is
+ // always accurate by the time the click arrives. More reliable than a pixel
+ // threshold, which has to guess.
+ const isInteractiveTarget = (target: EventTarget | null) => {
+ const el = target as HTMLElement | null;
+
+ return !!(selectionCancel && el?.closest?.(selectionCancel));
+ };
+
+ /**
+ * Selecting and starting a drag are the *same* gesture: you grab the thing you
+ * are about to move. So the press selects immediately and the drag arms behind
+ * it — move and it drags, stay still and it was only a selection. This is what
+ * every canvas tool does, and it is why there is no ambiguity to resolve with a
+ * modifier.
+ *
+ * - a press on an interactive descendant belongs to that control, so the
+ * selection is dropped and the press is left alone;
+ * - Shift or the platform modifier toggles membership;
+ * - a press on an unselected widget makes it the selection, so the drag that
+ * follows moves exactly what was grabbed;
+ * - a press on an already-selected widget changes nothing, so the drag moves
+ * the whole group.
+ *
+ * Runs before the drag handler (see the `mergeProps` order below), so the
+ * registry resolves the group against the selection this press just made.
+ */
+ const handleSelectPointerDown = (e: React.PointerEvent) => {
+ if (!isSelectable || !onSelect || e.button !== 0) return;
+
+ if (isInteractiveTarget(e.target)) {
+ onSelectionReset?.();
+
+ return;
+ }
+
+ // A press this widget owns must never reach an ancestor widget host: in a
+ // nested board the outer widget would otherwise select itself on top of the
+ // inner selection. This has to cover the already-selected case too, which
+ // changes nothing here but is still a press this board handled — and
+ // `stopBubbleProps` below only guards draggable widgets.
+ e.stopPropagation();
+
+ if (e.shiftKey || e[selectModifierKey]) {
+ onSelect(item.i, true);
+
+ return;
+ }
+
+ if (!isSelected) {
+ onSelect(item.i, false);
+ }
+ };
+
+ const handleSelectKeyDown = (e: React.KeyboardEvent) => {
+ // Same guard the arrow keys use: only act when the host itself is focused,
+ // so Space inside a nested input or button keeps its meaning.
+ if (e.target !== e.currentTarget) return;
+ if (!isSelectable || !onSelect) return;
+ if (e.key !== ' ' && e.key !== 'Spacebar') return;
+
+ // No modifier needed here: focus already says which widget is meant, and
+ // Space cannot be mistaken for the start of a drag. Only swallow the key
+ // once we know we are acting on it, so a board without selection still
+ // scrolls on Space.
+ e.preventDefault();
+ e.stopPropagation();
+ // Space is an explicit selection gesture (a click is also "interact with
+ // this"), so it toggles - that is the keyboard's way to deselect one widget.
+ onSelect(item.i, true);
};
// When this widget is draggable it owns its gesture, so stop the pointer-down
@@ -549,7 +686,19 @@ export function WidgetHost(props: WidgetHostProps) {
const shouldGateDrag = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false;
if (dragHandle && !target.closest(dragHandle)) return true;
- return !!(dragCancel && target.closest(dragCancel));
+ if (dragCancel && target.closest(dragCancel)) return true;
+
+ // `selectionCancel` already declares which descendants are interactive, and
+ // a drag must not start from them either — otherwise `useMove`'s
+ // `preventDefault()` on pointer-down swallows the native focus and an input
+ // inside a widget cannot be typed into. Only for selectable widgets, so a
+ // board that never opted into selection keeps its exact previous behaviour
+ // and `dragCancel` stays the only thing that gates a drag there.
+ return !!(
+ isSelectable &&
+ selectionCancel &&
+ target.closest(selectionCancel)
+ );
};
// The gate wraps `useMove`'s own pointer-down handlers rather than a separate
@@ -600,11 +749,25 @@ export function WidgetHost(props: WidgetHostProps) {
...(moveProps.onKeyDown && {
onKeyDown: (e: React.KeyboardEvent) => {
if (e.target !== e.currentTarget) return;
+ handleSelectKeyDown(e);
+ if (e.defaultPrevented) return;
moveProps.onKeyDown!(e);
},
}),
};
+ // A non-draggable widget gets no `moveProps` at all, so its Space handling and
+ // its focusability have to come from here instead.
+ const selectionProps = isSelectable
+ ? {
+ onPointerDown: handleSelectPointerDown,
+ // A draggable widget routes Space through the drag gate below, which
+ // already enforces the host-focused rule; a non-draggable one gets no
+ // `moveProps` at all and needs its own handler.
+ ...(isDraggable ? {} : { onKeyDown: handleSelectKeyDown }),
+ }
+ : {};
+
const handleResize = (
axis: ResizeHandleAxis,
phase: ResizePhase,
@@ -624,6 +787,7 @@ export function WidgetHost(props: WidgetHostProps) {
card: isCard,
hovered: isHovered,
'focus-visible': isFocusVisible,
+ selected: isSelected,
};
const content = (
@@ -680,7 +844,36 @@ export function WidgetHost(props: WidgetHostProps) {
// gesture for its whole lifetime, and portal a separate, non-interactive
// visual clone into the overlay.
const overlayNode = registry.overlayRef.current;
- const floatInOverlay = useOverlay && !!overlayNode && !!dragState;
+ // Non-grabbed group members float too, from their own drag-start rect plus the
+ // one shared gesture delta. Measuring them here would be wrong (the board is
+ // mid-reflow); the registry measured every member once at drag start, which is
+ // the only safe window.
+ const memberRect = isDragMember
+ ? dragState!.memberRects.get(item.i)
+ : undefined;
+ const floatInOverlay =
+ !!overlayNode &&
+ !!dragState &&
+ dragState.pointerType !== 'keyboard' &&
+ (useOverlay || (isDragMember && !!memberRect));
+
+ // Where the floating clone sits. The grabbed widget tracks the live drag rect
+ // directly; a member tracks its own start rect offset by the same delta, so
+ // the block moves as one.
+ const floatRect =
+ floatInOverlay && dragState
+ ? isActiveDrag || !memberRect
+ ? dragState.rect
+ : {
+ left:
+ memberRect.left +
+ (dragState.rect.left - dragState.startRect.left),
+ top:
+ memberRect.top + (dragState.rect.top - dragState.startRect.top),
+ width: memberRect.width,
+ height: memberRect.height,
+ }
+ : null;
const hostStyle: CSSProperties = {
left: `${pos.left}px`,
@@ -707,7 +900,16 @@ export function WidgetHost(props: WidgetHostProps) {
// a nested board while the anchor is still over its host - e.g. the Tabs
// header above a nested board - instead of reflowing the ancestor board).
data-board-widget-host=""
+ // Which widget, as opposed to "am I inside a widget host" - the two are
+ // separate questions and the existing attribute is used as a presence
+ // selector, so overloading it would make every such selector implicitly
+ // value-dependent. Namespaced so it cannot collide with an app's own
+ // `data-widget-id`.
+ data-board-widget-id={item.i}
{...mergeProps(
+ // Before `gatedMoveProps`: the press selects, and only then does the
+ // drag start and read that selection to decide what moves.
+ selectionProps,
gatedMoveProps,
stopBubbleProps,
hoverProps,
@@ -729,10 +931,10 @@ export function WidgetHost(props: WidgetHostProps) {
number;
getContainerHeight: () => number;
getLayout: () => LayoutItem[];
+ /**
+ * Keys of the widgets currently selected on this board, or `null` when the
+ * board has no selection. Read at drag start to decide whether the gesture
+ * moves one widget or a whole group.
+ */
+ getSelectedKeys: () => ReadonlySet | null;
/** Update the board layout. `commit` fires `onLayoutChange`. */
applyLayout: (layout: LayoutItem[], commit: boolean) => void;
- setPlaceholder: (item: LayoutItem | null) => void;
+ /** Replace every drop-slot preview. Pass `[]` to clear. */
+ setPlaceholders: (items: LayoutItem[]) => void;
isDroppable: () => boolean;
}
@@ -55,8 +62,24 @@ export interface BoardDragState {
itemId: string;
/** Snapshot of the dragged item (grid units; w/h preserved across boards). */
item: LayoutItem;
+ /**
+ * Every widget moving in this gesture, the grabbed one first. Length 1 for an
+ * ordinary drag, so `itemIds[0] === itemId` always holds and any check against
+ * this set is a strict superset of the equivalent check against `itemId`.
+ */
+ itemIds: string[];
+ /** Drag-start snapshots of `itemIds`, in the same order. */
+ items: LayoutItem[];
/** Dragged widget rect in viewport coordinates (follows the pointer). */
rect: ViewportRect;
+ /** `rect` as measured at drag start — lets any host derive the gesture delta. */
+ startRect: ViewportRect;
+ /**
+ * Viewport rect of every group member's host, measured once at drag start.
+ * Drag start is the only safe window to measure (see `frozenRectsRef` in the
+ * registry); measuring later would feed the preview back into itself.
+ */
+ memberRects: Map;
pointerType: string;
/**
* Ids of boards nested inside the dragged widget, captured at drag start.
@@ -82,6 +105,13 @@ export interface BoardRegistryContextValue {
onDragMove: (deltaX: number, deltaY: number, pointerType: string) => void;
onDragEnd: () => void;
dragState: BoardDragState | null;
+ /**
+ * Synchronously-updated mirror of `dragState`. `dragState` is React state, so
+ * a handler running in the same tick as `onDragStart` — such as the drag
+ * lifecycle `WidgetHost` fires immediately afterwards — still sees the
+ * previous value and must read this instead.
+ */
+ getDragState: () => BoardDragState | null;
}
export const BoardRegistryContext =
diff --git a/src/components/layout/Board/board-store.ts b/src/components/layout/Board/board-store.ts
index fd4fc3bb4..ca35dddce 100644
--- a/src/components/layout/Board/board-store.ts
+++ b/src/components/layout/Board/board-store.ts
@@ -38,6 +38,12 @@ export interface WidgetRegistration {
dragCancel?: string;
/** Override the board's `dragHandle` selector for this widget. */
dragHandle?: string;
+ /** Disable selection for this widget while the board's selection is on. */
+ isSelectable?: boolean;
+ /** Override the board's `selectionCancel` selector for this widget. */
+ selectionCancel?: string;
+ /** Accessible name; falls back to `qa`, then the layout item id. */
+ 'aria-label'?: string;
}
/**
@@ -86,7 +92,10 @@ export class BoardWidgetStore {
prev.styles !== reg.styles ||
prev.isAutoHeight !== reg.isAutoHeight ||
prev.dragCancel !== reg.dragCancel ||
- prev.dragHandle !== reg.dragHandle;
+ prev.dragHandle !== reg.dragHandle ||
+ prev.isSelectable !== reg.isSelectable ||
+ prev.selectionCancel !== reg.selectionCancel ||
+ prev['aria-label'] !== reg['aria-label'];
if (changed) {
this.version++;
diff --git a/src/components/layout/Board/grid-core/group-move.test.ts b/src/components/layout/Board/grid-core/group-move.test.ts
new file mode 100644
index 000000000..931b1ba2c
--- /dev/null
+++ b/src/components/layout/Board/grid-core/group-move.test.ts
@@ -0,0 +1,400 @@
+import { describe, expect, it } from 'vitest';
+
+import { getCompactor } from './compactors';
+import { moveElements } from './group-move';
+
+import type { LayoutItem } from './types';
+
+function item(
+ i: string,
+ x: number,
+ y: number,
+ w = 2,
+ h = 2,
+ extra: Partial = {},
+): LayoutItem {
+ return { i, x, y, w, h, ...extra };
+}
+
+/** `{ id: 'x,y' }` — compact enough to assert a whole layout at a glance. */
+function positions(layout: LayoutItem[]): Record {
+ return Object.fromEntries(layout.map((it) => [it.i, `${it.x},${it.y}`]));
+}
+
+const free = getCompactor(null, false, true);
+const loose = getCompactor(null, false, false);
+const vertical = getCompactor('vertical', false, false);
+const overlap = getCompactor(null, true, false);
+
+describe('moveElements', () => {
+ describe('rigid movement', () => {
+ it('applies the same delta to every mover', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 6)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 2, 1, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(true);
+ expect(positions(result.layout)).toEqual({ a: '2,1', b: '6,7' });
+ });
+
+ it('leaves non-movers alone when nothing collides', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 0), item('other', 8, 0)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 4, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(result.layout).other).toBe('8,0');
+ });
+
+ it('preserves the input item order', () => {
+ const layout = [item('a', 0, 0), item('z', 4, 0), item('m', 8, 0)];
+
+ const result = moveElements(layout, new Set(['a', 'm']), 0, 4, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.layout.map((it) => it.i)).toEqual(['a', 'z', 'm']);
+ });
+
+ it('does not mutate the input layout', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 0)];
+
+ moveElements(layout, new Set(['a', 'b']), 3, 3, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(layout)).toEqual({ a: '0,0', b: '4,0' });
+ });
+
+ it('ignores ids that are not in the layout', () => {
+ const layout = [item('a', 0, 0)];
+
+ const result = moveElements(layout, new Set(['a', 'ghost']), 1, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(true);
+ expect(positions(result.layout)).toEqual({ a: '1,0' });
+ });
+
+ it('reports no movement when the selection is empty', () => {
+ const layout = [item('a', 0, 0)];
+
+ const result = moveElements(layout, new Set(), 1, 1, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(false);
+ expect(positions(result.layout)).toEqual({ a: '0,0' });
+ });
+ });
+
+ describe('group clamping', () => {
+ // The bug this exists to prevent: clamping each item into bounds separately
+ // collapses the group against the wall and it never recovers its shape.
+ it('keeps the group shape when dragged past the left edge', () => {
+ const layout = [item('a', 0, 0), item('b', 6, 0)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), -5, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,0', b: '6,0' });
+ expect(result.dx).toBe(0);
+ });
+
+ it('keeps the group shape when dragged past the right edge', () => {
+ const layout = [item('a', 0, 0), item('b', 6, 0)];
+
+ // `b` can travel 4 columns (6 + 2 + 4 === 12); `a` must stop there too.
+ const result = moveElements(layout, new Set(['a', 'b']), 9, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '4,0', b: '10,0' });
+ expect(result.dx).toBe(4);
+ });
+
+ it('clamps upward movement at the top row', () => {
+ const layout = [item('a', 0, 2), item('b', 4, 5)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, -9, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,0', b: '4,3' });
+ expect(result.dy).toBe(-2);
+ });
+
+ it('clamps downward movement at maxRows', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 2)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 20, {
+ compactor: free,
+ cols: 12,
+ maxRows: 8,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,4', b: '4,6' });
+ expect(result.dy).toBe(4);
+ });
+
+ it('does not invert the range when an item is wider than the grid', () => {
+ const layout = [item('wide', 0, 0, 14, 2), item('b', 0, 4)];
+
+ const result = moveElements(layout, new Set(['wide', 'b']), 3, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(true);
+ expect(result.dx).toBe(0);
+ expect(positions(result.layout)).toEqual({ wide: '0,0', b: '0,4' });
+ });
+ });
+
+ describe('collisions with non-movers', () => {
+ it('blocks the whole frame under preventCollision', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 0), item('wall', 6, 0)];
+
+ // Only `b` would hit `wall`, but a partial delta is what shears a group.
+ const result = moveElements(layout, new Set(['a', 'b']), 2, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(false);
+ expect(positions(result.layout)).toEqual({
+ a: '0,0',
+ b: '4,0',
+ wall: '6,0',
+ });
+ });
+
+ it('pushes a colliding non-mover down when collisions are allowed', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 0), item('victim', 0, 4)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 3, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(true);
+ expect(positions(result.layout)).toEqual({
+ a: '0,3',
+ b: '4,3',
+ victim: '0,5',
+ });
+ });
+
+ it('cascades the push through stacked non-movers', () => {
+ const layout = [
+ item('a', 0, 0),
+ item('first', 0, 2),
+ item('second', 0, 4),
+ ];
+
+ const result = moveElements(layout, new Set(['a']), 0, 1, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({
+ a: '0,1',
+ first: '0,3',
+ second: '0,5',
+ });
+ });
+
+ it('rejects a frame that would overlap a static non-mover', () => {
+ const layout = [
+ item('a', 0, 0),
+ item('pinned', 0, 4, 2, 2, { static: true }),
+ ];
+
+ const result = moveElements(layout, new Set(['a']), 0, 3, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(false);
+ expect(positions(result.layout)).toEqual({ a: '0,0', pinned: '0,4' });
+ });
+
+ it('applies the delta untouched when overlap is allowed', () => {
+ const layout = [item('a', 0, 0), item('victim', 0, 4)];
+
+ const result = moveElements(layout, new Set(['a']), 0, 4, {
+ compactor: overlap,
+ cols: 12,
+ });
+
+ expect(result.moved).toBe(true);
+ expect(positions(result.layout)).toEqual({ a: '0,4', victim: '0,4' });
+ });
+ });
+
+ describe('movers', () => {
+ it('never treats one mover as an obstacle for another', () => {
+ // `b` sits directly below `a`; moving both down must not push `b` away.
+ const layout = [item('a', 0, 0), item('b', 0, 2)];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 2, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,2', b: '0,4' });
+ });
+
+ it('excludes a static item from the selection', () => {
+ const layout = [
+ item('a', 0, 0),
+ item('pinned', 4, 0, 2, 2, { static: true }),
+ ];
+
+ const result = moveElements(layout, new Set(['a', 'pinned']), 0, 4, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,4', pinned: '4,0' });
+ });
+
+ it('includes a static item that opts back into dragging', () => {
+ const layout = [
+ item('a', 0, 0),
+ item('opted', 4, 0, 2, 2, { static: true, isDraggable: true }),
+ ];
+
+ const result = moveElements(layout, new Set(['a', 'opted']), 0, 4, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,4', opted: '4,4' });
+ });
+ });
+
+ describe('reflow of non-movers', () => {
+ // The whole point: a group drag must reflow its neighbours exactly like a
+ // single drag does. The moment the group vacates a row, whatever sat below
+ // floats up into it — anything less reads as the board lagging a step.
+ it('closes the gap the group leaves behind, in the same frame', () => {
+ const layout = [
+ item('a', 0, 0, 2, 1),
+ item('b', 2, 0, 2, 1),
+ item('e', 0, 3, 2, 1),
+ ];
+
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 6, {
+ compactor: vertical,
+ cols: 12,
+ });
+
+ // `e` rises to the top and the group settles around it, rather than the
+ // group parking in mid-air with `e` shoved below it. A vertically
+ // compacted board never leaves a gap, for a group no more than for one
+ // widget.
+ expect(positions(result.layout)).toEqual({
+ a: '0,1',
+ b: '2,0',
+ e: '0,0',
+ });
+ });
+
+ it('gives a one-widget group the same result as moving that widget', () => {
+ const layout = [item('a', 0, 0, 2, 1), item('e', 0, 3, 2, 1)];
+
+ const result = moveElements(layout, new Set(['a']), 0, 6, {
+ compactor: vertical,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,1', e: '0,0' });
+ });
+
+ it('settles in one frame — reapplying the result changes nothing', () => {
+ const layout = [
+ item('a', 0, 0, 2, 1),
+ item('b', 2, 0, 2, 1),
+ item('e', 0, 3, 2, 1),
+ ];
+ const opts = { compactor: vertical, cols: 12 } as const;
+
+ const once = moveElements(layout, new Set(['a', 'b']), 0, 6, opts);
+ const twice = moveElements(once.layout, new Set(['a', 'b']), 0, 0, opts);
+
+ expect(positions(twice.layout)).toEqual(positions(once.layout));
+ });
+
+ it('keeps a freeform group exactly where it was dropped', () => {
+ const layout = [item('a', 0, 0), item('b', 4, 0)];
+
+ // Nothing compacts here, so the block stays put — the behaviour `free`
+ // and legacy boards rely on.
+ const result = moveElements(layout, new Set(['a', 'b']), 0, 5, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,5', b: '4,5' });
+ });
+
+ it('still pushes a neighbour aside when nothing compacts', () => {
+ const layout = [item('a', 0, 0), item('victim', 0, 4)];
+
+ const result = moveElements(layout, new Set(['a']), 0, 3, {
+ compactor: loose,
+ cols: 12,
+ });
+
+ expect(positions(result.layout)).toEqual({ a: '0,3', victim: '0,5' });
+ });
+
+ // A leaked `static` flag would freeze a widget forever, and consumers
+ // persist layouts, so it would survive a reload.
+ it('never marks anything static', () => {
+ const layout = [
+ item('a', 0, 0),
+ item('opted', 4, 0, 2, 2, { static: true, isDraggable: true }),
+ item('other', 8, 0),
+ ];
+
+ const result = moveElements(layout, new Set(['a', 'opted']), 0, 3, {
+ compactor: vertical,
+ cols: 12,
+ });
+
+ expect(
+ Object.fromEntries(result.layout.map((it) => [it.i, it.static])),
+ ).toEqual({ a: false, opted: true, other: false });
+ });
+ });
+
+ describe('rejected frames', () => {
+ it('returns a zero delta alongside the untouched layout', () => {
+ const layout = [item('a', 0, 0), item('wall', 2, 0)];
+
+ const result = moveElements(layout, new Set(['a']), 2, 0, {
+ compactor: free,
+ cols: 12,
+ });
+
+ expect(result).toMatchObject({ moved: false, dx: 0, dy: 0 });
+ expect(positions(result.layout)).toEqual({ a: '0,0', wall: '2,0' });
+ });
+ });
+});
diff --git a/src/components/layout/Board/grid-core/group-move.ts b/src/components/layout/Board/grid-core/group-move.ts
new file mode 100644
index 000000000..7cbce6e9b
--- /dev/null
+++ b/src/components/layout/Board/grid-core/group-move.ts
@@ -0,0 +1,214 @@
+/**
+ * Rigid multi-item movement.
+ *
+ * `moveElement` (see ./layout.ts) moves exactly one item and is free to displace
+ * any other item — including one the caller also wanted to move — as a collision
+ * victim. That makes it unusable for moving a *selection* as a block: the shape
+ * of the group would be destroyed by the very collision resolution meant to make
+ * room for it.
+ *
+ * `moveElements` moves a set of items rigidly instead. Every mover receives the
+ * same `(dx, dy)`, so the group's shape is invariant *by construction* rather
+ * than by repair.
+ *
+ * This module is UI-Kit-specific; it is not part of the vendored react-grid-layout
+ * core (see ./NOTICE.md).
+ */
+
+import { collides, getFirstCollision } from './collision';
+import { bottom, cloneLayout } from './layout';
+
+import type { Compactor, Layout, LayoutItem, Mutable } from './types';
+
+export interface MoveElementsOptions {
+ /**
+ * The board's compactor. Supplies `type`, `allowOverlap` and
+ * `preventCollision` as one consistent unit, and performs the tidy-up pass —
+ * taking it whole is what keeps this function from ever disagreeing with the
+ * board about compaction mode.
+ */
+ compactor: Compactor;
+ cols: number;
+ /** @default Infinity */
+ maxRows?: number;
+}
+
+export interface MoveElementsResult {
+ layout: LayoutItem[];
+ /** The delta actually applied, after clamping the group to the grid. */
+ dx: number;
+ dy: number;
+ /**
+ * Whether a valid arrangement was produced. `false` means the frame must be
+ * discarded and the previous one kept — never partially applied, since a
+ * partial delta is precisely what shears the group apart.
+ */
+ moved: boolean;
+}
+
+/** An item may be moved unless it is static and does not opt back in. */
+function isMovable(item: LayoutItem): boolean {
+ return !item.static || item.isDraggable === true;
+}
+
+/**
+ * Move every item in `ids` by the same delta, resolving collisions with the
+ * items that are *not* moving.
+ *
+ * Semantics, all deliberate:
+ *
+ * - **Group-clamped, not item-clamped.** The delta is clamped once against the
+ * whole group, so dragging into an edge parks the block against it. Clamping
+ * each item into bounds separately would collapse the group's shape the first
+ * time it touched a wall, and it would never recover.
+ * - **All-or-nothing.** If the frame cannot be resolved, nothing moves.
+ * - **Movers never collide with each other.** They keep their relative
+ * positions, so only mover↔non-mover and non-mover↔non-mover overlaps resolve.
+ * - **A static item is never a mover**, matching `moveElement`'s own guard, and
+ * a mover can never displace a static item — that frame is rejected instead.
+ *
+ * Returns a new layout in the input's item order; the input is never mutated.
+ */
+export function moveElements(
+ layout: Layout,
+ ids: ReadonlySet,
+ dx: number,
+ dy: number,
+ options: MoveElementsOptions,
+): MoveElementsResult {
+ const { compactor, cols, maxRows = Infinity } = options;
+
+ const working = cloneLayout(layout);
+ const movers = working.filter((it) => ids.has(it.i) && isMovable(it));
+
+ if (movers.length === 0) {
+ return { layout: working, dx: 0, dy: 0, moved: false };
+ }
+
+ const moverIds = new Set(movers.map((it) => it.i));
+ const others = working.filter((it) => !moverIds.has(it.i));
+
+ // ---- Clamp the delta to the group ----------------------------------------
+ //
+ // The group can travel until its *first* item hits an edge. `Math.max` on the
+ // upper bounds guards the degenerate case of an item wider/taller than the
+ // grid, where the max would otherwise fall below the min and invert the range.
+ let minDx = -Infinity;
+ let maxDx = Infinity;
+ let minDy = -Infinity;
+ let maxDy = Infinity;
+
+ for (const it of movers) {
+ minDx = Math.max(minDx, -it.x);
+ maxDx = Math.min(maxDx, cols - it.w - it.x);
+ minDy = Math.max(minDy, -it.y);
+ maxDy = Math.min(maxDy, maxRows - it.h - it.y);
+ }
+ maxDx = Math.max(minDx, maxDx);
+ maxDy = Math.max(minDy, maxDy);
+
+ // `|| 0` collapses `-0`, which `Math.max` produces whenever a mover sits at
+ // coordinate 0. It compares equal to `0` but not under `Object.is`, and it
+ // would otherwise be handed back to callers as a delta.
+ const clampedDx = Math.min(Math.max(dx, minDx), maxDx) || 0;
+ const clampedDy = Math.min(Math.max(dy, minDy), maxDy) || 0;
+
+ for (const it of movers) {
+ (it as Mutable).x = it.x + clampedDx;
+ (it as Mutable).y = it.y + clampedDy;
+ }
+
+ const rejected: MoveElementsResult = {
+ layout: cloneLayout(layout),
+ dx: 0,
+ dy: 0,
+ moved: false,
+ };
+
+ if (!compactor.allowOverlap) {
+ // A static non-mover cannot be pushed out of the way, so overlapping one is
+ // never resolvable.
+ for (const it of others) {
+ if (it.static && getFirstCollision(movers, it)) {
+ return rejected;
+ }
+ }
+
+ if (compactor.preventCollision) {
+ // `compact="free"` / legacy no-compaction with collisions prevented: the
+ // group may only land where it fits outright.
+ for (const it of movers) {
+ if (getFirstCollision(others, it)) {
+ return rejected;
+ }
+ }
+ } else if (compactor.type === null && !pushOthersDown(movers, others)) {
+ // Only when nothing else will: a compacting compactor resolves overlaps
+ // itself (and floats items back up afterwards), so pre-pushing there just
+ // shoves neighbours further than needed and makes the board look like it
+ // is lagging a step behind the pointer.
+ return rejected;
+ }
+ }
+
+ // Compact exactly as the single-widget path does — the group is not held in
+ // place. On a vertically-compacted board a lone widget can never be parked in
+ // empty space, and a group must not be able to either: neighbours have to
+ // close the gap the moment it opens, or the board reads as a step behind.
+ const out = [...compactor.compact(working, cols)];
+
+ return { layout: out, dx: clampedDx, dy: clampedDy, moved: true };
+}
+
+/**
+ * Push every non-mover that overlaps the group (or another displaced item)
+ * straight down until nothing overlaps.
+ *
+ * Down, always — even under horizontal compaction. Downward is the one direction
+ * a grid always has room in, so the pass is guaranteed to terminate; the
+ * compactor then re-packs along its own axis immediately afterwards. Pushing
+ * sideways would need overflow wrapping here and could ping-pong an item between
+ * two neighbours.
+ *
+ * Mutates `others` in place. Returns `false` if the cascade fails to settle,
+ * which the caller turns into a rejected frame.
+ */
+function pushOthersDown(movers: LayoutItem[], others: LayoutItem[]): boolean {
+ // Place one at a time, in reading order, against a set that starts as the
+ // group. Each push moves an item strictly past a placed item's bottom edge, so
+ // it advances monotonically and the loop is bounded by the stack height.
+ const ordered = [...others].sort((a, b) =>
+ a.y === b.y ? a.x - b.x : a.y - b.y,
+ );
+ const placed: LayoutItem[] = [...movers];
+ const limit = bottom(movers) + bottom(others) + others.length + 1;
+
+ for (const item of ordered) {
+ if (item.static) {
+ // Verified collision-free against the movers by the caller; other statics
+ // are pre-existing and not ours to resolve.
+ placed.push(item);
+ continue;
+ }
+
+ let steps = 0;
+ let shifted = true;
+
+ while (shifted) {
+ shifted = false;
+ for (const other of placed) {
+ if (collides(item, other)) {
+ (item as Mutable).y = other.y + other.h;
+ shifted = true;
+ }
+ }
+ if (++steps > limit) {
+ return false;
+ }
+ }
+
+ placed.push(item);
+ }
+
+ return true;
+}
diff --git a/src/components/layout/Board/grid-core/index.ts b/src/components/layout/Board/grid-core/index.ts
index a4b80f1a3..ccd33b649 100644
--- a/src/components/layout/Board/grid-core/index.ts
+++ b/src/components/layout/Board/grid-core/index.ts
@@ -43,6 +43,9 @@ export {
validateLayout,
} from './layout';
+export type { MoveElementsOptions, MoveElementsResult } from './group-move';
+export { moveElements } from './group-move';
+
export {
resolveCompactionCollision,
compactItemVertical,
diff --git a/src/components/layout/Board/index.tsx b/src/components/layout/Board/index.tsx
index e9cbf7302..92b44bdba 100644
--- a/src/components/layout/Board/index.tsx
+++ b/src/components/layout/Board/index.tsx
@@ -13,6 +13,7 @@ export { Board };
export { BoardProvider };
export { BoardResponsive };
export { Widget as BoardWidget };
+export { BOARD_SELECTION_CANCEL } from './Board';
export type {
CubeBoardProps,
@@ -20,6 +21,7 @@ export type {
BoardGridLines,
BoardInteractionInfo,
} from './Board';
+export type { BoardSelectionMode } from './use-board-selection';
export type { CubeBoardResponsiveProps } from './BoardResponsive';
export type { CubeBoardWidgetProps } from './Widget';
export type { CubeBoardProviderProps } from './BoardProvider';
diff --git a/src/components/layout/Board/use-board-layout.ts b/src/components/layout/Board/use-board-layout.ts
index 3bf3d7e1a..c7f1ae8ca 100644
--- a/src/components/layout/Board/use-board-layout.ts
+++ b/src/components/layout/Board/use-board-layout.ts
@@ -15,15 +15,26 @@ export interface UseBoardLayoutOptions {
export interface UseBoardLayoutResult {
layout: LayoutItem[];
layoutRef: React.MutableRefObject;
+ /**
+ * Every drop-slot preview for the gesture in flight. A single-widget drag or
+ * resize produces one; a group drag produces one per moving widget.
+ */
+ placeholders: LayoutItem[];
+ /**
+ * The grabbed widget's placeholder — `placeholders[0]`, or `null` when there
+ * is none. Kept as a derived singular so the public `BoardInteractionInfo`
+ * keeps its exact shape.
+ */
placeholder: LayoutItem | null;
/**
- * Synchronously-updated mirror of `placeholder`. `setPlaceholder` only
+ * Synchronously-updated mirrors of the two above. `setPlaceholders` only
* schedules a re-render, so consumers that run in the same tick as a
- * `setPlaceholder` call (e.g. drag lifecycle callbacks fired right after the
- * registry updates the placeholder) must read the ref to see the live value.
+ * `setPlaceholders` call (e.g. drag lifecycle callbacks fired right after the
+ * registry updates the placeholders) must read a ref to see the live value.
*/
+ placeholdersRef: React.MutableRefObject;
placeholderRef: React.MutableRefObject;
- setPlaceholder: (item: LayoutItem | null) => void;
+ setPlaceholders: (items: LayoutItem[]) => void;
/** Update the layout. `commit` fires `onLayoutChange`. */
applyLayout: (layout: LayoutItem[], commit: boolean) => void;
}
@@ -47,11 +58,15 @@ export function useBoardLayout(
const layoutRef = useRef(layout);
layoutRef.current = layout;
- const [placeholder, setPlaceholderState] = useState(null);
+ const [placeholders, setPlaceholdersState] = useState([]);
+ const placeholdersRef = useRef([]);
const placeholderRef = useRef(null);
- const setPlaceholder = useCallback((item: LayoutItem | null) => {
- placeholderRef.current = item;
- setPlaceholderState(item);
+ const setPlaceholders = useCallback((items: LayoutItem[]) => {
+ placeholdersRef.current = items;
+ // Both mirrors move in the same synchronous call, so a same-tick reader can
+ // never see the two disagree.
+ placeholderRef.current = items[0] ?? null;
+ setPlaceholdersState(items);
}, []);
const onLayoutChangeEvent = useEvent((next: LayoutItem[]) =>
@@ -83,9 +98,11 @@ export function useBoardLayout(
return {
layout,
layoutRef,
- placeholder,
+ placeholders,
+ placeholder: placeholders[0] ?? null,
+ placeholdersRef,
placeholderRef,
- setPlaceholder,
+ setPlaceholders,
applyLayout,
};
}
diff --git a/src/components/layout/Board/use-board-registry.ts b/src/components/layout/Board/use-board-registry.ts
index 3b84fba63..48f7118e0 100644
--- a/src/components/layout/Board/use-board-registry.ts
+++ b/src/components/layout/Board/use-board-registry.ts
@@ -19,6 +19,7 @@ import {
getLayoutItem,
LayoutItem,
moveElement,
+ moveElements,
} from './grid-core';
/**
@@ -207,6 +208,8 @@ export function useBoardRegistry(
setDragStateInternal(next);
}, []);
+ const getDragState = useCallback(() => dragStateRef.current, []);
+
// Record the live cursor for the ancestor-handoff gate (see `pointerPosRef`).
// Capture phase so it lands before `useMove`'s own window listeners drive the
// frame's `onDragMove`.
@@ -341,18 +344,75 @@ export function useBoardRegistry(
widgetNode: HTMLElement | null,
) => {
const entry = boardsRef.current.get(boardId);
- const item = entry ? getLayoutItem(entry.getLayout(), itemId) : undefined;
+ const layoutAtStart = entry?.getLayout() ?? [];
+ const item = entry ? getLayoutItem(layoutAtStart, itemId) : undefined;
if (!entry || !item) return;
- // Record boards nested inside the dragged widget so they are never picked
- // as a drop target (dropping a widget into a board nested within itself
- // would unmount it). Computed here, before the widget floats into the
- // overlay, while its nested boards are still in-grid descendants.
+ // ---- Resolve the gesture's membership --------------------------------
+ //
+ // This is the ONE place a group drag is decided. Everything downstream
+ // branches on `itemIds.length`, never on the selection itself, which is
+ // what keeps the single-widget path provably untouched.
+ //
+ // Three rules, all enforced here:
+ // 1. The grabbed widget must already be in the selection. Grabbing an
+ // unselected widget is an ordinary drag and never moves the selection
+ // — whether the grab should *replace* the selection is app policy.
+ // 2. Members are resolved against this board's own layout, so a nested
+ // board's ids can never leak into another board's group.
+ // 3. A static widget is never a member, matching `moveElement`'s guard.
+ const selected = entry.getSelectedKeys();
+ const memberIds =
+ selected && selected.has(itemId)
+ ? layoutAtStart
+ .filter(
+ (l) =>
+ l.i !== itemId &&
+ selected.has(l.i) &&
+ (!l.static || l.isDraggable === true),
+ )
+ .map((l) => l.i)
+ : [];
+ const itemIds = [itemId, ...memberIds];
+
+ // Host nodes of every member, needed both to exclude nested boards and to
+ // measure the float rects below. Ids are unique per provider and `itemIds`
+ // only holds ids from this board, so the query cannot pick up a nested
+ // board's widgets.
+ const memberNodes: HTMLElement[] = widgetNode ? [widgetNode] : [];
+ const memberRects = new Map();
+
+ if (memberIds.length > 0) {
+ const contentNode = entry.getContentNode();
+ const idSet = new Set(itemIds);
+
+ contentNode
+ ?.querySelectorAll('[data-board-widget-id]')
+ .forEach((el) => {
+ const id = el.dataset.boardWidgetId;
+ if (!id || !idSet.has(id)) return;
+ if (id !== itemId) memberNodes.push(el);
+ const r = el.getBoundingClientRect();
+ memberRects.set(id, {
+ left: r.left,
+ top: r.top,
+ width: r.width,
+ height: r.height,
+ });
+ });
+ }
+
+ // Record boards nested inside the dragged widget(s) so they are never
+ // picked as a drop target (dropping a widget into a board nested within
+ // itself would unmount it). Computed here, before the widget floats into
+ // the overlay, while its nested boards are still in-grid descendants.
const nested = new Set();
- if (widgetNode) {
+ if (memberNodes.length > 0) {
boardsRef.current.forEach((e) => {
const node = e.getContentNode();
- if (node && widgetNode.contains(node)) nested.add(e.id);
+ if (node && memberNodes.some((host) => host.contains(node))) {
+ nested.add(e.id);
+ }
});
}
nestedInDraggedRef.current = nested;
@@ -382,17 +442,26 @@ export function useBoardRegistry(
...item,
constraints: item.constraints ?? store.get(itemId)?.constraints,
};
+ const items = itemIds
+ .map((id) =>
+ id === itemId ? draggedItem : getLayoutItem(layoutAtStart, id),
+ )
+ .filter((it): it is LayoutItem => it !== undefined);
const next: BoardDragState = {
sourceBoardId: boardId,
currentBoardId: boardId,
itemId,
item: draggedItem,
+ itemIds,
+ items,
rect,
+ startRect: rect,
+ memberRects,
pointerType,
nestedBoardIds: nested,
};
setDragState(next);
- entry.setPlaceholder({ ...item });
+ entry.setPlaceholders(items.map((it) => ({ ...it })));
},
);
@@ -473,7 +542,49 @@ export function useBoardRegistry(
return;
}
entry.applyLayout(compacted, false);
- entry.setPlaceholder(getLayoutItem(compacted, item.i) ?? null);
+ entry.setPlaceholders(
+ [getLayoutItem(compacted, item.i)].filter(
+ (it): it is LayoutItem => it !== undefined,
+ ),
+ );
+ },
+ [],
+ );
+
+ /**
+ * Move the whole selection rigidly to an absolute delta.
+ *
+ * Unlike the single-widget path, each frame is recomputed from the drag-start
+ * snapshot rather than from the previous frame. A rigid group at an absolute
+ * delta is a pure function of that delta, so dragging back retraces the
+ * arrangement exactly, pushed neighbours never accumulate, and there is no
+ * hysteresis. The single path's frame-to-frame continuity exists to stop
+ * `moveElement` sinking a no-op placement to the bottom of a column;
+ * `moveElements` places the group explicitly, so that does not apply here.
+ */
+ const moveGroupWithinBoard = useCallback(
+ (entry: BoardEntry, ds: BoardDragState, dx: number, dy: number) => {
+ const pp = entry.getPositionParams();
+ const result = moveElements(
+ sourceSnapshotRef.current,
+ new Set(ds.itemIds),
+ dx,
+ dy,
+ {
+ compactor: entry.getCompactor(),
+ cols: pp.cols,
+ maxRows: entry.getMaxRows(),
+ },
+ );
+
+ if (!result.moved) return;
+
+ entry.applyLayout(result.layout, false);
+ entry.setPlaceholders(
+ ds.itemIds
+ .map((id) => getLayoutItem(result.layout, id))
+ .filter((it): it is LayoutItem => it !== undefined),
+ );
},
[],
);
@@ -588,7 +699,120 @@ export function useBoardRegistry(
if (!advanced || overlaps) continue;
entry.applyLayout(compacted, false);
- entry.setPlaceholder(landed);
+ entry.setPlaceholders([landed]);
+ lastLandingRef.current = { x: landed.x, y: landed.y };
+ return;
+ }
+ },
+ [],
+ );
+
+ /**
+ * Keyboard equivalent of `moveGroupWithinBoard`: scan outward for the nearest
+ * whole-group delta that resolves cleanly.
+ *
+ * Constraints are resolved through the **grabbed** widget only, and the delta
+ * it yields is applied to the rest. `applyPositionConstraints` returns an
+ * absolute position, so running it per member would shear the group apart
+ * under `snapToGrid` or any app constraint — and the grabbed widget is the one
+ * the user is aiming with.
+ *
+ * Unlike the pointer path this steps from the live layout, not the drag-start
+ * snapshot, because keyboard moves accumulate one cell at a time.
+ */
+ const moveGroupWithKeyboard = useCallback(
+ (entry: BoardEntry, ds: BoardDragState, deltaX: number, deltaY: number) => {
+ const pp = entry.getPositionParams();
+ const compactor = entry.getCompactor();
+ const layout = entry.getLayout();
+ const live = getLayoutItem(layout, ds.itemId);
+ if (!live) return;
+
+ const directionX = Math.sign(deltaX);
+ const directionY = Math.sign(deltaY);
+ if (directionX === 0 && directionY === 0) return;
+
+ const maxRows = entry.getMaxRows();
+ const ids = new Set(ds.itemIds);
+ const members = layout.filter((l) => ids.has(l.i));
+ if (members.length === 0) return;
+
+ // Headroom of the whole block, so the scan never proposes a delta that is
+ // clamped back to a no-op.
+ const attempts =
+ directionX < 0
+ ? Math.min(...members.map((l) => l.x))
+ : directionX > 0
+ ? Math.min(...members.map((l) => pp.cols - l.w - l.x))
+ : directionY < 0
+ ? Math.min(...members.map((l) => l.y))
+ : Number.isFinite(maxRows)
+ ? Math.min(...members.map((l) => maxRows - l.h - l.y))
+ : Math.max(1, bottom(layout) - live.y);
+ const seen = new Set();
+ const beforePairs = overlappingPairs(layout);
+
+ for (let distance = 1; distance <= Math.max(0, attempts); distance++) {
+ const candidate = applyPositionConstraints(
+ entry.getConstraints(),
+ ds.item,
+ live.x + directionX * distance,
+ live.y + directionY * distance,
+ {
+ cols: pp.cols,
+ maxRows,
+ containerWidth: pp.containerWidth,
+ containerHeight: entry.getContainerHeight(),
+ rowHeight: pp.rowHeight,
+ margin: pp.margin,
+ layout,
+ },
+ );
+ const candidateKey = `${candidate.x}:${candidate.y}`;
+ if (seen.has(candidateKey)) continue;
+ seen.add(candidateKey);
+
+ if (
+ (directionX !== 0 &&
+ (Math.sign(candidate.x - live.x) !== directionX ||
+ candidate.y !== live.y)) ||
+ (directionY !== 0 &&
+ (Math.sign(candidate.y - live.y) !== directionY ||
+ candidate.x !== live.x))
+ ) {
+ continue;
+ }
+
+ const result = moveElements(
+ layout,
+ ids,
+ candidate.x - live.x,
+ candidate.y - live.y,
+ { compactor, cols: pp.cols, maxRows },
+ );
+ if (!result.moved) continue;
+
+ const landed = getLayoutItem(result.layout, ds.itemId);
+ if (!landed) continue;
+
+ const advanced =
+ directionX !== 0
+ ? Math.sign(landed.x - live.x) === directionX && landed.y === live.y
+ : Math.sign(landed.y - live.y) === directionY &&
+ landed.x === live.x;
+ if (
+ !advanced ||
+ (!compactor.allowOverlap && hasNewOverlap(beforePairs, result.layout))
+ ) {
+ continue;
+ }
+
+ entry.applyLayout(result.layout, false);
+ entry.setPlaceholders(
+ ds.itemIds
+ .map((id) => getLayoutItem(result.layout, id))
+ .filter((it): it is LayoutItem => it !== undefined),
+ );
lastLandingRef.current = { x: landed.x, y: landed.y };
return;
}
@@ -672,7 +896,7 @@ export function useBoardRegistry(
compacted.filter((l) => l.i !== item.i),
false,
);
- target.setPlaceholder({ ...landed });
+ target.setPlaceholders([{ ...landed }]);
},
[],
);
@@ -682,9 +906,16 @@ export function useBoardRegistry(
const ds = dragStateRef.current;
if (!ds) return;
+ const isGroup = ds.itemIds.length > 1;
+
if (pointerType === 'keyboard') {
const source = boardsRef.current.get(ds.sourceBoardId);
- if (source) moveWithKeyboard(source, ds.item, deltaX, deltaY);
+ if (!source) return;
+ if (isGroup) {
+ moveGroupWithKeyboard(source, ds, deltaX, deltaY);
+ } else {
+ moveWithKeyboard(source, ds.item, deltaX, deltaY);
+ }
return;
}
@@ -703,7 +934,12 @@ export function useBoardRegistry(
// it came from. Frozen rects make this deterministic (no preview-induced
// flip-flop).
const anchor = rectCenter(newRect);
- let target = hitTest(anchor) ?? source ?? null;
+ // A group drag never leaves its source board. Cross-board transfer is
+ // single-item throughout (`WidgetTransferInfo`, the carried preview, the
+ // free-slot fallback), and degrading a group to a single-widget transfer
+ // would silently split a selection the user deliberately made. Pinning the
+ // target keeps the whole gesture in-board and makes the limit testable.
+ let target = isGroup ? source : hitTest(anchor) ?? source ?? null;
// Keep the drag on a nested source board while the cursor is still within
// the widget that hosts it, instead of handing off to an ancestor board.
@@ -745,10 +981,17 @@ export function useBoardRegistry(
const prev = boardsRef.current.get(ds.currentBoardId);
const snap = targetSnapshotsRef.current.get(ds.currentBoardId);
if (snap) prev?.applyLayout(cloneLayout(snap), false);
- prev?.setPlaceholder(null);
+ prev?.setPlaceholders([]);
previewRef.current = null;
}
- moveWithinBoard(target, ds.item, x, y);
+ if (isGroup) {
+ // The group moves by the delta the grabbed widget travelled from its
+ // drag-start position — an absolute delta, recomputed from the
+ // snapshot each frame.
+ moveGroupWithinBoard(target, ds, x - ds.item.x, y - ds.item.y);
+ } else {
+ moveWithinBoard(target, ds.item, x, y);
+ }
setDragState({
...ds,
currentBoardId: ds.sourceBoardId,
@@ -771,7 +1014,7 @@ export function useBoardRegistry(
const snap = targetSnapshotsRef.current.get(ds.currentBoardId);
if (snap) prev?.applyLayout(cloneLayout(snap), false);
}
- prev?.setPlaceholder(null);
+ prev?.setPlaceholders([]);
// Drop the carried working layout so the newly entered target seeds a
// fresh preview from its own clean snapshot.
previewRef.current = null;
@@ -909,7 +1152,7 @@ export function useBoardRegistry(
const ids = new Set(affectedRef.current);
ids.add(ds.sourceBoardId);
ids.add(ds.currentBoardId);
- ids.forEach((id) => boardsRef.current.get(id)?.setPlaceholder(null));
+ ids.forEach((id) => boardsRef.current.get(id)?.setPlaceholders([]));
affectedRef.current.clear();
sourceSnapshotRef.current = [];
@@ -930,7 +1173,16 @@ export function useBoardRegistry(
onDragMove,
onDragEnd,
dragState,
+ getDragState,
}),
- [store, registerBoard, onDragStart, onDragMove, onDragEnd, dragState],
+ [
+ store,
+ registerBoard,
+ onDragStart,
+ onDragMove,
+ onDragEnd,
+ dragState,
+ getDragState,
+ ],
);
}
diff --git a/src/components/layout/Board/use-board-select-modifier-key.ts b/src/components/layout/Board/use-board-select-modifier-key.ts
new file mode 100644
index 000000000..3be7ff9f0
--- /dev/null
+++ b/src/components/layout/Board/use-board-select-modifier-key.ts
@@ -0,0 +1,21 @@
+import { useIsDarwin } from '../../../utils/react/useIsDarwin';
+
+/** Pointer-event property carrying this platform's additive-selection modifier. */
+export type BoardSelectModifierKey = 'metaKey' | 'ctrlKey';
+
+/**
+ * Which modifier adds to (or removes from) the selection on this platform.
+ *
+ * Shift works everywhere and is the canvas convention; this is the
+ * second, list-style modifier that toggles a single item. It is Cmd
+ * on Apple platforms and Ctrl elsewhere — deliberately not
+ * Ctrl on macOS, where Ctrl-clicking opens the context menu.
+ *
+ * Read off the event rather than tracked as held state: a pointer event always
+ * carries its own modifier flags, so selection can never be swallowed because a
+ * `keydown` was missed (the key went down while another window had focus, or the
+ * page loaded with it already held).
+ */
+export function useBoardSelectModifierKey(): BoardSelectModifierKey {
+ return useIsDarwin() ? 'metaKey' : 'ctrlKey';
+}
diff --git a/src/components/layout/Board/use-board-selection.ts b/src/components/layout/Board/use-board-selection.ts
new file mode 100644
index 000000000..1403d611a
--- /dev/null
+++ b/src/components/layout/Board/use-board-selection.ts
@@ -0,0 +1,254 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import { useEvent } from '../../../_internal/hooks';
+import { useI18n } from '../../../i18n';
+
+import type { MutableRefObject } from 'react';
+import type { LayoutItem } from './grid-core';
+
+export type BoardSelectionMode = 'none' | 'single' | 'multiple';
+
+export interface UseBoardSelectionOptions {
+ selectionMode: BoardSelectionMode;
+ /** Controlled selection. */
+ selectedKeys?: string[];
+ /** Initial selection for uncontrolled usage. */
+ defaultSelectedKeys?: string[];
+ onSelectionChange?: (keys: string[]) => void;
+ /** Live layout — supplies both the key order and the set of valid keys. */
+ layout: LayoutItem[];
+ /** Accessible name of a widget, for the single-selection announcement. */
+ getLabel: (key: string) => string;
+}
+
+export interface UseBoardSelectionResult {
+ /** The effective selection: provided keys ∩ live layout, in layout order. */
+ selectedKeySet: ReadonlySet;
+ /**
+ * Synchronously-updated mirror of `selectedKeySet`. Handlers that run before
+ * React re-renders — notably `useMove`'s `onMoveStart`, which has to decide
+ * single-drag vs. group-drag on the spot — must read this rather than the
+ * state, which would be one render stale.
+ */
+ selectedKeysRef: MutableRefObject>;
+ /** Replace the selection wholesale (marquee, programmatic). */
+ setSelection: (keys: Iterable) => void;
+ /**
+ * Apply a single-key gesture.
+ *
+ * `additive` (a modifier-held click, or any keyboard toggle) flips the key's
+ * membership. A plain gesture replaces the selection with just this key — it
+ * never clears, because a widget is a large surface the user also clicks to
+ * work with, and having the second click silently deselect reads as a bug.
+ * Deselecting is Escape, or an additive gesture.
+ */
+ select: (key: string, additive: boolean) => void;
+ clearSelection: () => void;
+ /** Live-region text. Only changes when the selection commits. */
+ announcement: string;
+}
+
+const EMPTY_KEYS: readonly string[] = [];
+
+/** Zero-width space. See `announce` below. */
+const ANNOUNCEMENT_NUDGE = '\u200B';
+
+/**
+ * Headless selection state for a single board.
+ *
+ * Deliberately shaped like `useBoardLayout`: controlled (`selectedKeys`) or
+ * uncontrolled (`defaultSelectedKeys`), with a synchronous ref mirror for the
+ * drag engine and a single commit path so every entry point — click, keyboard,
+ * marquee, pruning — produces exactly one `onSelectionChange` and one
+ * announcement.
+ */
+export function useBoardSelection(
+ options: UseBoardSelectionOptions,
+): UseBoardSelectionResult {
+ const {
+ selectionMode,
+ selectedKeys: controlledKeys,
+ defaultSelectedKeys,
+ onSelectionChange,
+ layout,
+ getLabel,
+ } = options;
+
+ const { t } = useI18n();
+ const isControlled = controlledKeys !== undefined;
+ const isEnabled = selectionMode !== 'none';
+
+ const [uncontrolledKeys, setUncontrolledKeys] = useState(
+ () => defaultSelectedKeys ?? EMPTY_KEYS,
+ );
+
+ // Layout order, not click order: a marquee has no meaningful click order, and
+ // a stable order makes group operations reproducible for consumers.
+ const orderedIds = useMemo(() => layout.map((it) => it.i), [layout]);
+
+ const selectedKeys = useMemo(() => {
+ if (!isEnabled) {
+ return EMPTY_KEYS as string[];
+ }
+
+ const requested = new Set(controlledKeys ?? uncontrolledKeys);
+
+ // Intersecting with the live layout on every render is what makes a stale
+ // key harmless: it simply highlights nothing. No effect, no flash, and no
+ // need for the consumer to clean up after removing a widget.
+ return orderedIds.filter((id) => requested.has(id));
+ }, [isEnabled, controlledKeys, uncontrolledKeys, orderedIds]);
+
+ const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]);
+
+ const selectedKeysRef = useRef>(selectedKeySet);
+ selectedKeysRef.current = selectedKeySet;
+
+ const onSelectionChangeEvent = useEvent((next: string[]) =>
+ onSelectionChange?.(next),
+ );
+
+ // ---- Announcements --------------------------------------------------------
+
+ const [announcement, setAnnouncement] = useState('');
+ // Screen readers skip a live-region update whose text is identical to the
+ // previous one, which would silently drop the very common select → deselect →
+ // reselect sequence. Alternating an invisible suffix keeps every update
+ // distinct without changing what is spoken.
+ const announcementParityRef = useRef(false);
+
+ const announce = useEvent((keys: string[], hadSelection: boolean) => {
+ let message: string;
+
+ if (keys.length === 0) {
+ if (!hadSelection) {
+ return;
+ }
+ message = t('board.selectionCleared', 'Selection cleared');
+ } else if (keys.length === 1) {
+ message = t('board.widgetSelected', '{{name}} selected', {
+ name: getLabel(keys[0]!),
+ });
+ } else {
+ message = t('board.widgetsSelected', '{{count}} widgets selected', {
+ count: keys.length,
+ });
+ }
+
+ announcementParityRef.current = !announcementParityRef.current;
+ setAnnouncement(
+ announcementParityRef.current
+ ? message
+ : `${message}${ANNOUNCEMENT_NUDGE}`,
+ );
+ });
+
+ // ---- Commit ---------------------------------------------------------------
+
+ const commit = useEvent((nextKeys: Iterable) => {
+ const requested = new Set(nextKeys);
+ const next = orderedIds.filter((id) => requested.has(id));
+ const current = selectedKeysRef.current;
+
+ if (next.length === current.size && next.every((id) => current.has(id))) {
+ return;
+ }
+
+ // Keep the ref in step *before* the state lands, so a gesture that reads it
+ // later in the same tick sees the new selection.
+ selectedKeysRef.current = new Set(next);
+
+ if (!isControlled) {
+ setUncontrolledKeys(next);
+ }
+
+ announce(next, current.size > 0);
+ onSelectionChangeEvent(next);
+ });
+
+ // ---- Pruning --------------------------------------------------------------
+
+ // Uncontrolled state is the only copy of the selection, so a removed widget
+ // has to be dropped from it and the change reported. In controlled mode the
+ // consumer removed the widget and owns its own state — emitting here would
+ // fight the controlled contract and can loop.
+ useEffect(() => {
+ if (isControlled || !isEnabled || uncontrolledKeys.length === 0) {
+ return;
+ }
+
+ const live = new Set(orderedIds);
+ const pruned = uncontrolledKeys.filter((key) => live.has(key));
+
+ if (pruned.length !== uncontrolledKeys.length) {
+ selectedKeysRef.current = new Set(pruned);
+ setUncontrolledKeys(pruned);
+ onSelectionChangeEvent(pruned);
+ }
+ }, [
+ isControlled,
+ isEnabled,
+ uncontrolledKeys,
+ orderedIds,
+ onSelectionChangeEvent,
+ ]);
+
+ // ---- Gestures -------------------------------------------------------------
+
+ const setSelection = useEvent((keys: Iterable) => {
+ if (!isEnabled) {
+ return;
+ }
+ commit(selectionMode === 'single' ? firstOf(keys) : keys);
+ });
+
+ const select = useEvent((key: string, additive: boolean) => {
+ if (!isEnabled) {
+ return;
+ }
+
+ if (selectionMode === 'single') {
+ // A modifier can still toggle the one selected widget off; a plain
+ // gesture always lands on the widget the user aimed at.
+ commit(additive && selectedKeysRef.current.has(key) ? [] : [key]);
+
+ return;
+ }
+
+ if (!additive) {
+ commit([key]);
+
+ return;
+ }
+
+ const next = new Set(selectedKeysRef.current);
+ if (!next.delete(key)) {
+ next.add(key);
+ }
+ commit(next);
+ });
+
+ const clearSelection = useEvent(() => {
+ if (!isEnabled) {
+ return;
+ }
+ commit([]);
+ });
+
+ return {
+ selectedKeySet,
+ selectedKeysRef,
+ setSelection,
+ select,
+ clearSelection,
+ announcement,
+ };
+}
+
+function firstOf(keys: Iterable): string[] {
+ for (const key of keys) {
+ return [key];
+ }
+
+ return [];
+}
diff --git a/src/eslint-plugin/defaults.generated.ts b/src/eslint-plugin/defaults.generated.ts
index a150db39b..c091b5cf6 100644
--- a/src/eslint-plugin/defaults.generated.ts
+++ b/src/eslint-plugin/defaults.generated.ts
@@ -74,6 +74,8 @@ export const DEFAULTS: DefaultsRegistry = {
maxRows: { kind: 'default', value: 'Infinity' },
preventCollision: { kind: 'default', value: false },
rowHeight: { kind: 'default', value: 100 },
+ selectionCancel: { kind: 'default', value: 'BOARD_SELECTION_CANCEL' },
+ selectionMode: { kind: 'default', value: 'none' },
showGridLines: { kind: 'default', value: false },
},
},
diff --git a/src/i18n/locales/de-DE/uikit.json b/src/i18n/locales/de-DE/uikit.json
index 46f4a1b37..493a9c06b 100644
--- a/src/i18n/locales/de-DE/uikit.json
+++ b/src/i18n/locales/de-DE/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Kontextmenü öffnen",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Verschiebbares Widget",
+ "selected": "Ausgewählt",
+ "widgetSelected": "{{name}} ausgewählt",
+ "widgetsSelected": "{{count}} Widgets ausgewählt",
+ "selectionCleared": "Auswahl aufgehoben"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/en-US/uikit.json b/src/i18n/locales/en-US/uikit.json
index d84b7bae1..dac6a1b05 100644
--- a/src/i18n/locales/en-US/uikit.json
+++ b/src/i18n/locales/en-US/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Open context menu",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Draggable widget",
+ "selected": "Selected",
+ "widgetSelected": "{{name}} selected",
+ "widgetsSelected": "{{count}} widgets selected",
+ "selectionCleared": "Selection cleared"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/es-ES/uikit.json b/src/i18n/locales/es-ES/uikit.json
index caefe67d0..25841479c 100644
--- a/src/i18n/locales/es-ES/uikit.json
+++ b/src/i18n/locales/es-ES/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Abrir el menú contextual",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget arrastrable",
+ "selected": "Seleccionado",
+ "widgetSelected": "{{name}} seleccionado",
+ "widgetsSelected": "{{count}} widgets seleccionados",
+ "selectionCleared": "Selección borrada"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/es-MX/uikit.json b/src/i18n/locales/es-MX/uikit.json
index 1ca2a8cd1..36185ba69 100644
--- a/src/i18n/locales/es-MX/uikit.json
+++ b/src/i18n/locales/es-MX/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Abrir el menú contextual",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget arrastrable",
+ "selected": "Seleccionado",
+ "widgetSelected": "{{name}} seleccionado",
+ "widgetsSelected": "{{count}} widgets seleccionados",
+ "selectionCleared": "Selección borrada"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/fr-FR/uikit.json b/src/i18n/locales/fr-FR/uikit.json
index 15337add8..3d13e35a3 100644
--- a/src/i18n/locales/fr-FR/uikit.json
+++ b/src/i18n/locales/fr-FR/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Ouvrir le menu contextuel",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget déplaçable",
+ "selected": "Sélectionné",
+ "widgetSelected": "{{name}} sélectionné",
+ "widgetsSelected": "{{count}} widgets sélectionnés",
+ "selectionCleared": "Sélection effacée"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/it-IT/uikit.json b/src/i18n/locales/it-IT/uikit.json
index 0e6cbca6f..31ef63eef 100644
--- a/src/i18n/locales/it-IT/uikit.json
+++ b/src/i18n/locales/it-IT/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Apri il menu contestuale",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget trascinabile",
+ "selected": "Selezionato",
+ "widgetSelected": "{{name}} selezionato",
+ "widgetsSelected": "{{count}} widget selezionati",
+ "selectionCleared": "Selezione annullata"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/ja-JP/uikit.json b/src/i18n/locales/ja-JP/uikit.json
index f65a3f4f1..4852bc30d 100644
--- a/src/i18n/locales/ja-JP/uikit.json
+++ b/src/i18n/locales/ja-JP/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "コンテキストメニューを開く",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "ウィジェット",
+ "draggableWidget": "ドラッグ可能なウィジェット",
+ "selected": "選択中",
+ "widgetSelected": "{{name}} を選択しました",
+ "widgetsSelected": "{{count}} 個のウィジェットを選択しました",
+ "selectionCleared": "選択を解除しました"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/nb-NO/uikit.json b/src/i18n/locales/nb-NO/uikit.json
index 024cfee63..da969c818 100644
--- a/src/i18n/locales/nb-NO/uikit.json
+++ b/src/i18n/locales/nb-NO/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Åpne kontekstmeny",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Flyttbar widget",
+ "selected": "Valgt",
+ "widgetSelected": "{{name}} valgt",
+ "widgetsSelected": "{{count}} widgeter valgt",
+ "selectionCleared": "Valget er fjernet"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/pt-BR/uikit.json b/src/i18n/locales/pt-BR/uikit.json
index c01929ce7..938728e4a 100644
--- a/src/i18n/locales/pt-BR/uikit.json
+++ b/src/i18n/locales/pt-BR/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Abrir o menu de contexto",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget arrastável",
+ "selected": "Selecionado",
+ "widgetSelected": "{{name}} selecionado",
+ "widgetsSelected": "{{count}} widgets selecionados",
+ "selectionCleared": "Seleção limpa"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/pt-PT/uikit.json b/src/i18n/locales/pt-PT/uikit.json
index 23b889a7c..16c7984b5 100644
--- a/src/i18n/locales/pt-PT/uikit.json
+++ b/src/i18n/locales/pt-PT/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Abrir o menu de contexto",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget arrastável",
+ "selected": "Selecionado",
+ "widgetSelected": "{{name}} selecionado",
+ "widgetsSelected": "{{count}} widgets selecionados",
+ "selectionCleared": "Seleção limpa"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/sv-SE/uikit.json b/src/i18n/locales/sv-SE/uikit.json
index 6232039c0..20661d73e 100644
--- a/src/i18n/locales/sv-SE/uikit.json
+++ b/src/i18n/locales/sv-SE/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Öppna snabbmeny",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Flyttbar widget",
+ "selected": "Markerad",
+ "widgetSelected": "{{name}} markerad",
+ "widgetsSelected": "{{count}} widgetar markerade",
+ "selectionCleared": "Markeringen rensad"
}
-}
\ No newline at end of file
+}
diff --git a/src/i18n/locales/vi-VN/uikit.json b/src/i18n/locales/vi-VN/uikit.json
index 37a3ffda7..575f48258 100644
--- a/src/i18n/locales/vi-VN/uikit.json
+++ b/src/i18n/locales/vi-VN/uikit.json
@@ -124,5 +124,13 @@
"contextMenu": {
"openContextMenu": "Mở menu ngữ cảnh",
"contextMenu": "context-menu"
+ },
+ "board": {
+ "widget": "Widget",
+ "draggableWidget": "Widget có thể kéo",
+ "selected": "Đã chọn",
+ "widgetSelected": "Đã chọn {{name}}",
+ "widgetsSelected": "Đã chọn {{count}} widget",
+ "selectionCleared": "Đã bỏ chọn"
}
-}
\ No newline at end of file
+}
diff --git a/src/index.ts b/src/index.ts
index 7fe74986c..5fcdbe092 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,9 +70,11 @@ export {
snapToGrid,
minSize,
maxSize,
+ BOARD_SELECTION_CANCEL,
} from './components/layout/Board';
export type {
CubeBoardProps,
+ BoardSelectionMode,
CubeBoardWidgetProps,
CubeBoardProviderProps,
CubeBoardResponsiveProps,