Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-board-cross-swaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": patch
---

Make Board's `collisionMode="swap"` source-aware: cross-board drops now insert only at an empty anchor, downscale without moving destination widgets, and cancel invalid transfers.
11 changes: 6 additions & 5 deletions src/components/layout/Board/Board.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ clipped by an ancestor's `overflow: hidden`.
- **`compact`** `'vertical' | 'horizontal' | 'free' | null` (default: `'vertical'`) — Compaction behavior. `'vertical'`/`'horizontal'` reflow widgets to remove gaps; `'free'` places each widget exactly where dropped and never pushes its neighbours (blocked from occupied cells unless `allowOverlap`); `null` disables compaction but still resolves collisions the legacy react-grid-layout way.
- **`allowOverlap`** `boolean` (default: `false`) — Allow widgets to overlap (stack). In `'free'` mode this is what distinguishes stacking from blocking a drop onto an occupied cell.
- **`preventCollision`** `boolean` (default: `false`) — Block movement into occupied cells instead of pushing.
- **`collisionMode`** `'revert' | 'downscale' | 'swap'` (default: `'revert'`) — How to resolve a drop the grid would otherwise refuse. Only applies where a collision *blocks* a move — `compact="free"` (which prevents collisions) or an explicit `preventCollision` — and never under `allowOverlap`, where nothing is blocked. `'revert'` snaps the widget back. `'downscale'` shrinks it into the free space at the drop cell, growing rightward and downward, floored at its `minW`/`minH` (below that it reverts). `'swap'` trades places with **one** widget — the one the drop covers most — which takes the cell the drag began at, each keeping as much of its own size as fits there (so a widget displaced next to a neighbour may shrink). It never displaces more than that one widget. A drop straddling two widgets trades with one of them rather than refusing, so the swap never blinks away as you sweep across a boundary; ties go to the widget earlier in reading order. Dragging back retraces the original arrangement exactly. It falls back to `'downscale'`, then `'revert'`. No mode ever *grows* a widget: a drag only ever preserves or reduces size. Applies to single-widget drags. Arrow keys honour it too but never resize anything — each press is a gesture of its own, so a press that shrank a widget would have nothing to restore from, and only an exchange that fits outright is allowed. A multi-widget selection still only moves where it fits outright, and a resize is still blocked by a collision.
- **`collisionMode`** `'revert' | 'downscale' | 'swap'` (default: `'revert'`) — How to resolve a drop the grid would otherwise refuse. Only applies where a collision *blocks* a move — `compact="free"` (which prevents collisions) or an explicit `preventCollision` — and never under `allowOverlap`, where nothing is blocked. `'revert'` snaps the widget back. `'downscale'` shrinks it into the free space at the drop cell, growing rightward and downward, floored at its `minW`/`minH` (below that it reverts). Within one board, `'swap'` trades places with **one** widget — the one the drop covers most — which takes the cell the drag began at, each keeping as much of its own size as fits there. It never displaces more than that one widget. A drop straddling two widgets trades with one of them rather than refusing, so the swap never blinks away as you sweep across a boundary; ties go to the widget earlier in reading order. Dragging back retraces the original arrangement exactly. For a widget arriving from another board (including a nested board entering its parent), the destination board's `'swap'` mode becomes strict insertion: the requested anchor cell must be empty, the incoming widget keeps its size when possible or downscales into the available room to its right and below, and existing destination widgets are neither exchanged nor reflowed. Releasing over an occupied anchor or where `minW`/`minH` cannot fit cancels the transfer and restores both boards; it does not reuse the last valid preview or search for another slot. No mode ever *grows* a widget. Applies to single-widget drags. Arrow keys honour it too but never resize anything — each press is a gesture of its own, so a press that shrank a widget would have nothing to restore from, and only an exchange that fits outright is allowed. A multi-widget selection still only moves where it fits outright, and a resize is still blocked by a collision.
- **`isDraggable`** `boolean` (default: `true`) — Enable dragging for all widgets.
- **`isResizable`** `boolean` (default: `true`) — Enable resizing for all widgets.
- **`isDroppable`** `boolean` (default: `true`) — Whether the board accepts widgets dropped from other boards.
Expand Down Expand Up @@ -276,10 +276,11 @@ so it lines up with a control centred on the opposite corner.
### Resolving a blocked drop

