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
9 changes: 9 additions & 0 deletions .changeset/board-downscale-clamped-anchors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@cube-dev/ui-kit': patch
---

`<Board collisionMode="downscale">` (and the cross-board half of `"swap"`) now shrinks a widget into free room on **either** side of a blocker, not just the left.

`gridBounds` clamps a drag anchor to `cols - w`, which assumes the widget keeps the width it started with. So when the blocker sat to the left and the room to its right was narrower than the widget, every anchor a pointer could produce landed inside the blocker — the mode whose whole job is to shrink the widget into that room never got offered it, and the drop just reverted. Dropping into room on the _left_ always worked, because column 0 is reachable whatever the widget's width.

A blocked drop whose anchor is **pinned against the grid edge** now also considers the cells beyond it that the clamp hid, taking the largest fit among them. An anchor short of the limit is the pointer's own choice — every cell between it and the limit was available to aim at — so a blocked drop there still reverts, and every drop that resolved before resolves to exactly the same cell. The row axis clamps the same way, so boards with a finite `maxRows` get the same fix below a blocker.
11 changes: 11 additions & 0 deletions .changeset/board-swap-places-arrivals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cube-dev/ui-kit': minor
---

`<Board collisionMode="swap">` now **places** a widget arriving from another board instead of cancelling the transfer.

`swap` used to treat a cross-board arrival as strict insertion: the anchor cell had to be empty, and releasing over an occupied one cancelled the whole transfer — both boards snapped back and `onWidgetTransfer` never fired. That made the mode's two halves unusable together, since a board that wanted in-board swapping had to give up cross-board drops (or pick `downscale` and give up the swap).

A cross-board arrival now resolves the same way `downscale` does: it keeps its size where the drop cell allows, downscales into the room to its right and below where it does not, and holds the last cell it fitted in as the pointer sweeps across a destination widget — so releasing over an occupied cell commits what the preview was showing. Entering a board directly over an occupied cell places the widget in the nearest cell that fits. Destination widgets are still never exchanged, pushed, or reflowed to make space — only the arrival moves. In-board drops are unchanged and still swap.

Also fixes a cross-board landing under any `collisionMode` where a refused placement committed the widget one row above the board (`y: -1`) instead of on it.
4 changes: 2 additions & 2 deletions src/components/layout/Board/Board.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ An aligned board never shrinks its rows, so pair it with `isAutoHeight` on the c
- **`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). It also **switches compaction off**: with it on, `compact="vertical"`/`"horizontal"` no longer reflow, and `preventCollision`/`collisionMode` have no effect at all, since nothing is ever refused. In `'free'` mode it is what distinguishes stacking from blocking a drop onto an occupied cell.
- **`preventCollision`** `boolean` (default: `false`) — Block movement into occupied cells instead of pushing. Applies to `'vertical'`, `'horizontal'` and `null` only: `compact="free"` derives blocking from `allowOverlap` and ignores this prop, and `allowOverlap` overrides it everywhere.
- **`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.
- **`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), there is no slot on the destination to give back, so `'swap'` resolves as `'downscale'`: the incoming widget keeps its size when possible or downscales into the available room to its right and below, and existing destination widgets are never exchanged. A release over an occupied anchor commits whatever the preview was showing rather than cancelling the transfer: the widget holds the last cell it fitted in as you sweep across a destination widget, and enters at the nearest fitting cell when the very first cell it is offered is occupied. 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. A ceiling, not a default: `false` here cannot be re-enabled by a widget, and a `static` layout item is never draggable regardless.
- **`isResizable`** `boolean` (default: `true`) — Enable resizing for all widgets. Same ceiling semantics as `isDraggable`.
- **`isDroppable`** `boolean` (default: `true`) — Whether the board accepts widgets dropped from _other_ boards. `false` does not affect dragging within the board.
Expand Down Expand Up @@ -357,7 +357,7 @@ Centre the resize grip on the widget's corner with `resizeGripPlacement="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. 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.
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. Within one board, `'swap'` trades places with the widget already there. Across boards, the same mode downscales into an empty cell without exchanging destination widgets, and an occupied anchor commits the previewed cell instead of cancelling. None of these paths ever grows a widget. The examples below show all three behaviors.

<Story of={BoardStories.CollisionModes} />

Expand Down
9 changes: 5 additions & 4 deletions src/components/layout/Board/Board.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -684,8 +684,9 @@ const CollisionModesTemplate: StoryFn<CubeBoardProps> = () => (
<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
incoming widget onto the room right of the blocker and it downscales
into it, even though 4 columns never fit there; dropping on the blocker
itself commits the previewed cell instead of cancelling
</Text>
<Board.Provider>
<Flow gap="1x" gridColumns="1fr 1fr" display="grid">
Expand Down Expand Up @@ -720,7 +721,7 @@ const CollisionModesTemplate: StoryFn<CubeBoardProps> = () => (
collisionMode="swap"
showGridLines="drag"
widgetProps={{ isCard: true }}
defaultLayout={[{ i: 'target-blocker', x: 3, y: 0, w: 3, h: 1 }]}
defaultLayout={[{ i: 'target-blocker', x: 0, y: 0, w: 3, h: 1 }]}
>
<Board.Widget id="target-blocker">
<WidgetBody
Expand All @@ -743,7 +744,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 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.',
'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** — there is no slot on the destination to trade back, so the arrival downscales into the room to its right and below and never moves a destination widget; an occupied anchor commits the cell the preview was showing rather than cancelling. Neither path ever grows a widget. The default, `"revert"`, is what every other story on this page shows: the widget snaps back.',
},
},
};
Expand Down
Loading
Loading