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