On a free grid a drop onto occupied cells is refused. `collisionMode` resolves it
instead: `'downscale'` shrinks the widget into whatever room is at the drop cell,
`'swap'` trades places with the widget already there. Neither ever grows a widget.
One board per mode below — drag the wide widget onto the row below in the first,
and drop a widget onto one of its neighbours in the second.
instead: `'downscale'` shrinks the widget into whatever room is at the drop cell.
Within one board, `'swap'` trades places with the widget already there. Across
boards, the same mode inserts only at an empty anchor and downscales without
moving destination widgets; an invalid release cancels the transfer. None of
these paths ever grows a widget. The examples below show all three behaviors.

<Story of={BoardStories.CollisionModes} />

Expand Down
56 changes: 54 additions & 2 deletions src/components/layout/Board/Board.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default {
control: { type: 'radio' },
options: ['revert', 'downscale', 'swap'],
description:
'How to resolve a drop the grid would otherwise refuse. Only applies where a collision blocks a move (`compact="free"`, or `preventCollision`).',
'How to resolve a drop the grid would otherwise refuse. Only applies where a collision blocks a move (`compact="free"`, or `preventCollision`). `swap` exchanges widgets within a board; across boards it inserts only at an empty anchor and downscales without moving destination widgets.',
table: { defaultValue: { summary: 'revert' } },
},
resizeGripPlacement: {
Expand Down Expand Up @@ -581,6 +581,58 @@ const CollisionModesTemplate: StoryFn<CubeBoardProps> = () => (
<WidgetBody title="Bottom" text="Drop me onto one of the others" />
</Board.Widget>
</CollisionBoard>

<Flow gap="1x">
<Text preset="t3" color="#dark-02">
<code>collisionMode=&quot;swap&quot;</code> across boards — drag the
incoming widget into empty space on the target; dropping on the blocker
cancels the transfer
</Text>
<Board.Provider>
<Flow gap="1x" gridColumns="1fr 1fr" display="grid">
<Board
id="collision-source"
fill="#light"
padding="1x"
radius="1r"
cols={6}
extraRows={1}
compact="free"
collisionMode="swap"
showGridLines="drag"
widgetProps={{ isCard: true }}
defaultLayout={[{ i: 'incoming', x: 0, y: 0, w: 4, h: 1 }]}
>
<Board.Widget id="incoming">
<WidgetBody
title="Incoming — 4 columns"
text="Drag me to the target"
/>
</Board.Widget>
</Board>
<Board
id="collision-target"
fill="#light"
padding="1x"
radius="1r"
cols={6}
extraRows={1}
compact="free"
collisionMode="swap"
showGridLines="drag"
widgetProps={{ isCard: true }}
defaultLayout={[{ i: 'target-blocker', x: 3, y: 0, w: 3, h: 1 }]}
>
<Board.Widget id="target-blocker">
<WidgetBody
title="Target blocker"
text="Existing widgets never move"
/>
</Board.Widget>
</Board>
</Flow>
</Board.Provider>
</Flow>
</Flow>
);

Expand All @@ -592,7 +644,7 @@ CollisionModes.parameters = {
docs: {
description: {
story:
'A `compact="free"` board refuses a drop onto occupied cells; `collisionMode` resolves it instead. **Downscale** — drag the 4-column widget onto the middle row and it shrinks to the 3 columns free beside the blocker, instead of snapping back. **Swap** — drop one widget onto another and they trade places: the dragged widget takes the other\'s cell, the displaced one takes the cell the drag began at, and each keeps as much of its own size as fits there. Exactly one widget is ever displaced, a drop straddling two of them trades with the one it covers most, and dragging back retraces the original arrangement. Neither mode ever grows a widget. The default, `"revert"`, is what every other story on this page shows: the widget snaps back.',
'A `compact="free"` board refuses a drop onto occupied cells; `collisionMode` resolves it instead. **Downscale** — drag the 4-column widget onto the middle row and it shrinks to the 3 columns free beside the blocker, instead of snapping back. **Swap within one board** — drop one widget onto another and they trade places: the dragged widget takes the other\'s cell, the displaced one takes the cell the drag began at, and each keeps as much of its own size as fits there. Exactly one widget is ever displaced, a drop straddling two of them trades with the one it covers most, and dragging back retraces the original arrangement. **Swap across boards** — the incoming widget can only use an empty anchor, downscales into the room to its right and below, and never moves a destination widget; an occupied anchor cancels the transfer. Neither path ever grows a widget. The default, `"revert"`, is what every other story on this page shows: the widget snaps back.',
},
},
};
Expand Down
274 changes: 274 additions & 0 deletions src/components/layout/Board/Board.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2338,6 +2338,280 @@ describe('Board', () => {
expect(rectOf('a')).toBe('0,2 4x1');
});

describe('cross-board swap', () => {
function setupCrossBoardSwap(
incoming: LayoutItem,
targetLayout: LayoutItem[],
) {
const onSourceLayoutChange = vi.fn();
const onTargetLayoutChange = vi.fn();
const onWidgetTransfer = vi.fn();
const onDragStop = vi.fn();

render(
<Board.Provider onWidgetTransfer={onWidgetTransfer}>
<Board
id="source"
width={600}
cols={6}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
compact="free"
collisionMode="swap"
defaultLayout={[incoming]}
onLayoutChange={onSourceLayoutChange}
onDragStop={onDragStop}
>
<Board.Widget id={incoming.i} qa="Incoming">
Incoming
</Board.Widget>
</Board>
<Board
id="target"
width={600}
cols={6}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
compact="free"
collisionMode="swap"
defaultLayout={targetLayout}
onLayoutChange={onTargetLayoutChange}
>
{targetLayout.map((it) => (
<Board.Widget key={it.i} id={it.i} qa={`Target-${it.i}`}>
{it.i}
</Board.Widget>
))}
</Board>
</Board.Provider>,
);

const widget = screen.getByTestId('Incoming');
const sourceContent = widget.parentElement as HTMLElement;
const firstTarget = targetLayout[0];
const targetContent = firstTarget
? (screen.getByTestId(`Target-${firstTarget.i}`)
.parentElement as HTMLElement)
: (document.querySelector(
'[data-board-id="target"] [data-qa="BoardContent"]',
) as HTMLElement);

sourceContent.getBoundingClientRect = () => mockRect(0, 0, 600, 600);
targetContent.getBoundingClientRect = () => mockRect(600, 0, 600, 600);
widget.getBoundingClientRect = () =>
mockRect(0, 0, incoming.w * 100, incoming.h * 100);

const start = () =>
fireEvent(widget, pointerEvent('pointerdown', 0, 0));
const moveTo = (x: number, y: number) =>
fireEvent(
window,
pointerEvent('pointermove', 600 + x * 100, y * 100),
);
const endAt = (x: number, y: number) =>
fireEvent(window, pointerEvent('pointerup', 600 + x * 100, y * 100));
const dragTo = (x: number, y: number) => {
start();
moveTo(x, y);
endAt(x, y);
};

return {
start,
moveTo,
endAt,
dragTo,
onSourceLayoutChange,
onTargetLayoutChange,
onWidgetTransfer,
onDragStop,
};
}

it('inserts at full size when the requested target space is empty', () => {
const targetLayout = [{ i: 'b', x: 4, y: 0, w: 2, h: 1 }];
const { dragTo, onTargetLayoutChange, onWidgetTransfer } =
setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, targetLayout);

dragTo(0, 0);

const committed = onTargetLayoutChange.mock
.calls[0]![0] as LayoutItem[];
expect(committed).toEqual([
expect.objectContaining(targetLayout[0]),
expect.objectContaining({ i: 'a', x: 0, y: 0, w: 2, h: 1 }),
]);
expect(onWidgetTransfer).toHaveBeenCalledWith(
expect.objectContaining({
fromBoardId: 'source',
toBoardId: 'target',
item: expect.objectContaining({ x: 0, y: 0, w: 2, h: 1 }),
}),
);
});

it('downscales at an empty anchor without moving target widgets', () => {
const targetLayout = [{ i: 'b', x: 3, y: 0, w: 3, h: 1 }];
const { dragTo, onTargetLayoutChange, onWidgetTransfer } =
setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 4, h: 1 }, targetLayout);

dragTo(0, 0);

const committed = onTargetLayoutChange.mock
.calls[0]![0] as LayoutItem[];
expect(committed.find((it) => it.i === 'a')).toEqual(
expect.objectContaining({ x: 0, y: 0, w: 3, h: 1 }),
);
expect(committed.find((it) => it.i === 'b')).toEqual(
expect.objectContaining(targetLayout[0]),
);
expect(onWidgetTransfer.mock.calls[0]![0].item).toEqual(
expect.objectContaining({ x: 0, y: 0, w: 3, h: 1 }),
);
});

