@snapgridjs/react 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,24 +1,222 @@
1
- import { DragDropProvider, useDragDropMonitor, useDraggable, useDraggable as useDraggable$1, useDroppable, useDroppable as useDroppable$1 } from "@dnd-kit/react";
2
- import { beginDrag, beginReceive, beginResize, bottom, calcGridItemPosition, calcXY, commitLayout, defaultGridConfig, dragResize, dragTo, findOrGenerateResponsiveLayout, getBreakpointFromWidth, getColsFromBreakpoint, getCompactor, horizontalCompactor, noCompactor, nudge, removeItemWithCompactor, toPositionParams, verticalCompactor, verticalCompactor as verticalCompactor$1 } from "@snapgridjs/core";
3
- import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
1
+ import { DragDropProvider, DragOverlay, useDragDropManager, useDragDropMonitor, useDraggable, useDraggable as useDraggable$1, useDroppable, useDroppable as useDroppable$1, useInstance } from "@dnd-kit/react";
2
+ import { beginDrag, beginReceive, beginResize, bottom, calcGridColWidth, calcGridItemPosition, calcXY, commitLayout, defaultGridConfig, dragResize, dragTo, findOrGenerateResponsiveLayout, getBreakpointFromWidth, getColsFromBreakpoint, getCompactor, horizontalCompactor, noCompactor, nudge, removeItemWithCompactor, toPositionParams, verticalCompactor, verticalCompactor as verticalCompactor$1 } from "@snapgridjs/core";
3
+ import { Children, createContext, isValidElement, memo, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import { Modifier } from "@dnd-kit/abstract";
4
5
  import { Feedback, Feedback as Feedback$1, KeyboardSensor, KeyboardSensor as KeyboardSensor$1, PointerActivationConstraints, PointerSensor, PointerSensor as PointerSensor$1 } from "@dnd-kit/dom";
5
- import { jsx, jsxs } from "react/jsx-runtime";
6
- import { createPortal } from "react-dom";
7
- //#region src/context.ts
8
- const GridContext = createContext(null);
9
- /** Read the grid runtime; throws if used outside a `SnapGridProvider`. */
10
- function useGridRuntime() {
11
- const runtime = useContext(GridContext);
12
- if (!runtime) throw new Error("snapgrid: hooks and components must be rendered inside a <SnapGridProvider> (or <GridLayout>).");
13
- return runtime;
6
+ import { isKeyboardEvent } from "@dnd-kit/dom/utilities";
7
+ import { useSortable } from "@dnd-kit/react/sortable";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/controller/GridController.ts
10
+ function sameItem(a, b) {
11
+ if (a === b) return true;
12
+ if (!a || !b) return false;
13
+ return a.i === b.i && a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
14
+ }
15
+ /**
16
+ * Live per-grid drag/resize state as a plain observable: the provider writes
17
+ * (`setSession`/`setKeyboard`/`setCommitted`), hooks subscribe to just their own
18
+ * slice via `useSyncExternalStore`. Value-cached snapshots mean a drag re-renders
19
+ * only the tiles whose slice changed, not the whole subtree (the old
20
+ * context-value model re-rendered every tile every frame).
21
+ */
22
+ var GridController = class {
23
+ id;
24
+ #committed;
25
+ #session = null;
26
+ #keyboard = false;
27
+ #listeners = /* @__PURE__ */ new Set();
28
+ config = null;
29
+ #itemCache = /* @__PURE__ */ new Map();
30
+ #resizeCache = /* @__PURE__ */ new Map();
31
+ #placeholderCache = null;
32
+ #renderedMap = null;
33
+ #renderedMapSource = null;
34
+ #indexById = /* @__PURE__ */ new Map();
35
+ #nextIndex = 0;
36
+ /** The dnd-kit manager this grid is registered with (set by useInstance). */
37
+ manager;
38
+ constructor(id, committed = [], manager) {
39
+ this.id = id;
40
+ this.#committed = committed;
41
+ this.manager = manager;
42
+ }
43
+ /** Replace the per-grid config (called by the container host during render). */
44
+ setConfig(config) {
45
+ this.config = config;
46
+ }
47
+ /**
48
+ * Re-point this grid's id. The container host syncs it (during render, before
49
+ * the droppable/group read it) when the controlled `id` prop changes, so the
50
+ * returned `group`, the droppable id, and the registry key never drift apart.
51
+ */
52
+ setId(id) {
53
+ this.id = id;
54
+ }
55
+ register = () => {};
56
+ subscribe = (listener) => {
57
+ this.#listeners.add(listener);
58
+ return () => {
59
+ this.#listeners.delete(listener);
60
+ };
61
+ };
62
+ #emit() {
63
+ for (const listener of this.#listeners) listener();
64
+ }
65
+ /** The layout currently shown: the drag preview while dragging, else committed. */
66
+ #rendered() {
67
+ return this.#session ? this.#session.preview : this.#committed;
68
+ }
69
+ #renderedById() {
70
+ const rendered = this.#rendered();
71
+ if (this.#renderedMapSource !== rendered) {
72
+ this.#renderedMap = new Map(rendered.map((it) => [it.i, it]));
73
+ this.#renderedMapSource = rendered;
74
+ }
75
+ return this.#renderedMap;
76
+ }
77
+ /**
78
+ * Sync the committed layout from the controlled `layout` prop. Called during
79
+ * the provider's render, so it must NOT notify — emitting here would update
80
+ * subscribed GridItems mid-render (a React "setState while rendering" error).
81
+ * No notify is needed: a `layout` prop change already re-renders the whole
82
+ * provider subtree, so every GridItem re-reads its snapshot on that pass.
83
+ */
84
+ setCommitted(layout) {
85
+ if (this.#committed === layout) return;
86
+ this.#committed = layout;
87
+ const present = new Set(layout.map((it) => it.i));
88
+ for (const id of this.#indexById.keys()) if (!present.has(id)) this.#indexById.delete(id);
89
+ for (const id of this.#itemCache.keys()) if (!present.has(id)) this.#itemCache.delete(id);
90
+ for (const id of this.#resizeCache.keys()) if (!present.has(id)) this.#resizeCache.delete(id);
91
+ }
92
+ setSession(next) {
93
+ this.#session = next;
94
+ this.#emit();
95
+ }
96
+ getSession() {
97
+ return this.#session;
98
+ }
99
+ /** Record whether the active drag is keyboard-driven (drives `hidden`). */
100
+ setKeyboard(value) {
101
+ if (this.#keyboard === value) return;
102
+ this.#keyboard = value;
103
+ this.#emit();
104
+ }
105
+ itemSnapshot = (id) => {
106
+ const item = this.#renderedById().get(id);
107
+ const isDragging = this.#session?.activeId === id;
108
+ const hidden = isDragging && this.#session?.kind === "move" && !this.#keyboard;
109
+ const prev = this.#itemCache.get(id);
110
+ if (prev && prev.isDragging === isDragging && prev.hidden === hidden && sameItem(prev.item, item)) return prev;
111
+ const snap = {
112
+ item,
113
+ isDragging,
114
+ hidden
115
+ };
116
+ this.#itemCache.set(id, snap);
117
+ return snap;
118
+ };
119
+ placeholderSnapshot = () => {
120
+ const next = this.#session?.placeholder ?? null;
121
+ if (sameItem(this.#placeholderCache ?? void 0, next ?? void 0)) return this.#placeholderCache;
122
+ this.#placeholderCache = next;
123
+ return next;
124
+ };
125
+ resizeSnapshot = (itemId) => {
126
+ const isResizing = this.#session?.kind === "resize" && this.#session.activeId === itemId;
127
+ const prev = this.#resizeCache.get(itemId);
128
+ if (prev && prev.isResizing === isResizing) return prev;
129
+ const snap = { isResizing };
130
+ this.#resizeCache.set(itemId, snap);
131
+ return snap;
132
+ };
133
+ renderedSnapshot = () => this.#rendered();
134
+ /** A stable index for `id` (see {@link GridController.#indexById}). */
135
+ itemIndex(id) {
136
+ let i = this.#indexById.get(id);
137
+ if (i === void 0) {
138
+ i = this.#nextIndex++;
139
+ this.#indexById.set(id, i);
140
+ }
141
+ return i;
142
+ }
143
+ };
144
+ //#endregion
145
+ //#region src/controller/registry.ts
146
+ /**
147
+ * Resolves a grid's {@link GridController} by its id, scoped to the dnd-kit
148
+ * manager the grid is registered with. A container registers its controller
149
+ * here (during render, so child items resolve it on their first render); items
150
+ * look it up by their `group` (= the grid id). Replaces the old geometry
151
+ * `GridRegistry` — which grid the pointer is over now comes from the collision
152
+ * target, so the registry's only job is id → controller resolution.
153
+ *
154
+ * Keyed by manager so two apps (or two providers) never collide, and grids in
155
+ * one provider share a map (the cross-grid seam).
156
+ */
157
+ const byManager = /* @__PURE__ */ new WeakMap();
158
+ const noManager = /* @__PURE__ */ new Map();
159
+ function mapFor(manager) {
160
+ if (!manager) return noManager;
161
+ let map = byManager.get(manager);
162
+ if (!map) {
163
+ map = /* @__PURE__ */ new Map();
164
+ byManager.set(manager, map);
165
+ }
166
+ return map;
167
+ }
168
+ /** Register a controller under `id` for `manager`. Returns an unregister fn. */
169
+ function registerController(manager, id, controller) {
170
+ const map = mapFor(manager);
171
+ map.set(id, controller);
172
+ return () => {
173
+ if (map.get(id) === controller) map.delete(id);
174
+ };
175
+ }
176
+ /** The controller registered under `id` for `manager`, or undefined. */
177
+ function getController(manager, id) {
178
+ return mapFor(manager).get(id);
179
+ }
180
+ const grabOffsets = /* @__PURE__ */ new WeakMap();
181
+ const noManagerGrab = { current: null };
182
+ function setGrabOffset(manager, offset) {
183
+ if (!manager) {
184
+ noManagerGrab.current = offset;
185
+ return;
186
+ }
187
+ if (offset) grabOffsets.set(manager, offset);
188
+ else grabOffsets.delete(manager);
189
+ }
190
+ function getGrabOffset(manager) {
191
+ return (manager ? grabOffsets.get(manager) : noManagerGrab.current) ?? {
192
+ x: 0,
193
+ y: 0
194
+ };
14
195
  }
15
196
  //#endregion
16
- //#region src/dragFlow.ts
197
+ //#region src/dnd/dragFlow.ts
17
198
  /**
18
- * Pure decision helpers for the drag interaction, extracted from
19
- * {@link SnapGridProvider} so the tricky bits grab-offset cell mapping and the
20
- * cross-grid drop lifecycle — are unit-testable without a DOM or dnd-kit.
199
+ * Pure decision helpers for the drag interaction so the tricky bits — grab-offset
200
+ * cell mapping, the cross-grid drop lifecycle, and external-drop acceptance are
201
+ * unit-testable without a DOM or dnd-kit.
21
202
  */
203
+ /** Read snapgrid's payload off a dnd-kit drag source. */
204
+ function dragData(event) {
205
+ return (event.operation.source?.data)?.snapGrid;
206
+ }
207
+ /** Size/id spec for an external (non-grid) draggable the grid may accept, or null. */
208
+ function externalDropSpec(source, dropConfig) {
209
+ if (!dropConfig?.enabled || !source) return null;
210
+ const data = source.data;
211
+ if (data?.snapGrid) return null;
212
+ if (dropConfig.accept && !dropConfig.accept(source)) return null;
213
+ const spec = data?.snapGridDrop;
214
+ return {
215
+ i: spec?.i,
216
+ w: spec?.w ?? dropConfig.defaultItem?.w ?? 1,
217
+ h: spec?.h ?? dropConfig.defaultItem?.h ?? 1
218
+ };
219
+ }
22
220
  /**
23
221
  * Map a client-space pointer to a grid cell, accounting for where *within* the
24
222
  * dragged tile the pointer grabbed it. Subtracting the grab offset means the
@@ -29,6 +227,29 @@ function useGridRuntime() {
29
227
  function receiveCell(pointer, gridRect, grabOffset, w, h, pp) {
30
228
  return calcXY(pp, pointer.y - grabOffset.y - gridRect.top, pointer.x - grabOffset.x - gridRect.left, w, h);
31
229
  }
230
+ /**
231
+ * Map a keyboard event key to a one-cell grid step while a keyboard drag is
232
+ * active, or null for keys snapgrid doesn't own — Enter/Space (drop) and Escape
233
+ * (cancel) fall through to dnd-kit's KeyboardSensor.
234
+ */
235
+ function arrowStep(key) {
236
+ switch (key) {
237
+ case "ArrowLeft": return [-1, 0];
238
+ case "ArrowRight": return [1, 0];
239
+ case "ArrowUp": return [0, -1];
240
+ case "ArrowDown": return [0, 1];
241
+ default: return null;
242
+ }
243
+ }
244
+ /**
245
+ * Which grid a drop commits to, as fed to {@link classifyDrop} as `dest`. A
246
+ * keyboard drag has no pointer, so it can only ever land in its own grid; a
247
+ * pointer drag lands in whichever grid the collision observer resolved (or none).
248
+ */
249
+ function dropDestination(opts) {
250
+ if (opts.keyboard) return opts.myId;
251
+ return opts.targetId != null ? String(opts.targetId) : null;
252
+ }
32
253
  /** Pure classification of a drag end. See {@link DropAction}. */
33
254
  function classifyDrop(s) {
34
255
  if (s.canceled) {
@@ -46,37 +267,27 @@ function classifyDrop(s) {
46
267
  return "noop";
47
268
  }
48
269
  //#endregion
49
- //#region src/grouping.ts
50
- function createGridRegistry() {
51
- const grids = /* @__PURE__ */ new Map();
52
- let grabOffset = null;
53
- return {
54
- register(id, getRect) {
55
- grids.set(id, getRect);
56
- return () => {
57
- if (grids.get(id) === getRect) grids.delete(id);
58
- };
59
- },
60
- gridAt(point) {
61
- for (const [id, getRect] of grids) {
62
- const r = getRect();
63
- if (r && point.x >= r.left && point.x <= r.right && point.y >= r.top && point.y <= r.bottom) return id;
64
- }
65
- return null;
66
- },
67
- setGrabOffset(offset) {
68
- grabOffset = offset;
69
- },
70
- getGrabOffset() {
71
- return grabOffset ?? {
72
- x: 0,
73
- y: 0
74
- };
75
- }
76
- };
77
- }
78
- /** Non-null when grids are wrapped in a `<SnapGridGroup>` (shared cross-grid registry). */
79
- const SnapGridGroupContext = createContext(null);
270
+ //#region src/dnd/snapToGrid.ts
271
+ /**
272
+ * Quantizes the dragged item's transform to whole grid cells, so the floating
273
+ * <DragOverlay> clone jumps cell-to-cell in lockstep with the (always-snapped)
274
+ * placeholder instead of tracking the pointer smoothly. Applied on the item
275
+ * draggable; a no-op unless `dragConfig.snapToGrid` is set.
276
+ */
277
+ var SnapToGrid = class extends Modifier {
278
+ apply({ transform }) {
279
+ const opts = this.options;
280
+ if (!opts?.isEnabled()) return transform;
281
+ const pp = opts.getPositionParams();
282
+ const colStep = calcGridColWidth(pp) + pp.margin[0];
283
+ const rowStep = pp.rowHeight + pp.margin[1];
284
+ if (colStep <= 0 || rowStep <= 0) return transform;
285
+ return {
286
+ x: Math.round(transform.x / colStep) * colStep,
287
+ y: Math.round(transform.y / rowStep) * rowStep
288
+ };
289
+ }
290
+ };
80
291
  //#endregion
81
292
  //#region src/hooks/dndShared.ts
82
293
  /** Marker attribute placed on resize-handle elements. */
@@ -108,52 +319,30 @@ function buildItemSensors(threshold, getDragConfig) {
108
319
  }), KeyboardSensor$1];
109
320
  }
110
321
  //#endregion
111
- //#region src/SnapGridProvider.tsx
322
+ //#region src/hooks/useGridController.ts
112
323
  const DEFAULT_HANDLES = ["se"];
113
- /** Read snapgrid's payload off a dnd-kit drag source. */
114
- function dragData(event) {
115
- return (event.operation.source?.data)?.snapGrid;
116
- }
117
- /** Size/id spec for an external (non-grid) draggable the grid may accept, or null. */
118
- function externalDropSpec(source, dropConfig) {
119
- if (!dropConfig?.enabled || !source) return null;
120
- const data = source.data;
121
- if (data?.snapGrid) return null;
122
- if (dropConfig.accept && !dropConfig.accept(source)) return null;
123
- const spec = data?.snapGridDrop;
124
- return {
125
- i: spec?.i,
126
- w: spec?.w ?? dropConfig.defaultItem?.w ?? 1,
127
- h: spec?.h ?? dropConfig.defaultItem?.h ?? 1
128
- };
129
- }
130
324
  /**
131
- * Headless provider for a grid. Standalone grids get their own isolated dnd-kit
132
- * provider; grids inside a {@link SnapGridGroup} share that group's provider and
133
- * registry so tiles can be dragged between them. Owns this grid's drag/resize
134
- * session; the consumer owns all markup/styling.
325
+ * The grid's brain: owns the {@link GridController}, runs the dnd-kit drag/resize
326
+ * monitor for this grid, and writes per-grid config to the controller each render.
327
+ * Created by {@link useGridContainer}; items resolve the same controller by their
328
+ * `group` (= this grid's id) from the per-manager registry. Consumes the ambient
329
+ * `DragDropProvider` — it does not mint one.
135
330
  */
136
- function SnapGridProvider(props) {
137
- const groupRegistry = useContext(SnapGridGroupContext);
138
- const runtime = /* @__PURE__ */ jsx(SnapGridRuntime, {
139
- groupRegistry,
140
- ...props
141
- });
142
- return groupRegistry ? runtime : /* @__PURE__ */ jsx(DragDropProvider, { children: runtime });
143
- }
144
- function SnapGridRuntime(props) {
331
+ function useGridController(opts) {
145
332
  const autoId = useId();
146
- const containerId = props.id ?? autoId;
333
+ const containerId = opts.id ?? autoId;
147
334
  const gridConfig = useMemo(() => ({
148
335
  ...defaultGridConfig,
149
- ...props.gridConfig
150
- }), [props.gridConfig]);
151
- const positionParams = useMemo(() => toPositionParams(gridConfig, props.width), [gridConfig, props.width]);
152
- const compactor = props.compactor ?? verticalCompactor$1;
153
- const [session, setSession] = useState(null);
154
- const [overlay, setOverlay] = useState(null);
155
- const propsRef = useRef(props);
156
- propsRef.current = props;
336
+ ...opts.gridConfig
337
+ }), [opts.gridConfig]);
338
+ const positionParams = useMemo(() => toPositionParams(gridConfig, opts.width), [gridConfig, opts.width]);
339
+ const compactor = opts.compactor ?? verticalCompactor$1;
340
+ const manager = useDragDropManager();
341
+ const controller = useInstance((m) => new GridController(containerId, opts.layout, m ?? void 0));
342
+ controller.setCommitted(opts.layout);
343
+ if (controller.id !== containerId) controller.setId(containerId);
344
+ const optsRef = useRef(opts);
345
+ optsRef.current = opts;
157
346
  const ppRef = useRef(positionParams);
158
347
  ppRef.current = positionParams;
159
348
  const gridRef = useRef(gridConfig);
@@ -162,39 +351,44 @@ function SnapGridRuntime(props) {
162
351
  compactorRef.current = compactor;
163
352
  const containerIdRef = useRef(containerId);
164
353
  containerIdRef.current = containerId;
354
+ const managerRef = useRef(manager);
355
+ managerRef.current = manager;
165
356
  const sessionRef = useRef(null);
166
357
  const containerElRef = useRef(null);
167
358
  const keyboardRef = useRef(false);
168
359
  const dropSpecRef = useRef(null);
169
360
  const dropCounterRef = useRef(0);
170
- const localRegistryRef = useRef(null);
171
- if (!localRegistryRef.current) localRegistryRef.current = createGridRegistry();
172
- const registry = props.groupRegistry ?? localRegistryRef.current;
173
- const registryRef = useRef(registry);
174
- registryRef.current = registry;
175
- useEffect(() => registry.register(containerId, () => containerElRef.current?.getBoundingClientRect() ?? null), [registry, containerId]);
176
- const committedById = useMemo(() => new Map(props.layout.map((it) => [it.i, it])), [props.layout]);
361
+ registerController(manager, containerId, controller);
362
+ useEffect(() => registerController(manager, containerId, controller), [
363
+ manager,
364
+ containerId,
365
+ controller
366
+ ]);
367
+ const committedById = useMemo(() => new Map(opts.layout.map((it) => [it.i, it])), [opts.layout]);
177
368
  const committedByIdRef = useRef(committedById);
178
369
  committedByIdRef.current = committedById;
179
370
  const setSessionBoth = useCallback((next) => {
180
371
  sessionRef.current = next;
181
- setSession(next);
182
- }, []);
372
+ controller.setSession(next);
373
+ }, [controller]);
374
+ const setKeyboard = useCallback((value) => {
375
+ keyboardRef.current = value;
376
+ controller.setKeyboard(value);
377
+ }, [controller]);
183
378
  const setContainerElement = useCallback((element) => {
184
379
  containerElRef.current = element;
185
380
  }, []);
186
381
  /**
187
- * Is THIS grid the one under `p` (client coords)? Resolved through the
188
- * registry (not a self-only rect test) so the move-phase preview and the
189
- * drop-phase commit which both go through `gridAt` always agree on which
190
- * grid wins when grids overlap (`gridAt` returns a single first match).
382
+ * Is THIS grid the drop target dnd-kit's collision observer resolved? Both the
383
+ * move-phase preview and the drop-phase commit read `operation.target`, so they
384
+ * always agree on which grid wins (one oracle), including when grids overlap.
191
385
  */
192
- const overMe = useCallback((p) => registryRef.current.gridAt(p) === containerIdRef.current, []);
386
+ const overMe = useCallback((target) => target?.id === containerIdRef.current, []);
193
387
  /** Map a client-space pointer to a grid cell within THIS grid (see {@link receiveCell}). */
194
388
  const cellFromPointer = useCallback((p, item) => {
195
389
  const el = containerElRef.current;
196
390
  if (!el) return null;
197
- return receiveCell(p, el.getBoundingClientRect(), registryRef.current.getGrabOffset(), item.w, item.h, ppRef.current);
391
+ return receiveCell(p, el.getBoundingClientRect(), getGrabOffset(managerRef.current), item.w, item.h, ppRef.current);
198
392
  }, []);
199
393
  const ctx = useCallback(() => ({
200
394
  positionParams: ppRef.current,
@@ -202,10 +396,10 @@ function SnapGridRuntime(props) {
202
396
  cols: gridRef.current.cols
203
397
  }), []);
204
398
  const handleDragStart = useCallback((event) => {
205
- keyboardRef.current = false;
399
+ setKeyboard(false);
206
400
  const data = dragData(event);
207
401
  if (!data) {
208
- const spec = externalDropSpec(event.operation.source, propsRef.current.dropConfig);
402
+ const spec = externalDropSpec(event.operation.source, optsRef.current.dropConfig);
209
403
  if (spec) {
210
404
  dropCounterRef.current += 1;
211
405
  dropSpecRef.current = {
@@ -217,7 +411,7 @@ function SnapGridRuntime(props) {
217
411
  return;
218
412
  }
219
413
  dropSpecRef.current = null;
220
- const layout = propsRef.current.layout;
414
+ const layout = optsRef.current.layout;
221
415
  const item = layout.find((it) => it.i === data.itemId);
222
416
  const p = event.operation.position.current;
223
417
  const pointer = {
@@ -231,11 +425,11 @@ function SnapGridRuntime(props) {
231
425
  rect: calcGridItemPosition(ppRef.current, item.x, item.y, item.w, item.h),
232
426
  pointer
233
427
  }, data.handle));
234
- propsRef.current.onResizeStart?.(layout, item, item, item, event.operation.activatorEvent, null);
428
+ optsRef.current.onResizeStart?.(layout, item, item, item, event.operation.activatorEvent, null);
235
429
  return;
236
430
  }
237
431
  if (item) {
238
- keyboardRef.current = event.operation.activatorEvent instanceof KeyboardEvent;
432
+ setKeyboard(event.operation.activatorEvent instanceof KeyboardEvent);
239
433
  const rect = calcGridItemPosition(ppRef.current, item.x, item.y, item.w, item.h);
240
434
  setSessionBoth(beginDrag(layout, {
241
435
  item,
@@ -244,22 +438,13 @@ function SnapGridRuntime(props) {
244
438
  pointer
245
439
  }));
246
440
  const cr = (event.operation.source?.element)?.getBoundingClientRect();
247
- if (cr) {
248
- registryRef.current.setGrabOffset({
249
- x: pointer.x - cr.left,
250
- y: pointer.y - cr.top
251
- });
252
- setOverlay({
253
- item,
254
- left: cr.left,
255
- top: cr.top,
256
- width: cr.width,
257
- height: cr.height
258
- });
259
- }
260
- propsRef.current.onDragStart?.(layout, item, item, item, event.operation.activatorEvent, null);
441
+ if (cr) setGrabOffset(managerRef.current, {
442
+ x: pointer.x - cr.left,
443
+ y: pointer.y - cr.top
444
+ });
445
+ optsRef.current.onDragStart?.(layout, item, item, item, event.operation.activatorEvent, null);
261
446
  }
262
- }, [setSessionBoth]);
447
+ }, [setSessionBoth, setKeyboard]);
263
448
  const handleDragMove = useCallback((event) => {
264
449
  if (keyboardRef.current) return;
265
450
  const p = event.operation.position.current;
@@ -271,13 +456,14 @@ function SnapGridRuntime(props) {
271
456
  if (current?.kind === "resize") {
272
457
  const next = dragResize(current, pointer, ctx());
273
458
  setSessionBoth(next);
274
- propsRef.current.onResize?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
459
+ optsRef.current.onResize?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
275
460
  return;
276
461
  }
462
+ const target = event.operation.target;
277
463
  const data = dragData(event);
278
464
  if (!data) {
279
465
  const spec = dropSpecRef.current;
280
- if (spec && overMe(pointer)) {
466
+ if (spec && overMe(target)) {
281
467
  const foreign = {
282
468
  i: spec.i,
283
469
  x: 0,
@@ -285,7 +471,7 @@ function SnapGridRuntime(props) {
285
471
  w: spec.w,
286
472
  h: spec.h
287
473
  };
288
- const committed = propsRef.current.layout;
474
+ const committed = optsRef.current.layout;
289
475
  const cell = cellFromPointer(pointer, foreign) ?? {
290
476
  x: 0,
291
477
  y: 0
@@ -295,7 +481,7 @@ function SnapGridRuntime(props) {
295
481
  return;
296
482
  }
297
483
  if (data.kind !== "move") return;
298
- const here = overMe(pointer);
484
+ const here = overMe(target);
299
485
  if (committedByIdRef.current.has(data.itemId)) {
300
486
  const source = current?.kind === "move" ? current : null;
301
487
  if (!source) return;
@@ -307,28 +493,12 @@ function SnapGridRuntime(props) {
307
493
  placeholder: null
308
494
  };
309
495
  setSessionBoth(next);
310
- const grab = registryRef.current.getGrabOffset();
311
- let oLeft = pointer.x - grab.x;
312
- let oTop = pointer.y - grab.y;
313
- if (propsRef.current.dragConfig?.snapToGrid && here && next.placeholder) {
314
- const rect = containerElRef.current?.getBoundingClientRect();
315
- if (rect) {
316
- const cell = calcGridItemPosition(ppRef.current, next.placeholder.x, next.placeholder.y, next.placeholder.w, next.placeholder.h);
317
- oLeft = rect.left + cell.left;
318
- oTop = rect.top + cell.top;
319
- }
320
- }
321
- setOverlay((o) => o ? {
322
- ...o,
323
- left: oLeft,
324
- top: oTop
325
- } : o);
326
- propsRef.current.onDrag?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
496
+ optsRef.current.onDrag?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
327
497
  return;
328
498
  }
329
499
  if (here) {
330
500
  const foreign = data.item;
331
- const committed = propsRef.current.layout;
501
+ const committed = optsRef.current.layout;
332
502
  const cell = cellFromPointer(pointer, foreign) ?? {
333
503
  x: 0,
334
504
  y: 0
@@ -344,17 +514,16 @@ function SnapGridRuntime(props) {
344
514
  const handleDragEnd = useCallback((event) => {
345
515
  const current = sessionRef.current;
346
516
  const data = dragData(event);
347
- const p = event.operation.position.current;
348
517
  const myId = containerIdRef.current;
349
- const dest = keyboardRef.current ? myId : registryRef.current.gridAt({
350
- x: p.x,
351
- y: p.y
518
+ const dest = dropDestination({
519
+ keyboard: keyboardRef.current,
520
+ targetId: event.operation.target?.id,
521
+ myId
352
522
  });
353
523
  const ownsItem = data ? committedByIdRef.current.has(data.itemId) : false;
354
- setOverlay(null);
355
- registryRef.current.setGrabOffset(null);
524
+ setGrabOffset(managerRef.current, null);
356
525
  const native = event.nativeEvent ?? null;
357
- const p2 = propsRef.current;
526
+ const o = optsRef.current;
358
527
  const action = classifyDrop({
359
528
  kind: current?.kind ?? null,
360
529
  canceled: event.canceled,
@@ -365,74 +534,59 @@ function SnapGridRuntime(props) {
365
534
  });
366
535
  switch (action) {
367
536
  case "cancel-resize":
368
- p2.onResizeStop?.(p2.layout, current?.anchor.item ?? null, null, null, native, null);
537
+ o.onResizeStop?.(o.layout, current?.anchor.item ?? null, null, null, native, null);
369
538
  break;
370
539
  case "cancel-move":
371
- p2.onDragStop?.(p2.layout, current?.anchor.item ?? null, null, null, native, null);
540
+ o.onDragStop?.(o.layout, current?.anchor.item ?? null, null, null, native, null);
372
541
  break;
373
542
  case "commit-resize":
374
543
  if (current) {
375
- p2.onLayoutChange?.(commitLayout(current));
376
- p2.onResizeStop?.(current.preview, current.anchor.item, current.placeholder, current.placeholder, native, null);
544
+ o.onLayoutChange?.(commitLayout(current));
545
+ o.onResizeStop?.(current.preview, current.anchor.item, current.placeholder, current.placeholder, native, null);
377
546
  }
378
547
  break;
379
548
  case "commit-in-grid":
380
549
  case "remove-source":
381
550
  case "revert":
382
- if (action === "commit-in-grid" && current) p2.onLayoutChange?.(commitLayout(current));
551
+ if (action === "commit-in-grid" && current) o.onLayoutChange?.(commitLayout(current));
383
552
  else if (action === "remove-source" && data) {
384
553
  const { compactor: c, cols } = ctx();
385
- p2.onLayoutChange?.(removeItemWithCompactor(p2.layout, data.itemId, {
554
+ o.onLayoutChange?.(removeItemWithCompactor(o.layout, data.itemId, {
386
555
  compactor: c,
387
556
  cols
388
557
  }));
389
558
  }
390
- p2.onDragStop?.(current?.preview ?? p2.layout, current?.anchor.item ?? null, current?.placeholder ?? null, current?.placeholder ?? null, native, null);
559
+ o.onDragStop?.(current?.preview ?? o.layout, current?.anchor.item ?? null, current?.placeholder ?? null, current?.placeholder ?? null, native, null);
391
560
  break;
392
561
  case "commit-dest":
393
- if (current) p2.onLayoutChange?.(commitLayout(current));
562
+ if (current) o.onLayoutChange?.(commitLayout(current));
394
563
  break;
395
564
  case "external-drop":
396
565
  if (current) {
397
566
  const committed = commitLayout(current);
398
567
  const dropped = committed.find((it) => it.i === current.activeId);
399
- if (dropped) p2.onDrop?.(committed, dropped, native);
568
+ if (dropped) o.onDrop?.(committed, dropped, native);
400
569
  }
401
570
  break;
402
571
  }
403
572
  dropSpecRef.current = null;
404
- keyboardRef.current = false;
573
+ setKeyboard(false);
405
574
  setSessionBoth(null);
406
- }, [setSessionBoth, ctx]);
575
+ }, [
576
+ setSessionBoth,
577
+ setKeyboard,
578
+ ctx
579
+ ]);
407
580
  useEffect(() => {
408
- const STEP = {
409
- ArrowLeft: [-1, 0],
410
- ArrowRight: [1, 0],
411
- ArrowUp: [0, -1],
412
- ArrowDown: [0, 1]
413
- };
414
581
  const onKeyDown = (e) => {
415
582
  if (!keyboardRef.current) return;
416
583
  const session = sessionRef.current;
417
584
  if (!session || session.kind !== "move") return;
418
- const step = STEP[e.key];
585
+ const step = arrowStep(e.key);
419
586
  if (!step) return;
420
587
  e.preventDefault();
421
588
  e.stopImmediatePropagation();
422
- const next = nudge(session, step[0], step[1], ctx());
423
- setSessionBoth(next);
424
- const cell = next.placeholder;
425
- const rect = containerElRef.current?.getBoundingClientRect();
426
- if (cell && rect) {
427
- const pos = calcGridItemPosition(ppRef.current, cell.x, cell.y, cell.w, cell.h);
428
- setOverlay((o) => o ? {
429
- ...o,
430
- left: rect.left + pos.left,
431
- top: rect.top + pos.top,
432
- width: pos.width,
433
- height: pos.height
434
- } : o);
435
- }
589
+ setSessionBoth(nudge(session, step[0], step[1], ctx()));
436
590
  };
437
591
  window.addEventListener("keydown", onKeyDown, true);
438
592
  return () => window.removeEventListener("keydown", onKeyDown, true);
@@ -446,12 +600,14 @@ function SnapGridRuntime(props) {
446
600
  handleDragMove,
447
601
  handleDragEnd
448
602
  ]));
449
- const dragThreshold = props.dragConfig?.threshold ?? 3;
450
- const itemSensors = useMemo(() => buildItemSensors(dragThreshold, () => propsRef.current.dragConfig), [dragThreshold]);
451
- const renderedLayout = session ? session.preview : props.layout;
452
- const itemsById = useMemo(() => new Map(renderedLayout.map((it) => [it.i, it])), [renderedLayout]);
453
- const gridDraggable = props.isDraggable ?? true;
454
- const dragEnabled = props.dragConfig?.enabled ?? true;
603
+ const dragThreshold = opts.dragConfig?.threshold ?? 3;
604
+ const itemSensors = useMemo(() => buildItemSensors(dragThreshold, () => optsRef.current.dragConfig), [dragThreshold]);
605
+ const itemModifiers = useMemo(() => [SnapToGrid.configure({
606
+ getPositionParams: () => ppRef.current,
607
+ isEnabled: () => optsRef.current.dragConfig?.snapToGrid ?? false
608
+ })], []);
609
+ const gridDraggable = opts.isDraggable ?? true;
610
+ const dragEnabled = opts.dragConfig?.enabled ?? true;
455
611
  const isItemDraggable = useCallback((id) => {
456
612
  const it = committedById.get(id);
457
613
  if (!it) return false;
@@ -461,8 +617,8 @@ function SnapGridRuntime(props) {
461
617
  gridDraggable,
462
618
  dragEnabled
463
619
  ]);
464
- const gridResizable = props.isResizable ?? true;
465
- const resizeEnabled = props.resizeConfig?.enabled ?? true;
620
+ const gridResizable = opts.isResizable ?? true;
621
+ const resizeEnabled = opts.resizeConfig?.enabled ?? true;
466
622
  const isItemResizable = useCallback((id) => {
467
623
  const it = committedById.get(id);
468
624
  if (!it) return false;
@@ -472,63 +628,25 @@ function SnapGridRuntime(props) {
472
628
  gridResizable,
473
629
  resizeEnabled
474
630
  ]);
475
- const defaultHandles = props.resizeConfig?.handles;
631
+ const defaultHandles = opts.resizeConfig?.handles;
476
632
  const resizeHandlesFor = useCallback((id) => committedById.get(id)?.resizeHandles ?? defaultHandles ?? DEFAULT_HANDLES, [committedById, defaultHandles]);
477
- const runtime = useMemo(() => ({
478
- containerId,
479
- width: props.width,
480
- autoSize: props.autoSize ?? true,
481
- gridConfig,
633
+ controller.setConfig({
482
634
  positionParams,
483
- renderedLayout,
484
- itemsById,
485
- session,
486
- isItemDraggable,
487
- isItemResizable,
488
- resizeHandlesFor,
489
- itemSensors,
490
- setContainerElement,
491
- overlay
492
- }), [
493
- containerId,
494
- props.width,
495
- props.autoSize,
496
635
  gridConfig,
497
- positionParams,
498
- renderedLayout,
499
- itemsById,
500
- session,
636
+ width: opts.width,
637
+ autoSize: opts.autoSize ?? true,
638
+ itemSensors,
639
+ itemModifiers,
501
640
  isItemDraggable,
502
641
  isItemResizable,
503
642
  resizeHandlesFor,
504
- itemSensors,
505
- setContainerElement,
506
- overlay
507
- ]);
508
- return /* @__PURE__ */ jsx(GridContext.Provider, {
509
- value: runtime,
510
- children: props.children
643
+ setContainerElement
511
644
  });
512
- }
513
- //#endregion
514
- //#region src/SnapGridGroup.tsx
515
- /**
516
- * Wrap multiple grids to let tiles be dragged **between** them. Provides one
517
- * shared dnd-kit `DragDropProvider` and a registry so each grid can tell which
518
- * grid the pointer is over.
519
- *
520
- * Item ids must be unique across all grids in a group (they share one manager).
521
- */
522
- function SnapGridGroup({ children }) {
523
- const registryRef = useRef(null);
524
- if (!registryRef.current) registryRef.current = createGridRegistry();
525
- return /* @__PURE__ */ jsx(DragDropProvider, { children: /* @__PURE__ */ jsx(SnapGridGroupContext.Provider, {
526
- value: registryRef.current,
527
- children
528
- }) });
645
+ return controller;
529
646
  }
530
647
  //#endregion
531
648
  //#region src/hooks/useGridContainer.ts
649
+ const GRID_COLLISION_PRIORITY = 10;
532
650
  /** Total container height in pixels for the given number of occupied rows. */
533
651
  function containerHeight(rows, grid) {
534
652
  const padY = (grid.containerPadding ?? grid.margin)[1];
@@ -536,81 +654,166 @@ function containerHeight(rows, grid) {
536
654
  return padY * 2 + rows * grid.rowHeight + (rows - 1) * grid.margin[1];
537
655
  }
538
656
  /**
539
- * Headless hook for the grid container. Registers the droppable surface (the
540
- * seam for cross-grid drops) and returns props (ref + sizing style) to spread
541
- * onto your own container element.
657
+ * The grid host: creates this grid's controller + drag monitor (see
658
+ * {@link useGridController}), registers the droppable surface, and returns props
659
+ * to spread onto your own container element. Render `useGridItem` tiles inside,
660
+ * passing `group` (this grid's id) so they resolve this controller.
542
661
  */
543
- function useGridContainer() {
544
- const rt = useGridRuntime();
662
+ function useGridContainer(opts) {
663
+ const controller = useGridController(opts);
664
+ const { width, autoSize, gridConfig, setContainerElement } = controller.config;
545
665
  const { ref, isDropTarget } = useDroppable$1({
546
- id: rt.containerId,
666
+ id: controller.id,
547
667
  type: "grid",
548
- accept: "grid-item"
668
+ accept: (source) => {
669
+ if (source.type === "grid-item") return true;
670
+ return source.data?.snapGridDrop != null;
671
+ },
672
+ collisionPriority: GRID_COLLISION_PRIORITY
549
673
  });
550
- const setContainerElement = rt.setContainerElement;
551
674
  const setRef = useCallback((element) => {
552
675
  ref(element);
553
676
  setContainerElement(element);
554
677
  }, [ref, setContainerElement]);
555
- const height = rt.autoSize ? containerHeight(bottom(rt.renderedLayout), rt.gridConfig) : void 0;
678
+ const renderedLayout = useSyncExternalStore(controller.subscribe, controller.renderedSnapshot, controller.renderedSnapshot);
556
679
  return {
557
680
  containerProps: {
558
681
  ref: setRef,
559
682
  style: {
560
683
  position: "relative",
561
- width: rt.width,
562
- height
684
+ width,
685
+ height: autoSize ? containerHeight(bottom(renderedLayout), gridConfig) : void 0
563
686
  },
564
687
  "data-drop-target": isDropTarget || void 0
565
688
  },
566
- isDropTarget
689
+ isDropTarget,
690
+ group: controller.id,
691
+ controller
567
692
  };
568
693
  }
694
+ const REFLOW_EASING = "ease";
695
+ const REFLOW_TRANSITION = `transform 150ms ${REFLOW_EASING}, width 150ms ${REFLOW_EASING}, height 150ms ${REFLOW_EASING}`;
696
+ //#endregion
697
+ //#region src/hooks/useResolveController.ts
698
+ /**
699
+ * Resolve a grid's controller by its `group` (= the grid's id), scoped to the
700
+ * ambient dnd-kit manager. Items declare `group` (mirroring useSortable); the
701
+ * container registered the controller under that id. Throws a helpful error if
702
+ * unresolved — almost always a missing `group` or a tile rendered outside any
703
+ * grid / `DragDropProvider`.
704
+ */
705
+ function useResolveController(group) {
706
+ const controller = getController(useDragDropManager(), group);
707
+ if (!controller) throw new Error(`snapgrid: no grid found for group "${group}". A grid item must pass the group returned by its grid's useGridContainer, and render inside a <DragDropProvider> (or use <GridLayout>, which wires this for you).`);
708
+ return controller;
709
+ }
569
710
  //#endregion
570
711
  //#region src/hooks/useGridItem.ts
571
- const REFLOW_TRANSITION = "transform 150ms ease, width 150ms ease, height 150ms ease";
712
+ const ITEM_FEEDBACK = [Feedback$1.configure({
713
+ feedback: (_source, manager) => isKeyboardEvent(manager.dragOperation.activatorEvent) ? "none" : "default",
714
+ dropAnimation: null
715
+ })];
572
716
  /**
573
- * Headless hook for a single grid item. Returns a ref, a positioning `style`,
574
- * and drag state spread them onto whatever element you render. You own the
575
- * tag, className, content, and any cosmetic styling.
717
+ * Headless hook for a single grid tile. The tile is a real `useSortable` (a
718
+ * draggable + droppable carrying `group`/`index`/`type`/`accept`), so it
719
+ * interoperates with the dnd-kit sortable ecosystem, yet it is positioned by RGL
720
+ * via the {@link GridController}. `group` is the owning grid's id (from its
721
+ * {@link useGridContainer}), mirroring `useSortable`'s `group`. Spread the returned
722
+ * `ref`, optional `handleRef`, positioning `style`, and drag state onto whatever
723
+ * element you render — you own the tag, className, content, and cosmetic styling.
724
+ *
725
+ * The dragged tile floats itself via dnd-kit's default feedback (no `<DragOverlay>`):
726
+ * the active tile renders at its committed origin so the float offset composes, and
727
+ * reflow is animated on the compositor via the Web Animations API — both so it stays
728
+ * smooth in Safari, where the float's popover top-layer repaint would jank a
729
+ * CSS-transition reflow.
576
730
  */
577
- function useGridItem(id) {
578
- const rt = useGridRuntime();
579
- const item = rt.itemsById.get(id);
580
- const { ref, isDragging } = useDraggable$1({
731
+ function useGridItem(id, group) {
732
+ const controller = useResolveController(group);
733
+ const snap = useSyncExternalStore(controller.subscribe, () => controller.itemSnapshot(id), () => controller.itemSnapshot(id));
734
+ const item = snap.item;
735
+ const active = snap.isDragging;
736
+ const hidden = snap.hidden;
737
+ const config = controller.config;
738
+ const wasActive = useRef(false);
739
+ const justDropped = wasActive.current && !active;
740
+ wasActive.current = active;
741
+ const data = useMemo(() => ({ snapGrid: {
742
+ kind: "move",
743
+ itemId: id,
744
+ item
745
+ } }), [id, item]);
746
+ const { ref: sortableRef, handleRef, isDragging } = useSortable({
581
747
  id,
748
+ index: controller.itemIndex(id),
749
+ group,
582
750
  type: "grid-item",
583
- disabled: !rt.isItemDraggable(id),
584
- sensors: rt.itemSensors,
585
- plugins: NO_FEEDBACK,
586
- data: { snapGrid: {
587
- kind: "move",
588
- itemId: id,
589
- item
590
- } }
751
+ accept: "grid-item",
752
+ disabled: !config.isItemDraggable(id),
753
+ sensors: config.itemSensors,
754
+ modifiers: config.itemModifiers,
755
+ plugins: (defaults) => [...defaults, ...ITEM_FEEDBACK],
756
+ data
591
757
  });
592
- const active = rt.session?.activeId === id;
593
- let style = {
594
- position: "absolute",
595
- touchAction: "none"
596
- };
597
- if (item) {
598
- const pos = calcGridItemPosition(rt.positionParams, item.x, item.y, item.w, item.h);
599
- style = {
758
+ const elRef = useRef(null);
759
+ const setRef = useCallback((element) => {
760
+ sortableRef(element);
761
+ elRef.current = element;
762
+ }, [sortableRef]);
763
+ const session = controller.getSession();
764
+ const dragging = session != null;
765
+ const posItem = item ? active && hidden ? session?.anchor.item ?? item : item : void 0;
766
+ const pos = posItem ? calcGridItemPosition(config.positionParams, posItem.x, posItem.y, posItem.w, posItem.h) : void 0;
767
+ const posLeft = pos?.left;
768
+ const posTop = pos?.top;
769
+ const prev = useRef(null);
770
+ const reflowAnim = useRef(null);
771
+ useLayoutEffect(() => {
772
+ const cur = posLeft != null && posTop != null ? {
773
+ left: posLeft,
774
+ top: posTop
775
+ } : null;
776
+ const before = prev.current;
777
+ prev.current = cur;
778
+ const el = elRef.current;
779
+ if (!el || !cur || !before || active || justDropped || !dragging) return;
780
+ if (before.left === cur.left && before.top === cur.top) return;
781
+ let fromX = before.left;
782
+ let fromY = before.top;
783
+ if (reflowAnim.current?.playState === "running") {
784
+ const m = new DOMMatrix(getComputedStyle(el).transform);
785
+ fromX = m.m41;
786
+ fromY = m.m42;
787
+ }
788
+ reflowAnim.current?.cancel();
789
+ reflowAnim.current = el.animate([{ transform: `translate(${fromX}px, ${fromY}px)` }, { transform: `translate(${cur.left}px, ${cur.top}px)` }], {
790
+ duration: 150,
791
+ easing: REFLOW_EASING
792
+ });
793
+ }, [
794
+ posLeft,
795
+ posTop,
796
+ active,
797
+ justDropped,
798
+ dragging
799
+ ]);
800
+ useEffect(() => () => reflowAnim.current?.cancel(), []);
801
+ return {
802
+ ref: setRef,
803
+ handleRef,
804
+ style: pos ? {
600
805
  position: "absolute",
601
806
  left: 0,
602
807
  top: 0,
603
808
  width: pos.width,
604
809
  height: pos.height,
605
810
  transform: `translate(${pos.left}px, ${pos.top}px)`,
606
- visibility: active ? "hidden" : void 0,
607
- transition: active ? "none" : REFLOW_TRANSITION,
811
+ transition: active || justDropped || dragging ? "none" : REFLOW_TRANSITION,
608
812
  touchAction: "none"
609
- };
610
- }
611
- return {
612
- ref,
613
- style,
813
+ } : {
814
+ position: "absolute",
815
+ touchAction: "none"
816
+ },
614
817
  isDragging,
615
818
  item
616
819
  };
@@ -619,13 +822,14 @@ function useGridItem(id) {
619
822
  //#region src/hooks/useGridPlaceholder.ts
620
823
  /**
621
824
  * Headless hook returning where the drag placeholder should be rendered, or
622
- * `null` when no drag is in progress. You render the element however you like.
825
+ * `null` when no drag is in progress. `group` is the owning grid's id (from its
826
+ * {@link useGridContainer}). You render the element however you like.
623
827
  */
624
- function useGridPlaceholder() {
625
- const rt = useGridRuntime();
626
- const placeholder = rt.session?.placeholder;
828
+ function useGridPlaceholder(group) {
829
+ const controller = useResolveController(group);
830
+ const placeholder = useSyncExternalStore(controller.subscribe, controller.placeholderSnapshot, controller.placeholderSnapshot);
627
831
  if (!placeholder) return null;
628
- const pos = calcGridItemPosition(rt.positionParams, placeholder.x, placeholder.y, placeholder.w, placeholder.h);
832
+ const pos = calcGridItemPosition(controller.config.positionParams, placeholder.x, placeholder.y, placeholder.w, placeholder.h);
629
833
  return {
630
834
  item: placeholder,
631
835
  style: {
@@ -643,14 +847,15 @@ function useGridPlaceholder() {
643
847
  //#region src/hooks/useGridResizeHandle.ts
644
848
  /**
645
849
  * Headless hook for a single resize handle. Model a handle as its own draggable;
646
- * dragging it resizes the item from the given edge/corner. Position and style
647
- * the handle however you like spread `ref` and `handleProps` onto it.
850
+ * dragging it resizes the item from the given edge/corner. `group` is the owning
851
+ * grid's id (from its {@link useGridContainer}). Spread `ref` and `handleProps`
852
+ * onto the handle element you position/style.
648
853
  */
649
- function useGridResizeHandle(itemId, handle) {
650
- const rt = useGridRuntime();
854
+ function useGridResizeHandle(itemId, handle, group) {
855
+ const controller = useResolveController(group);
651
856
  const { ref } = useDraggable$1({
652
857
  id: `${itemId}::resize::${handle}`,
653
- disabled: !rt.isItemResizable(itemId),
858
+ disabled: !controller.config?.isItemResizable(itemId),
654
859
  plugins: NO_FEEDBACK,
655
860
  data: { snapGrid: {
656
861
  kind: "resize",
@@ -658,7 +863,7 @@ function useGridResizeHandle(itemId, handle) {
658
863
  handle
659
864
  } }
660
865
  });
661
- const isResizing = rt.session?.kind === "resize" && rt.session.activeId === itemId;
866
+ const { isResizing } = useSyncExternalStore(controller.subscribe, () => controller.resizeSnapshot(itemId), () => controller.resizeSnapshot(itemId));
662
867
  return {
663
868
  ref,
664
869
  handleProps: { [RESIZE_HANDLE_ATTR]: true },
@@ -666,30 +871,6 @@ function useGridResizeHandle(itemId, handle) {
666
871
  };
667
872
  }
668
873
  //#endregion
669
- //#region src/hooks/useGridDragOverlay.ts
670
- /**
671
- * Headless hook for the floating drag preview. Returns `null` unless this grid
672
- * is the source of an in-progress drag. Render the returned `item` with `style`
673
- * in a portal at `document.body` so it can float across grids unclipped (see
674
- * {@link GridDragOverlay} for the convenience component).
675
- */
676
- function useGridDragOverlay() {
677
- const o = useGridRuntime().overlay;
678
- if (!o) return null;
679
- return {
680
- item: o.item,
681
- style: {
682
- position: "fixed",
683
- left: o.left,
684
- top: o.top,
685
- width: o.width,
686
- height: o.height,
687
- pointerEvents: "none",
688
- zIndex: 1e3
689
- }
690
- };
691
- }
692
- //#endregion
693
874
  //#region src/hooks/useResponsiveLayout.ts
694
875
  /** react-grid-layout's default breakpoints (px) and column counts. */
695
876
  const DEFAULT_BREAKPOINTS = {
@@ -756,26 +937,7 @@ function useResponsiveLayout(options) {
756
937
  };
757
938
  }
758
939
  //#endregion
759
- //#region src/GridDragOverlay.tsx
760
- /**
761
- * Renders the floating drag preview in a portal at `document.body` — so it
762
- * follows the pointer across grids without being clipped by any container.
763
- * Renders nothing when this grid isn't the drag source.
764
- */
765
- function GridDragOverlay({ children, className, style }) {
766
- const overlay = useGridDragOverlay();
767
- if (typeof document === "undefined" || !overlay) return null;
768
- return createPortal(/* @__PURE__ */ jsx("div", {
769
- className: className ? `snapgrid-overlay ${className}` : "snapgrid-overlay",
770
- style: style ? {
771
- ...overlay.style,
772
- ...style
773
- } : overlay.style,
774
- children: children(overlay.item)
775
- }), document.body);
776
- }
777
- //#endregion
778
- //#region src/GridItem.tsx
940
+ //#region src/components/GridItem.tsx
779
941
  const HANDLE_CURSOR = {
780
942
  n: "ns-resize",
781
943
  s: "ns-resize",
@@ -804,8 +966,8 @@ function handleStyle(handle) {
804
966
  if (handle === "e" || handle === "w") s.top = `calc(50% - ${SIDE / 2}px)`;
805
967
  return s;
806
968
  }
807
- function DefaultResizeHandle({ itemId, handle }) {
808
- const { ref, handleProps } = useGridResizeHandle(itemId, handle);
969
+ function DefaultResizeHandle({ itemId, handle, group }) {
970
+ const { ref, handleProps } = useGridResizeHandle(itemId, handle, group);
809
971
  return /* @__PURE__ */ jsx("span", {
810
972
  ref,
811
973
  ...handleProps,
@@ -816,12 +978,19 @@ function DefaultResizeHandle({ itemId, handle }) {
816
978
  /**
817
979
  * Convenience wrapper over {@link useGridItem}: an absolutely-positioned `<div>`
818
980
  * with stable hooks (`.snapgrid-item`, `data-grid-id`, `data-dragging`) and the
819
- * configured resize handles. For full control, use the hooks directly.
981
+ * configured resize handles. `group` is the owning grid's id. For full control,
982
+ * use the hooks directly.
983
+ *
984
+ * Memoized so re-rendering the surface (e.g. its auto-height tracking the drag)
985
+ * doesn't re-render every tile — a tile re-renders only when its own slice
986
+ * changes (via useGridItem's subscription). Keeps a drag's React work scoped to
987
+ * the moved tile (see renderScope.test).
820
988
  */
821
- function GridItem({ id, children, className, style }) {
822
- const rt = useGridRuntime();
823
- const { ref, style: positionStyle, isDragging } = useGridItem(id);
824
- const handles = rt.isItemResizable(id) ? rt.resizeHandlesFor(id) : [];
989
+ function GridItemImpl({ id, group, children, className, style }) {
990
+ const controller = useResolveController(group);
991
+ const { ref, style: positionStyle, isDragging } = useGridItem(id, group);
992
+ const config = controller.config;
993
+ const handles = config?.isItemResizable(id) ? config.resizeHandlesFor(id) : [];
825
994
  return /* @__PURE__ */ jsxs("div", {
826
995
  ref,
827
996
  "data-grid-id": id,
@@ -833,27 +1002,29 @@ function GridItem({ id, children, className, style }) {
833
1002
  } : positionStyle,
834
1003
  children: [children, handles.map((handle) => /* @__PURE__ */ jsx(DefaultResizeHandle, {
835
1004
  itemId: id,
836
- handle
1005
+ handle,
1006
+ group
837
1007
  }, handle))]
838
1008
  });
839
1009
  }
1010
+ const GridItem = memo(GridItemImpl);
840
1011
  //#endregion
841
- //#region src/GridPlaceholder.tsx
1012
+ //#region src/components/GridPlaceholder.tsx
842
1013
  const DEFAULT_LOOK = {
843
1014
  background: "rgba(99, 102, 241, 0.2)",
844
1015
  border: "1px dashed rgba(99, 102, 241, 0.6)",
845
1016
  borderRadius: 4,
846
1017
  boxSizing: "border-box",
847
1018
  zIndex: 2,
848
- transition: "transform 150ms ease, width 150ms ease, height 150ms ease"
1019
+ transition: REFLOW_TRANSITION
849
1020
  };
850
1021
  /**
851
1022
  * Convenience placeholder rendered from {@link useGridPlaceholder}. Renders
852
1023
  * nothing when no drag is active. For a custom placeholder, call the hook
853
1024
  * directly and render your own element with the returned `style`.
854
1025
  */
855
- function GridPlaceholder({ className, style }) {
856
- const placeholder = useGridPlaceholder();
1026
+ function GridPlaceholder({ group, className, style }) {
1027
+ const placeholder = useGridPlaceholder(group);
857
1028
  if (!placeholder) return null;
858
1029
  return /* @__PURE__ */ jsx("div", {
859
1030
  "aria-hidden": "true",
@@ -866,18 +1037,15 @@ function GridPlaceholder({ className, style }) {
866
1037
  });
867
1038
  }
868
1039
  //#endregion
869
- //#region src/GridLayout.tsx
1040
+ //#region src/components/GridLayout.tsx
1041
+ const InProvider = createContext(false);
870
1042
  /** Strip the namespacing prefix React applies to keys inside `Children.map`. */
871
1043
  function keyToId(key) {
872
1044
  return key.startsWith(".$") ? key.slice(2) : key;
873
1045
  }
874
1046
  /** The default surface: positioned container + mapped items + placeholder. */
875
- function GridSurface({ className, style, children }) {
876
- const { containerProps } = useGridContainer();
877
- const childById = /* @__PURE__ */ new Map();
878
- Children.forEach(children, (child) => {
879
- if (isValidElement(child) && child.key != null) childById.set(keyToId(String(child.key)), child);
880
- });
1047
+ function GridSurface({ className, style, children, ...opts }) {
1048
+ const { containerProps, group } = useGridContainer(opts);
881
1049
  return /* @__PURE__ */ jsxs("div", {
882
1050
  ...containerProps,
883
1051
  className: className ? `snapgrid ${className}` : "snapgrid",
@@ -885,38 +1053,50 @@ function GridSurface({ className, style, children }) {
885
1053
  ...containerProps.style,
886
1054
  ...style
887
1055
  } : containerProps.style,
888
- children: [
889
- Children.map(children, (child) => {
890
- if (!isValidElement(child) || child.key == null) return child;
891
- return /* @__PURE__ */ jsx(GridItem, {
892
- id: keyToId(String(child.key)),
893
- children: child
894
- }, child.key);
895
- }),
896
- /* @__PURE__ */ jsx(GridPlaceholder, {}),
897
- /* @__PURE__ */ jsx(GridDragOverlay, { children: (item) => childById.get(item.i) ?? null })
898
- ]
1056
+ children: [Children.map(children, (child) => {
1057
+ if (!isValidElement(child) || child.key == null) return child;
1058
+ return /* @__PURE__ */ jsx(GridItem, {
1059
+ id: keyToId(String(child.key)),
1060
+ group,
1061
+ children: child
1062
+ }, child.key);
1063
+ }), /* @__PURE__ */ jsx(GridPlaceholder, { group })]
899
1064
  });
900
1065
  }
901
1066
  /**
902
1067
  * Drop-in grid component: a controlled, react-grid-layout v2-compatible layout
903
- * backed by dnd-kit. A thin shell over {@link SnapGridProvider} and the headless
1068
+ * backed by dnd-kit. A thin shell over {@link useGridContainer} and the headless
904
1069
  * hooks — children are keyed by their layout item's `i`. For full control over
905
- * markup/styling, use the provider + hooks directly.
1070
+ * markup/styling, use the hooks directly.
1071
+ *
1072
+ * Supplies the dnd-kit `DragDropProvider` for the turnkey case so consumers
1073
+ * don't manage one. Nest multiple `GridLayout`s and they share one provider
1074
+ * (the seam for cross-grid drags); a consumer's own provider is also honored.
906
1075
  */
907
1076
  function GridLayout(props) {
908
- const { className, style, children, ...providerProps } = props;
909
- return /* @__PURE__ */ jsx(SnapGridProvider, {
910
- ...providerProps,
911
- children: /* @__PURE__ */ jsx(GridSurface, {
912
- className,
913
- style,
914
- children
915
- })
916
- });
1077
+ const inProvider = useContext(InProvider);
1078
+ const surface = /* @__PURE__ */ jsx(GridSurface, { ...props });
1079
+ if (inProvider) return surface;
1080
+ return /* @__PURE__ */ jsx(DragDropProvider, { children: /* @__PURE__ */ jsx(InProvider.Provider, {
1081
+ value: true,
1082
+ children: surface
1083
+ }) });
1084
+ }
1085
+ /**
1086
+ * Share one dnd-kit `DragDropProvider` across several sibling grids so tiles can
1087
+ * be dragged between them. (Nested `GridLayout`s already share a provider; this
1088
+ * is for siblings.) A thin wrapper over `DragDropProvider` — the cross-grid seam
1089
+ * is the shared manager + collision target.
1090
+ */
1091
+ function SnapGridGroup({ children }) {
1092
+ if (useContext(InProvider)) return /* @__PURE__ */ jsx(Fragment, { children });
1093
+ return /* @__PURE__ */ jsx(DragDropProvider, { children: /* @__PURE__ */ jsx(InProvider.Provider, {
1094
+ value: true,
1095
+ children
1096
+ }) });
917
1097
  }
918
1098
  //#endregion
919
- //#region src/ResponsiveGridLayout.tsx
1099
+ //#region src/components/ResponsiveGridLayout.tsx
920
1100
  /**
921
1101
  * A responsive grid: switches column count and layout by breakpoint as `width`
922
1102
  * changes, generating a breakpoint's layout from the nearest one when absent.
@@ -994,6 +1174,6 @@ function useContainerWidth(options = {}) {
994
1174
  };
995
1175
  }
996
1176
  //#endregion
997
- export { DEFAULT_BREAKPOINTS, DEFAULT_BREAKPOINT_COLS, Feedback, GridDragOverlay, GridItem, GridLayout, GridPlaceholder, KeyboardSensor, PointerSensor, ResponsiveGridLayout, SnapGridGroup, SnapGridProvider, getCompactor, horizontalCompactor, noCompactor, useContainerWidth, useDraggable, useDroppable, useGridContainer, useGridDragOverlay, useGridItem, useGridPlaceholder, useGridResizeHandle, useResponsiveLayout, verticalCompactor };
1177
+ export { DEFAULT_BREAKPOINTS, DEFAULT_BREAKPOINT_COLS, DragOverlay, Feedback, GridItem, GridLayout, GridPlaceholder, KeyboardSensor, PointerSensor, ResponsiveGridLayout, SnapGridGroup, getCompactor, horizontalCompactor, noCompactor, useContainerWidth, useDraggable, useDroppable, useGridContainer, useGridItem, useGridPlaceholder, useGridResizeHandle, useResponsiveLayout, verticalCompactor };
998
1178
 
999
1179
  //# sourceMappingURL=index.mjs.map