From 59fd40d9f5ea8ec29d9602c7f5fa53177c70f608 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 7 Aug 2026 16:16:03 +0200 Subject: [PATCH 1/4] feat(Board): widget selection and rigid group movement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing a widget selects it and arms a drag of the selection — selecting and grabbing are one gesture, so move and it drags, stay still and it was only a selection. Shift (or Cmd/Ctrl) toggles membership, dragging from empty canvas lassos, Space toggles the focused widget, Escape clears. With selectionMode="multiple", dragging any selected widget moves the whole selection as a rigid block: every widget travels by the same delta, the group clamps against the grid edge as a unit instead of collapsing into it, a frame that cannot be placed is rejected outright rather than partially applied, and the move commits through a single onLayoutChange. The group reflows by the board's own compaction rules, so it can no more be parked in empty space on a vertical board than a lone widget can. Selection behaves like focus: pressing another widget makes that the selection, and pressing a control inside a widget or moving focus off the board drops it. Also fixes three pre-existing Board bugs in the blast radius: - aria-roledescription sat on a role-less div, which is invalid, and its value was hardcoded English. Hosts are now role="group" with a localized roledescription and a name from the new Board.Widget aria-label prop. - useMove's pointer-down calls preventDefault(), so without a dragCancel an input inside a widget could not be focused or typed into. On a selectable board selectionCancel now gates dragging too. - A widget only clips its content when it is a card. A borderless widget had overflow: hidden, which cropped any outline a descendant drew for its own focus or active state. Co-Authored-By: Claude Opus 5 --- .changeset/board-selection-and-group-move.md | 62 ++ src/components/layout/Board/Board.docs.mdx | 171 +++- src/components/layout/Board/Board.stories.tsx | 199 +++- src/components/layout/Board/Board.test.tsx | 915 ++++++++++++++++++ src/components/layout/Board/Board.tsx | 464 ++++++++- src/components/layout/Board/Widget.tsx | 22 + src/components/layout/Board/WidgetHost.tsx | 219 ++++- src/components/layout/Board/board-context.tsx | 32 +- src/components/layout/Board/board-store.ts | 11 +- .../layout/Board/grid-core/group-move.test.ts | 400 ++++++++ .../layout/Board/grid-core/group-move.ts | 214 ++++ .../layout/Board/grid-core/index.ts | 3 + src/components/layout/Board/index.tsx | 2 + .../layout/Board/use-board-layout.ts | 37 +- .../layout/Board/use-board-registry.ts | 288 +++++- .../Board/use-board-select-modifier-key.ts | 21 + .../layout/Board/use-board-selection.ts | 254 +++++ src/eslint-plugin/defaults.generated.ts | 2 + src/i18n/locales/de-DE/uikit.json | 10 +- src/i18n/locales/en-US/uikit.json | 10 +- src/i18n/locales/es-ES/uikit.json | 10 +- src/i18n/locales/es-MX/uikit.json | 10 +- src/i18n/locales/fr-FR/uikit.json | 10 +- src/i18n/locales/it-IT/uikit.json | 10 +- src/i18n/locales/ja-JP/uikit.json | 10 +- src/i18n/locales/nb-NO/uikit.json | 10 +- src/i18n/locales/pt-BR/uikit.json | 10 +- src/i18n/locales/pt-PT/uikit.json | 10 +- src/i18n/locales/sv-SE/uikit.json | 10 +- src/i18n/locales/vi-VN/uikit.json | 10 +- src/index.ts | 2 + 31 files changed, 3335 insertions(+), 103 deletions(-) create mode 100644 .changeset/board-selection-and-group-move.md create mode 100644 src/components/layout/Board/grid-core/group-move.test.ts create mode 100644 src/components/layout/Board/grid-core/group-move.ts create mode 100644 src/components/layout/Board/use-board-select-modifier-key.ts create mode 100644 src/components/layout/Board/use-board-selection.ts diff --git a/.changeset/board-selection-and-group-move.md b/.changeset/board-selection-and-group-move.md new file mode 100644 index 000000000..75bb71476 --- /dev/null +++ b/.changeset/board-selection-and-group-move.md @@ -0,0 +1,62 @@ +--- +'@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`. + +**Behavior change:** `Board.Widget` no longer clips its content unless it is a +card. A borderless widget (`isCard={false}`, the default) previously had +`overflow: hidden`, which cropped any `outline` a descendant drew for its focused +or active state. Card widgets still clip to their border. If you relied on the old +clipping, set `widgetProps={{ overflow: 'hidden' }}` on the board or +`overflow="hidden"` on the widget. diff --git a/src/components/layout/Board/Board.docs.mdx b/src/components/layout/Board/Board.docs.mdx index d24193c23..499edb3c3 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,15 @@ 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. + +Note that a widget clips its content only when it is a card. `isCard` means "this +widget draws a visible edge", and a borderless widget has no edge to clip to — +clipping one would crop any `outline` a descendant paints for its own focus or +active state. Set `widgetProps={{ overflow: 'hidden' }}` (or `overflow="hidden"` +per widget) to clip regardless. ### Board.Provider @@ -127,6 +153,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 +325,88 @@ 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 + + + +### Card and borderless overflow + + + ## 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 +418,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..5cf29b9b5 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,194 @@ 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' }; + +const BorderlessOverflowTemplate: StoryFn = (args) => ( + + + + + Card — clips + + + + + + + + Borderless — does not clip + + + + + +); + +export const BorderlessOverflow = BorderlessOverflowTemplate.bind({}); +BorderlessOverflow.parameters = { + docs: { + description: { + story: + 'A widget only clips its content when it is a card, i.e. when it actually draws an edge. A borderless widget lets a descendant paint outside the box, which is what an `outline` (a focus ring, an active state) needs. Pass `widgetProps={{ overflow: "hidden" }}` to clip everything.', + }, + }, +}; + 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..54f1b354a 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,918 @@ 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'); + }); + + 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('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('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..8d6f1e3e3 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'; @@ -80,15 +84,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 +104,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 +168,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 +287,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 +339,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 +402,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 +437,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 +476,11 @@ function BoardInner( const { layout, layoutRef, + placeholders, placeholder, + placeholdersRef, placeholderRef, - setPlaceholder, + setPlaceholders, applyLayout, } = useBoardLayout({ layout: controlledLayout, @@ -356,6 +488,32 @@ 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; + }); + + 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 +534,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 +646,7 @@ function BoardInner( }; const applyLayoutEvent = useEvent(applyLayout); - const setPlaceholderEvent = useEvent(setPlaceholder); + const setPlaceholdersEvent = useEvent(setPlaceholders); const entryRef = useRef(null); if (!entryRef.current) { @@ -501,8 +660,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 +796,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 +815,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 +905,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 +947,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 +957,201 @@ 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) { + 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 +1190,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 +1277,17 @@ function BoardInner( } > - + {ready && gridOverlayStyle ? ( + {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..ce16c01f3 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,7 +103,16 @@ const WidgetElement = tasty({ drag: 'grabbing', }, touchAction: 'none', - overflow: 'hidden', + // Clip to the boundary only when there is one to clip to. `isCard` is + // precisely "this widget draws a visible edge"; a borderless, transparent + // widget has no edge to respect, and clipping to it cropped any `outline` a + // descendant drew for its own focus/active state (an element's outline is + // clipped by an *ancestor's* overflow, never its own). Pass + // `widgetProps={{ overflow: 'hidden' }}` to restore the old behavior. + overflow: { + '': 'visible', + card: 'hidden', + }, }, }); @@ -386,6 +406,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 +448,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 +570,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 +683,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 +746,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 +784,7 @@ export function WidgetHost(props: WidgetHostProps) { card: isCard, hovered: isHovered, 'focus-visible': isFocusVisible, + selected: isSelected, }; const content = ( @@ -680,7 +841,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 +897,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 +928,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, From 1500f276cca3a9014e974a5cb77504aca4c28585 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 7 Aug 2026 16:21:53 +0200 Subject: [PATCH 2/4] docs(agents): state where i18n applies, and where it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md had no i18n section at all, despite `src/i18n/README.md` holding the rules and `locale-parity.test.ts` enforcing them in CI. Record the scope explicitly: strings a component renders — including `aria-label`, `aria-roledescription` and live-region announcements — are localized; stories, docs and tests use plain literals. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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. From 4ba2872594ab95abfeb343c562c2a250e42db069 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 7 Aug 2026 16:34:38 +0200 Subject: [PATCH 3/4] fix(Board): honour isSelectable in the marquee, and raise the size budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lasso added every intersecting layout id to the selection without checking `isSelectable`, so it could pick up a widget that a press or Space would refuse. All three paths now resolve it through one helper. Also raises the "All" size budget to 466 kB. The feature costs ~4 kB gzipped — ~3.5 kB of engine plus ~0.5 kB for the six `board.*` strings across twelve eagerly-registered locales, measured by building with and without them. The Button budget is unchanged, so none of it reaches a consumer who does not import Board. Co-Authored-By: Claude Opus 5 --- .size-limit.cjs | 17 ++++++---- src/components/layout/Board/Board.test.tsx | 36 ++++++++++++++++++++++ src/components/layout/Board/Board.tsx | 12 ++++++-- 3 files changed, 56 insertions(+), 9 deletions(-) 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/src/components/layout/Board/Board.test.tsx b/src/components/layout/Board/Board.test.tsx index 54f1b354a..7e0a3236c 100644 --- a/src/components/layout/Board/Board.test.tsx +++ b/src/components/layout/Board/Board.test.tsx @@ -2594,6 +2594,42 @@ describe('Board', () => { 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 }); diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index 8d6f1e3e3..fc9de1373 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -496,6 +496,13 @@ function BoardInner( 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, @@ -1043,6 +1050,7 @@ function BoardInner( const next = new Set(base); for (const it of layoutRef.current) { + if (!isWidgetSelectable(it.i)) continue; const pos = calcGridItemPosition( liveRef.current.positionParams, it.x, @@ -1349,9 +1357,7 @@ function BoardInner( false; const widgetQa = registration?.qa ?? widgetProps?.qa; const widgetSelectable = - selectionMode !== 'none' && - (registration?.isSelectable ?? - widgetProps?.isSelectable) !== false; + selectionMode !== 'none' && isWidgetSelectable(item.i); const widgetSelectionCancel = registration?.selectionCancel ?? widgetProps?.selectionCancel ?? From 69b4cbec2673acc50c06e10f5b74092e7178452b Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 7 Aug 2026 17:03:26 +0200 Subject: [PATCH 4/4] fix(Board): keep widgets clipped, and drop the board's default focus ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the non-card `overflow: visible` default. A widget owns its grid cell and must not paint outside it: an auto-height container deliberately cannot grow while a drag is in flight, so a nested board reflowing to more rows spilled over its neighbours instead of being clipped. Clipping now holds regardless of `isCard` — a borderless widget has no drawn edge, but it still has a cell. The narrow problem that flip was meant to solve — a descendant's `outline` cropped at the widget edge — keeps a narrow fix: opt that widget into `overflow="visible"`, or draw the ring inset with a negative `outlineOffset`. Both are documented on `Board.Widget`. The board root also drew the browser's default focus ring. It takes focus programmatically (never by Tab — it is `tabIndex=-1`) as a parking spot, so that Escape and Delete still have somewhere to land after a delete unmounts the focused widget or a marquee leaves focus elsewhere. A ring on a parking spot announces a state the user cannot act on, so it is suppressed the same way `Dialog` suppresses it on its own focusable container. Re-adds the regression test for interactive descendants keeping native focus without a `dragCancel`, which was lost when the selection suite was rewritten. Co-Authored-By: Claude Opus 5 --- .changeset/board-selection-and-group-move.md | 7 --- src/components/layout/Board/Board.docs.mdx | 15 +++--- src/components/layout/Board/Board.stories.tsx | 40 -------------- src/components/layout/Board/Board.test.tsx | 52 +++++++++++++++++++ src/components/layout/Board/Board.tsx | 8 +++ src/components/layout/Board/WidgetHost.tsx | 23 ++++---- 6 files changed, 79 insertions(+), 66 deletions(-) diff --git a/.changeset/board-selection-and-group-move.md b/.changeset/board-selection-and-group-move.md index 75bb71476..816abd9e2 100644 --- a/.changeset/board-selection-and-group-move.md +++ b/.changeset/board-selection-and-group-move.md @@ -53,10 +53,3 @@ described as "Selected", and selection changes are announced through a polite li region. Widget hosts also expose `data-board-widget-id` and `data-selected`. - -**Behavior change:** `Board.Widget` no longer clips its content unless it is a -card. A borderless widget (`isCard={false}`, the default) previously had -`overflow: hidden`, which cropped any `outline` a descendant drew for its focused -or active state. Card widgets still clip to their border. If you relied on the old -clipping, set `widgetProps={{ overflow: 'hidden' }}` on the board or -`overflow="hidden"` on the widget. diff --git a/src/components/layout/Board/Board.docs.mdx b/src/components/layout/Board/Board.docs.mdx index 499edb3c3..39bc61df8 100644 --- a/src/components/layout/Board/Board.docs.mdx +++ b/src/components/layout/Board/Board.docs.mdx @@ -111,11 +111,12 @@ clipped by an ancestor's `overflow: hidden`. - **`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. -Note that a widget clips its content only when it is a card. `isCard` means "this -widget draws a visible edge", and a borderless widget has no edge to clip to — -clipping one would crop any `outline` a descendant paints for its own focus or -active state. Set `widgetProps={{ overflow: 'hidden' }}` (or `overflow="hidden"` -per widget) to clip regardless. +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 @@ -355,10 +356,6 @@ it was dropped. -### Card and borderless overflow - - - ## Accessibility ### Keyboard Navigation diff --git a/src/components/layout/Board/Board.stories.tsx b/src/components/layout/Board/Board.stories.tsx index 5cf29b9b5..357c94f2e 100644 --- a/src/components/layout/Board/Board.stories.tsx +++ b/src/components/layout/Board/Board.stories.tsx @@ -252,46 +252,6 @@ const RestyledSelectionTemplate: StoryFn = (args) => ( export const RestyledSelection = RestyledSelectionTemplate.bind({}); RestyledSelection.args = { selectionMode: 'multiple' }; -const BorderlessOverflowTemplate: StoryFn = (args) => ( - - - - - Card — clips - - - - - - - - Borderless — does not clip - - - - - -); - -export const BorderlessOverflow = BorderlessOverflowTemplate.bind({}); -BorderlessOverflow.parameters = { - docs: { - description: { - story: - 'A widget only clips its content when it is a card, i.e. when it actually draws an edge. A borderless widget lets a descendant paint outside the box, which is what an `outline` (a focus ring, an active state) needs. Pass `widgetProps={{ overflow: "hidden" }}` to clip everything.', - }, - }, -}; - 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 7e0a3236c..ce510cbc6 100644 --- a/src/components/layout/Board/Board.test.tsx +++ b/src/components/layout/Board/Board.test.tsx @@ -2182,6 +2182,42 @@ describe('Board', () => { 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(); @@ -2227,6 +2263,22 @@ describe('Board', () => { 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(); diff --git a/src/components/layout/Board/Board.tsx b/src/components/layout/Board/Board.tsx index fc9de1373..6e91bf6d9 100644 --- a/src/components/layout/Board/Board.tsx +++ b/src/components/layout/Board/Board.tsx @@ -74,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, }, }); diff --git a/src/components/layout/Board/WidgetHost.tsx b/src/components/layout/Board/WidgetHost.tsx index ce16c01f3..5f330118f 100644 --- a/src/components/layout/Board/WidgetHost.tsx +++ b/src/components/layout/Board/WidgetHost.tsx @@ -103,16 +103,19 @@ const WidgetElement = tasty({ drag: 'grabbing', }, touchAction: 'none', - // Clip to the boundary only when there is one to clip to. `isCard` is - // precisely "this widget draws a visible edge"; a borderless, transparent - // widget has no edge to respect, and clipping to it cropped any `outline` a - // descendant drew for its own focus/active state (an element's outline is - // clipped by an *ancestor's* overflow, never its own). Pass - // `widgetProps={{ overflow: 'hidden' }}` to restore the old behavior. - overflow: { - '': 'visible', - card: 'hidden', - }, + // 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', }, });