it('cancels the transfer when the anchor cell is occupied', () => {
const {
dragTo,
onSourceLayoutChange,
onTargetLayoutChange,
onWidgetTransfer,
onDragStop,
} = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, [
{ i: 'b', x: 0, y: 0, w: 2, h: 1 },
]);

dragTo(0, 0);

expect(onSourceLayoutChange).not.toHaveBeenCalled();
expect(onTargetLayoutChange).not.toHaveBeenCalled();
expect(onWidgetTransfer).not.toHaveBeenCalled();
expect(onDragStop).toHaveBeenCalledWith(
expect.objectContaining({
item: expect.objectContaining({ i: 'a', x: 0, y: 0 }),
layout: [expect.objectContaining({ i: 'a', x: 0, y: 0 })],
}),
);
});

it('cancels when the available space is below the minimum size', () => {
const {
dragTo,
onSourceLayoutChange,
onTargetLayoutChange,
onWidgetTransfer,
} = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 4, h: 1, minW: 4 }, [
{ i: 'b', x: 3, y: 0, w: 3, h: 1 },
]);

dragTo(0, 0);

expect(onSourceLayoutChange).not.toHaveBeenCalled();
expect(onTargetLayoutChange).not.toHaveBeenCalled();
expect(onWidgetTransfer).not.toHaveBeenCalled();
});

it('cancels instead of committing the last valid preview', () => {
const {
start,
moveTo,
endAt,
onSourceLayoutChange,
onTargetLayoutChange,
onWidgetTransfer,
} = setupCrossBoardSwap({ i: 'a', x: 0, y: 0, w: 2, h: 1 }, [
{ i: 'b', x: 2, y: 0, w: 2, h: 1 },
]);

start();
moveTo(0, 0);
expect(screen.getAllByTestId('BoardPlaceholder')).toHaveLength(1);
moveTo(2, 0);
expect(
screen.queryByTestId('BoardPlaceholder'),
).not.toBeInTheDocument();
endAt(2, 0);

expect(onSourceLayoutChange).not.toHaveBeenCalled();
expect(onTargetLayoutChange).not.toHaveBeenCalled();
expect(onWidgetTransfer).not.toHaveBeenCalled();
});

it('uses the same insertion rule when dragging from a nested board to its parent', () => {
const onParentLayoutChange = vi.fn();
const onChildLayoutChange = vi.fn();
const onWidgetTransfer = vi.fn();

render(
<Board.Provider onWidgetTransfer={onWidgetTransfer}>
<Board
id="parent"
width={600}
cols={6}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
compact="free"
collisionMode="swap"
defaultLayout={[{ i: 'container', x: 0, y: 0, w: 3, h: 2 }]}
onLayoutChange={onParentLayoutChange}
>
<Board.Widget id="container" qa="Container">
<Board
id="child"
width={300}
cols={3}
rowHeight={100}
margin={[0, 0]}
containerPadding={[0, 0]}
compact="free"
collisionMode="swap"
defaultLayout={[{ i: 'a', x: 0, y: 0, w: 1, h: 1 }]}
onLayoutChange={onChildLayoutChange}
>
<Board.Widget id="a" qa="NestedIncoming">
A
</Board.Widget>
</Board>
</Board.Widget>
</Board>
</Board.Provider>,
);

const widget = screen.getByTestId('NestedIncoming');
const childContent = widget.parentElement as HTMLElement;
const container = screen.getByTestId('Container');
const parentContent = container.parentElement as HTMLElement;
childContent.getBoundingClientRect = () => mockRect(0, 0, 300, 200);
parentContent.getBoundingClientRect = () => mockRect(0, 0, 600, 400);
container.getBoundingClientRect = () => mockRect(0, 0, 300, 200);
widget.getBoundingClientRect = () => mockRect(0, 0, 100, 100);

fireEvent(widget, pointerEvent('pointerdown', 0, 0));
fireEvent(window, pointerEvent('pointermove', 400, 0));
fireEvent(window, pointerEvent('pointerup', 400, 0));

const committed = onParentLayoutChange.mock
.calls[0]![0] as LayoutItem[];
expect(committed.find((it) => it.i === 'container')).toEqual(
expect.objectContaining({ x: 0, y: 0, w: 3, h: 2 }),
);
expect(committed.find((it) => it.i === 'a')).toEqual(
expect.objectContaining({ x: 4, y: 0, w: 1, h: 1 }),
);
expect(onChildLayoutChange).toHaveBeenCalledWith([]);
expect(onWidgetTransfer).toHaveBeenCalledWith(
expect.objectContaining({
widgetId: 'a',
fromBoardId: 'child',
toBoardId: 'parent',
}),
);
});
});

describe('keyboard', () => {
function setupKeyboardBoard(
collisionMode: 'downscale' | 'swap',
Expand Down
Loading
Loading