@snapgridjs/react 0.1.0 → 0.3.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
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
+ };
16
145
  //#endregion
17
- //#region src/dragFlow.ts
146
+ //#region src/controller/registry.ts
18
147
  /**
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.
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).
22
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
+ };
196
+ }
197
+ //#endregion
198
+ //#region src/dnd/dragFlow.ts
199
+ /**
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.
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,33 @@ 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
- };
325
+ function itemGateOpen(flag, isStatic) {
326
+ return isStatic ? flag === true : flag ?? true;
130
327
  }
131
328
  /**
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.
329
+ * The grid's brain: owns the {@link GridController}, runs the dnd-kit drag/resize
330
+ * monitor for this grid, and writes per-grid config to the controller each render.
331
+ * Created by {@link useGridContainer}; items resolve the same controller by their
332
+ * `group` (= this grid's id) from the per-manager registry. Consumes the ambient
333
+ * `DragDropProvider` — it does not mint one.
136
334
  */
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) {
335
+ function useGridController(opts) {
146
336
  const autoId = (0, react.useId)();
147
- const containerId = props.id ?? autoId;
337
+ const containerId = opts.id ?? autoId;
148
338
  const gridConfig = (0, react.useMemo)(() => ({
149
339
  ..._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;
340
+ ...opts.gridConfig
341
+ }), [opts.gridConfig]);
342
+ const positionParams = (0, react.useMemo)(() => (0, _snapgridjs_core.toPositionParams)(gridConfig, opts.width), [gridConfig, opts.width]);
343
+ const compactor = opts.compactor ?? _snapgridjs_core.verticalCompactor;
344
+ const manager = (0, _dnd_kit_react.useDragDropManager)();
345
+ const controller = (0, _dnd_kit_react.useInstance)((m) => new GridController(containerId, opts.layout, m ?? void 0));
346
+ controller.setCommitted(opts.layout);
347
+ if (controller.id !== containerId) controller.setId(containerId);
348
+ const optsRef = (0, react.useRef)(opts);
349
+ optsRef.current = opts;
158
350
  const ppRef = (0, react.useRef)(positionParams);
159
351
  ppRef.current = positionParams;
160
352
  const gridRef = (0, react.useRef)(gridConfig);
@@ -163,39 +355,44 @@ function SnapGridRuntime(props) {
163
355
  compactorRef.current = compactor;
164
356
  const containerIdRef = (0, react.useRef)(containerId);
165
357
  containerIdRef.current = containerId;
358
+ const managerRef = (0, react.useRef)(manager);
359
+ managerRef.current = manager;
166
360
  const sessionRef = (0, react.useRef)(null);
167
361
  const containerElRef = (0, react.useRef)(null);
168
362
  const keyboardRef = (0, react.useRef)(false);
169
363
  const dropSpecRef = (0, react.useRef)(null);
170
364
  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]);
365
+ registerController(manager, containerId, controller);
366
+ (0, react.useEffect)(() => registerController(manager, containerId, controller), [
367
+ manager,
368
+ containerId,
369
+ controller
370
+ ]);
371
+ const committedById = (0, react.useMemo)(() => new Map(opts.layout.map((it) => [it.i, it])), [opts.layout]);
178
372
  const committedByIdRef = (0, react.useRef)(committedById);
179
373
  committedByIdRef.current = committedById;
180
374
  const setSessionBoth = (0, react.useCallback)((next) => {
181
375
  sessionRef.current = next;
182
- setSession(next);
183
- }, []);
376
+ controller.setSession(next);
377
+ }, [controller]);
378
+ const setKeyboard = (0, react.useCallback)((value) => {
379
+ keyboardRef.current = value;
380
+ controller.setKeyboard(value);
381
+ }, [controller]);
184
382
  const setContainerElement = (0, react.useCallback)((element) => {
185
383
  containerElRef.current = element;
186
384
  }, []);
187
385
  /**
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).
386
+ * Is THIS grid the drop target dnd-kit's collision observer resolved? Both the
387
+ * move-phase preview and the drop-phase commit read `operation.target`, so they
388
+ * always agree on which grid wins (one oracle), including when grids overlap.
192
389
  */
193
- const overMe = (0, react.useCallback)((p) => registryRef.current.gridAt(p) === containerIdRef.current, []);
390
+ const overMe = (0, react.useCallback)((target) => target?.id === containerIdRef.current, []);
194
391
  /** Map a client-space pointer to a grid cell within THIS grid (see {@link receiveCell}). */
195
392
  const cellFromPointer = (0, react.useCallback)((p, item) => {
196
393
  const el = containerElRef.current;
197
394
  if (!el) return null;
198
- return receiveCell(p, el.getBoundingClientRect(), registryRef.current.getGrabOffset(), item.w, item.h, ppRef.current);
395
+ return receiveCell(p, el.getBoundingClientRect(), getGrabOffset(managerRef.current), item.w, item.h, ppRef.current);
199
396
  }, []);
200
397
  const ctx = (0, react.useCallback)(() => ({
201
398
  positionParams: ppRef.current,
@@ -203,10 +400,10 @@ function SnapGridRuntime(props) {
203
400
  cols: gridRef.current.cols
204
401
  }), []);
205
402
  const handleDragStart = (0, react.useCallback)((event) => {
206
- keyboardRef.current = false;
403
+ setKeyboard(false);
207
404
  const data = dragData(event);
208
405
  if (!data) {
209
- const spec = externalDropSpec(event.operation.source, propsRef.current.dropConfig);
406
+ const spec = externalDropSpec(event.operation.source, optsRef.current.dropConfig);
210
407
  if (spec) {
211
408
  dropCounterRef.current += 1;
212
409
  dropSpecRef.current = {
@@ -218,7 +415,7 @@ function SnapGridRuntime(props) {
218
415
  return;
219
416
  }
220
417
  dropSpecRef.current = null;
221
- const layout = propsRef.current.layout;
418
+ const layout = optsRef.current.layout;
222
419
  const item = layout.find((it) => it.i === data.itemId);
223
420
  const p = event.operation.position.current;
224
421
  const pointer = {
@@ -232,11 +429,11 @@ function SnapGridRuntime(props) {
232
429
  rect: (0, _snapgridjs_core.calcGridItemPosition)(ppRef.current, item.x, item.y, item.w, item.h),
233
430
  pointer
234
431
  }, data.handle));
235
- propsRef.current.onResizeStart?.(layout, item, item, item, event.operation.activatorEvent, null);
432
+ optsRef.current.onResizeStart?.(layout, item, item, item, event.operation.activatorEvent, null);
236
433
  return;
237
434
  }
238
435
  if (item) {
239
- keyboardRef.current = event.operation.activatorEvent instanceof KeyboardEvent;
436
+ setKeyboard(event.operation.activatorEvent instanceof KeyboardEvent);
240
437
  const rect = (0, _snapgridjs_core.calcGridItemPosition)(ppRef.current, item.x, item.y, item.w, item.h);
241
438
  setSessionBoth((0, _snapgridjs_core.beginDrag)(layout, {
242
439
  item,
@@ -245,22 +442,13 @@ function SnapGridRuntime(props) {
245
442
  pointer
246
443
  }));
247
444
  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);
445
+ if (cr) setGrabOffset(managerRef.current, {
446
+ x: pointer.x - cr.left,
447
+ y: pointer.y - cr.top
448
+ });
449
+ optsRef.current.onDragStart?.(layout, item, item, item, event.operation.activatorEvent, null);
262
450
  }
263
- }, [setSessionBoth]);
451
+ }, [setSessionBoth, setKeyboard]);
264
452
  const handleDragMove = (0, react.useCallback)((event) => {
265
453
  if (keyboardRef.current) return;
266
454
  const p = event.operation.position.current;
@@ -272,13 +460,14 @@ function SnapGridRuntime(props) {
272
460
  if (current?.kind === "resize") {
273
461
  const next = (0, _snapgridjs_core.dragResize)(current, pointer, ctx());
274
462
  setSessionBoth(next);
275
- propsRef.current.onResize?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
463
+ optsRef.current.onResize?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
276
464
  return;
277
465
  }
466
+ const target = event.operation.target;
278
467
  const data = dragData(event);
279
468
  if (!data) {
280
469
  const spec = dropSpecRef.current;
281
- if (spec && overMe(pointer)) {
470
+ if (spec && overMe(target)) {
282
471
  const foreign = {
283
472
  i: spec.i,
284
473
  x: 0,
@@ -286,7 +475,7 @@ function SnapGridRuntime(props) {
286
475
  w: spec.w,
287
476
  h: spec.h
288
477
  };
289
- const committed = propsRef.current.layout;
478
+ const committed = optsRef.current.layout;
290
479
  const cell = cellFromPointer(pointer, foreign) ?? {
291
480
  x: 0,
292
481
  y: 0
@@ -296,7 +485,7 @@ function SnapGridRuntime(props) {
296
485
  return;
297
486
  }
298
487
  if (data.kind !== "move") return;
299
- const here = overMe(pointer);
488
+ const here = overMe(target);
300
489
  if (committedByIdRef.current.has(data.itemId)) {
301
490
  const source = current?.kind === "move" ? current : null;
302
491
  if (!source) return;
@@ -308,28 +497,12 @@ function SnapGridRuntime(props) {
308
497
  placeholder: null
309
498
  };
310
499
  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);
500
+ optsRef.current.onDrag?.(next.preview, next.anchor.item, next.placeholder, next.placeholder, event.operation.activatorEvent, null);
328
501
  return;
329
502
  }
330
503
  if (here) {
331
504
  const foreign = data.item;
332
- const committed = propsRef.current.layout;
505
+ const committed = optsRef.current.layout;
333
506
  const cell = cellFromPointer(pointer, foreign) ?? {
334
507
  x: 0,
335
508
  y: 0
@@ -345,17 +518,16 @@ function SnapGridRuntime(props) {
345
518
  const handleDragEnd = (0, react.useCallback)((event) => {
346
519
  const current = sessionRef.current;
347
520
  const data = dragData(event);
348
- const p = event.operation.position.current;
349
521
  const myId = containerIdRef.current;
350
- const dest = keyboardRef.current ? myId : registryRef.current.gridAt({
351
- x: p.x,
352
- y: p.y
522
+ const dest = dropDestination({
523
+ keyboard: keyboardRef.current,
524
+ targetId: event.operation.target?.id,
525
+ myId
353
526
  });
354
527
  const ownsItem = data ? committedByIdRef.current.has(data.itemId) : false;
355
- setOverlay(null);
356
- registryRef.current.setGrabOffset(null);
528
+ setGrabOffset(managerRef.current, null);
357
529
  const native = event.nativeEvent ?? null;
358
- const p2 = propsRef.current;
530
+ const o = optsRef.current;
359
531
  const action = classifyDrop({
360
532
  kind: current?.kind ?? null,
361
533
  canceled: event.canceled,
@@ -366,74 +538,59 @@ function SnapGridRuntime(props) {
366
538
  });
367
539
  switch (action) {
368
540
  case "cancel-resize":
369
- p2.onResizeStop?.(p2.layout, current?.anchor.item ?? null, null, null, native, null);
541
+ o.onResizeStop?.(o.layout, current?.anchor.item ?? null, null, null, native, null);
370
542
  break;
371
543
  case "cancel-move":
372
- p2.onDragStop?.(p2.layout, current?.anchor.item ?? null, null, null, native, null);
544
+ o.onDragStop?.(o.layout, current?.anchor.item ?? null, null, null, native, null);
373
545
  break;
374
546
  case "commit-resize":
375
547
  if (current) {
376
- p2.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
377
- p2.onResizeStop?.(current.preview, current.anchor.item, current.placeholder, current.placeholder, native, null);
548
+ o.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
549
+ o.onResizeStop?.(current.preview, current.anchor.item, current.placeholder, current.placeholder, native, null);
378
550
  }
379
551
  break;
380
552
  case "commit-in-grid":
381
553
  case "remove-source":
382
554
  case "revert":
383
- if (action === "commit-in-grid" && current) p2.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
555
+ if (action === "commit-in-grid" && current) o.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
384
556
  else if (action === "remove-source" && data) {
385
557
  const { compactor: c, cols } = ctx();
386
- p2.onLayoutChange?.((0, _snapgridjs_core.removeItemWithCompactor)(p2.layout, data.itemId, {
558
+ o.onLayoutChange?.((0, _snapgridjs_core.removeItemWithCompactor)(o.layout, data.itemId, {
387
559
  compactor: c,
388
560
  cols
389
561
  }));
390
562
  }
391
- p2.onDragStop?.(current?.preview ?? p2.layout, current?.anchor.item ?? null, current?.placeholder ?? null, current?.placeholder ?? null, native, null);
563
+ o.onDragStop?.(current?.preview ?? o.layout, current?.anchor.item ?? null, current?.placeholder ?? null, current?.placeholder ?? null, native, null);
392
564
  break;
393
565
  case "commit-dest":
394
- if (current) p2.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
566
+ if (current) o.onLayoutChange?.((0, _snapgridjs_core.commitLayout)(current));
395
567
  break;
396
568
  case "external-drop":
397
569
  if (current) {
398
570
  const committed = (0, _snapgridjs_core.commitLayout)(current);
399
571
  const dropped = committed.find((it) => it.i === current.activeId);
400
- if (dropped) p2.onDrop?.(committed, dropped, native);
572
+ if (dropped) o.onDrop?.(committed, dropped, native);
401
573
  }
402
574
  break;
403
575
  }
404
576
  dropSpecRef.current = null;
405
- keyboardRef.current = false;
577
+ setKeyboard(false);
406
578
  setSessionBoth(null);
407
- }, [setSessionBoth, ctx]);
579
+ }, [
580
+ setSessionBoth,
581
+ setKeyboard,
582
+ ctx
583
+ ]);
408
584
  (0, react.useEffect)(() => {
409
- const STEP = {
410
- ArrowLeft: [-1, 0],
411
- ArrowRight: [1, 0],
412
- ArrowUp: [0, -1],
413
- ArrowDown: [0, 1]
414
- };
415
585
  const onKeyDown = (e) => {
416
586
  if (!keyboardRef.current) return;
417
587
  const session = sessionRef.current;
418
588
  if (!session || session.kind !== "move") return;
419
- const step = STEP[e.key];
589
+ const step = arrowStep(e.key);
420
590
  if (!step) return;
421
591
  e.preventDefault();
422
592
  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
- }
593
+ setSessionBoth((0, _snapgridjs_core.nudge)(session, step[0], step[1], ctx()));
437
594
  };
438
595
  window.addEventListener("keydown", onKeyDown, true);
439
596
  return () => window.removeEventListener("keydown", onKeyDown, true);
@@ -447,89 +604,53 @@ function SnapGridRuntime(props) {
447
604
  handleDragMove,
448
605
  handleDragEnd
449
606
  ]));
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;
607
+ const dragThreshold = opts.dragConfig?.threshold ?? 3;
608
+ const itemSensors = (0, react.useMemo)(() => buildItemSensors(dragThreshold, () => optsRef.current.dragConfig), [dragThreshold]);
609
+ const itemModifiers = (0, react.useMemo)(() => [SnapToGrid.configure({
610
+ getPositionParams: () => ppRef.current,
611
+ isEnabled: () => optsRef.current.dragConfig?.snapToGrid ?? false
612
+ })], []);
613
+ const gridDraggable = opts.isDraggable ?? true;
614
+ const dragEnabled = opts.dragConfig?.enabled ?? true;
456
615
  const isItemDraggable = (0, react.useCallback)((id) => {
457
616
  const it = committedById.get(id);
458
617
  if (!it) return false;
459
- return gridDraggable && dragEnabled && (it.isDraggable ?? true) && !it.static;
618
+ return gridDraggable && dragEnabled && itemGateOpen(it.isDraggable, it.static);
460
619
  }, [
461
620
  committedById,
462
621
  gridDraggable,
463
622
  dragEnabled
464
623
  ]);
465
- const gridResizable = props.isResizable ?? true;
466
- const resizeEnabled = props.resizeConfig?.enabled ?? true;
624
+ const gridResizable = opts.isResizable ?? true;
625
+ const resizeEnabled = opts.resizeConfig?.enabled ?? true;
467
626
  const isItemResizable = (0, react.useCallback)((id) => {
468
627
  const it = committedById.get(id);
469
628
  if (!it) return false;
470
- return gridResizable && resizeEnabled && (it.isResizable ?? true) && !it.static;
629
+ return gridResizable && resizeEnabled && itemGateOpen(it.isResizable, it.static);
471
630
  }, [
472
631
  committedById,
473
632
  gridResizable,
474
633
  resizeEnabled
475
634
  ]);
476
- const defaultHandles = props.resizeConfig?.handles;
635
+ const defaultHandles = opts.resizeConfig?.handles;
477
636
  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,
637
+ controller.setConfig({
483
638
  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
639
  gridConfig,
498
- positionParams,
499
- renderedLayout,
500
- itemsById,
501
- session,
640
+ width: opts.width,
641
+ autoSize: opts.autoSize ?? true,
642
+ itemSensors,
643
+ itemModifiers,
502
644
  isItemDraggable,
503
645
  isItemResizable,
504
646
  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
647
+ setContainerElement
512
648
  });
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
- }) });
649
+ return controller;
530
650
  }
531
651
  //#endregion
532
652
  //#region src/hooks/useGridContainer.ts
653
+ const GRID_COLLISION_PRIORITY = 10;
533
654
  /** Total container height in pixels for the given number of occupied rows. */
534
655
  function containerHeight(rows, grid) {
535
656
  const padY = (grid.containerPadding ?? grid.margin)[1];
@@ -537,81 +658,166 @@ function containerHeight(rows, grid) {
537
658
  return padY * 2 + rows * grid.rowHeight + (rows - 1) * grid.margin[1];
538
659
  }
539
660
  /**
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.
661
+ * The grid host: creates this grid's controller + drag monitor (see
662
+ * {@link useGridController}), registers the droppable surface, and returns props
663
+ * to spread onto your own container element. Render `useGridItem` tiles inside,
664
+ * passing `group` (this grid's id) so they resolve this controller.
543
665
  */
544
- function useGridContainer() {
545
- const rt = useGridRuntime();
666
+ function useGridContainer(opts) {
667
+ const controller = useGridController(opts);
668
+ const { width, autoSize, gridConfig, setContainerElement } = controller.config;
546
669
  const { ref, isDropTarget } = (0, _dnd_kit_react.useDroppable)({
547
- id: rt.containerId,
670
+ id: controller.id,
548
671
  type: "grid",
549
- accept: "grid-item"
672
+ accept: (source) => {
673
+ if (source.type === "grid-item") return true;
674
+ return source.data?.snapGridDrop != null;
675
+ },
676
+ collisionPriority: GRID_COLLISION_PRIORITY
550
677
  });
551
- const setContainerElement = rt.setContainerElement;
552
678
  const setRef = (0, react.useCallback)((element) => {
553
679
  ref(element);
554
680
  setContainerElement(element);
555
681
  }, [ref, setContainerElement]);
556
- const height = rt.autoSize ? containerHeight((0, _snapgridjs_core.bottom)(rt.renderedLayout), rt.gridConfig) : void 0;
682
+ const renderedLayout = (0, react.useSyncExternalStore)(controller.subscribe, controller.renderedSnapshot, controller.renderedSnapshot);
557
683
  return {
558
684
  containerProps: {
559
685
  ref: setRef,
560
686
  style: {
561
687
  position: "relative",
562
- width: rt.width,
563
- height
688
+ width,
689
+ height: autoSize ? containerHeight((0, _snapgridjs_core.bottom)(renderedLayout), gridConfig) : void 0
564
690
  },
565
691
  "data-drop-target": isDropTarget || void 0
566
692
  },
567
- isDropTarget
693
+ isDropTarget,
694
+ group: controller.id,
695
+ controller
568
696
  };
569
697
  }
698
+ const REFLOW_EASING = "ease";
699
+ const REFLOW_TRANSITION = `transform 150ms ${REFLOW_EASING}, width 150ms ${REFLOW_EASING}, height 150ms ${REFLOW_EASING}`;
700
+ //#endregion
701
+ //#region src/hooks/useResolveController.ts
702
+ /**
703
+ * Resolve a grid's controller by its `group` (= the grid's id), scoped to the
704
+ * ambient dnd-kit manager. Items declare `group` (mirroring useSortable); the
705
+ * container registered the controller under that id. Throws a helpful error if
706
+ * unresolved — almost always a missing `group` or a tile rendered outside any
707
+ * grid / `DragDropProvider`.
708
+ */
709
+ function useResolveController(group) {
710
+ const controller = getController((0, _dnd_kit_react.useDragDropManager)(), group);
711
+ 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).`);
712
+ return controller;
713
+ }
570
714
  //#endregion
571
715
  //#region src/hooks/useGridItem.ts
572
- const REFLOW_TRANSITION = "transform 150ms ease, width 150ms ease, height 150ms ease";
716
+ const ITEM_FEEDBACK = [_dnd_kit_dom.Feedback.configure({
717
+ feedback: (_source, manager) => (0, _dnd_kit_dom_utilities.isKeyboardEvent)(manager.dragOperation.activatorEvent) ? "none" : "default",
718
+ dropAnimation: null
719
+ })];
573
720
  /**
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.
721
+ * Headless hook for a single grid tile. The tile is a real `useSortable` (a
722
+ * draggable + droppable carrying `group`/`index`/`type`/`accept`), so it
723
+ * interoperates with the dnd-kit sortable ecosystem, yet it is positioned by RGL
724
+ * via the {@link GridController}. `group` is the owning grid's id (from its
725
+ * {@link useGridContainer}), mirroring `useSortable`'s `group`. Spread the returned
726
+ * `ref`, optional `handleRef`, positioning `style`, and drag state onto whatever
727
+ * element you render — you own the tag, className, content, and cosmetic styling.
728
+ *
729
+ * The dragged tile floats itself via dnd-kit's default feedback (no `<DragOverlay>`):
730
+ * the active tile renders at its committed origin so the float offset composes, and
731
+ * reflow is animated on the compositor via the Web Animations API — both so it stays
732
+ * smooth in Safari, where the float's popover top-layer repaint would jank a
733
+ * CSS-transition reflow.
577
734
  */
578
- function useGridItem(id) {
579
- const rt = useGridRuntime();
580
- const item = rt.itemsById.get(id);
581
- const { ref, isDragging } = (0, _dnd_kit_react.useDraggable)({
735
+ function useGridItem(id, group) {
736
+ const controller = useResolveController(group);
737
+ const snap = (0, react.useSyncExternalStore)(controller.subscribe, () => controller.itemSnapshot(id), () => controller.itemSnapshot(id));
738
+ const item = snap.item;
739
+ const active = snap.isDragging;
740
+ const hidden = snap.hidden;
741
+ const config = controller.config;
742
+ const wasActive = (0, react.useRef)(false);
743
+ const justDropped = wasActive.current && !active;
744
+ wasActive.current = active;
745
+ const data = (0, react.useMemo)(() => ({ snapGrid: {
746
+ kind: "move",
747
+ itemId: id,
748
+ item
749
+ } }), [id, item]);
750
+ const { ref: sortableRef, handleRef, isDragging } = (0, _dnd_kit_react_sortable.useSortable)({
582
751
  id,
752
+ index: controller.itemIndex(id),
753
+ group,
583
754
  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
- } }
755
+ accept: "grid-item",
756
+ disabled: !config.isItemDraggable(id),
757
+ sensors: config.itemSensors,
758
+ modifiers: config.itemModifiers,
759
+ plugins: (defaults) => [...defaults, ...ITEM_FEEDBACK],
760
+ data
592
761
  });
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 = {
762
+ const elRef = (0, react.useRef)(null);
763
+ const setRef = (0, react.useCallback)((element) => {
764
+ sortableRef(element);
765
+ elRef.current = element;
766
+ }, [sortableRef]);
767
+ const session = controller.getSession();
768
+ const dragging = session != null;
769
+ const posItem = item ? active && hidden ? session?.anchor.item ?? item : item : void 0;
770
+ const pos = posItem ? (0, _snapgridjs_core.calcGridItemPosition)(config.positionParams, posItem.x, posItem.y, posItem.w, posItem.h) : void 0;
771
+ const posLeft = pos?.left;
772
+ const posTop = pos?.top;
773
+ const prev = (0, react.useRef)(null);
774
+ const reflowAnim = (0, react.useRef)(null);
775
+ (0, react.useLayoutEffect)(() => {
776
+ const cur = posLeft != null && posTop != null ? {
777
+ left: posLeft,
778
+ top: posTop
779
+ } : null;
780
+ const before = prev.current;
781
+ prev.current = cur;
782
+ const el = elRef.current;
783
+ if (!el || !cur || !before || active || justDropped || !dragging) return;
784
+ if (before.left === cur.left && before.top === cur.top) return;
785
+ let fromX = before.left;
786
+ let fromY = before.top;
787
+ if (reflowAnim.current?.playState === "running") {
788
+ const m = new DOMMatrix(getComputedStyle(el).transform);
789
+ fromX = m.m41;
790
+ fromY = m.m42;
791
+ }
792
+ reflowAnim.current?.cancel();
793
+ reflowAnim.current = el.animate([{ transform: `translate(${fromX}px, ${fromY}px)` }, { transform: `translate(${cur.left}px, ${cur.top}px)` }], {
794
+ duration: 150,
795
+ easing: REFLOW_EASING
796
+ });
797
+ }, [
798
+ posLeft,
799
+ posTop,
800
+ active,
801
+ justDropped,
802
+ dragging
803
+ ]);
804
+ (0, react.useEffect)(() => () => reflowAnim.current?.cancel(), []);
805
+ return {
806
+ ref: setRef,
807
+ handleRef,
808
+ style: pos ? {
601
809
  position: "absolute",
602
810
  left: 0,
603
811
  top: 0,
604
812
  width: pos.width,
605
813
  height: pos.height,
606
814
  transform: `translate(${pos.left}px, ${pos.top}px)`,
607
- visibility: active ? "hidden" : void 0,
608
- transition: active ? "none" : REFLOW_TRANSITION,
815
+ transition: active || justDropped || dragging ? "none" : REFLOW_TRANSITION,
609
816
  touchAction: "none"
610
- };
611
- }
612
- return {
613
- ref,
614
- style,
817
+ } : {
818
+ position: "absolute",
819
+ touchAction: "none"
820
+ },
615
821
  isDragging,
616
822
  item
617
823
  };
@@ -620,13 +826,14 @@ function useGridItem(id) {
620
826
  //#region src/hooks/useGridPlaceholder.ts
621
827
  /**
622
828
  * 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.
829
+ * `null` when no drag is in progress. `group` is the owning grid's id (from its
830
+ * {@link useGridContainer}). You render the element however you like.
624
831
  */
625
- function useGridPlaceholder() {
626
- const rt = useGridRuntime();
627
- const placeholder = rt.session?.placeholder;
832
+ function useGridPlaceholder(group) {
833
+ const controller = useResolveController(group);
834
+ const placeholder = (0, react.useSyncExternalStore)(controller.subscribe, controller.placeholderSnapshot, controller.placeholderSnapshot);
628
835
  if (!placeholder) return null;
629
- const pos = (0, _snapgridjs_core.calcGridItemPosition)(rt.positionParams, placeholder.x, placeholder.y, placeholder.w, placeholder.h);
836
+ const pos = (0, _snapgridjs_core.calcGridItemPosition)(controller.config.positionParams, placeholder.x, placeholder.y, placeholder.w, placeholder.h);
630
837
  return {
631
838
  item: placeholder,
632
839
  style: {
@@ -644,14 +851,15 @@ function useGridPlaceholder() {
644
851
  //#region src/hooks/useGridResizeHandle.ts
645
852
  /**
646
853
  * 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.
854
+ * dragging it resizes the item from the given edge/corner. `group` is the owning
855
+ * grid's id (from its {@link useGridContainer}). Spread `ref` and `handleProps`
856
+ * onto the handle element you position/style.
649
857
  */
650
- function useGridResizeHandle(itemId, handle) {
651
- const rt = useGridRuntime();
858
+ function useGridResizeHandle(itemId, handle, group) {
859
+ const controller = useResolveController(group);
652
860
  const { ref } = (0, _dnd_kit_react.useDraggable)({
653
861
  id: `${itemId}::resize::${handle}`,
654
- disabled: !rt.isItemResizable(itemId),
862
+ disabled: !controller.config?.isItemResizable(itemId),
655
863
  plugins: NO_FEEDBACK,
656
864
  data: { snapGrid: {
657
865
  kind: "resize",
@@ -659,7 +867,7 @@ function useGridResizeHandle(itemId, handle) {
659
867
  handle
660
868
  } }
661
869
  });
662
- const isResizing = rt.session?.kind === "resize" && rt.session.activeId === itemId;
870
+ const { isResizing } = (0, react.useSyncExternalStore)(controller.subscribe, () => controller.resizeSnapshot(itemId), () => controller.resizeSnapshot(itemId));
663
871
  return {
664
872
  ref,
665
873
  handleProps: { [RESIZE_HANDLE_ATTR]: true },
@@ -667,30 +875,6 @@ function useGridResizeHandle(itemId, handle) {
667
875
  };
668
876
  }
669
877
  //#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
878
  //#region src/hooks/useResponsiveLayout.ts
695
879
  /** react-grid-layout's default breakpoints (px) and column counts. */
696
880
  const DEFAULT_BREAKPOINTS = {
@@ -757,26 +941,7 @@ function useResponsiveLayout(options) {
757
941
  };
758
942
  }
759
943
  //#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
944
+ //#region src/components/GridItem.tsx
780
945
  const HANDLE_CURSOR = {
781
946
  n: "ns-resize",
782
947
  s: "ns-resize",
@@ -805,8 +970,8 @@ function handleStyle(handle) {
805
970
  if (handle === "e" || handle === "w") s.top = `calc(50% - ${SIDE / 2}px)`;
806
971
  return s;
807
972
  }
808
- function DefaultResizeHandle({ itemId, handle }) {
809
- const { ref, handleProps } = useGridResizeHandle(itemId, handle);
973
+ function DefaultResizeHandle({ itemId, handle, group }) {
974
+ const { ref, handleProps } = useGridResizeHandle(itemId, handle, group);
810
975
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
811
976
  ref,
812
977
  ...handleProps,
@@ -817,12 +982,19 @@ function DefaultResizeHandle({ itemId, handle }) {
817
982
  /**
818
983
  * Convenience wrapper over {@link useGridItem}: an absolutely-positioned `<div>`
819
984
  * with stable hooks (`.snapgrid-item`, `data-grid-id`, `data-dragging`) and the
820
- * configured resize handles. For full control, use the hooks directly.
985
+ * configured resize handles. `group` is the owning grid's id. For full control,
986
+ * use the hooks directly.
987
+ *
988
+ * Memoized so re-rendering the surface (e.g. its auto-height tracking the drag)
989
+ * doesn't re-render every tile — a tile re-renders only when its own slice
990
+ * changes (via useGridItem's subscription). Keeps a drag's React work scoped to
991
+ * the moved tile (see renderScope.test).
821
992
  */
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) : [];
993
+ function GridItemImpl({ id, group, children, className, style }) {
994
+ const controller = useResolveController(group);
995
+ const { ref, style: positionStyle, isDragging } = useGridItem(id, group);
996
+ const config = controller.config;
997
+ const handles = config?.isItemResizable(id) ? config.resizeHandlesFor(id) : [];
826
998
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
827
999
  ref,
828
1000
  "data-grid-id": id,
@@ -834,27 +1006,29 @@ function GridItem({ id, children, className, style }) {
834
1006
  } : positionStyle,
835
1007
  children: [children, handles.map((handle) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DefaultResizeHandle, {
836
1008
  itemId: id,
837
- handle
1009
+ handle,
1010
+ group
838
1011
  }, handle))]
839
1012
  });
840
1013
  }
1014
+ const GridItem = (0, react.memo)(GridItemImpl);
841
1015
  //#endregion
842
- //#region src/GridPlaceholder.tsx
1016
+ //#region src/components/GridPlaceholder.tsx
843
1017
  const DEFAULT_LOOK = {
844
1018
  background: "rgba(99, 102, 241, 0.2)",
845
1019
  border: "1px dashed rgba(99, 102, 241, 0.6)",
846
1020
  borderRadius: 4,
847
1021
  boxSizing: "border-box",
848
1022
  zIndex: 2,
849
- transition: "transform 150ms ease, width 150ms ease, height 150ms ease"
1023
+ transition: REFLOW_TRANSITION
850
1024
  };
851
1025
  /**
852
1026
  * Convenience placeholder rendered from {@link useGridPlaceholder}. Renders
853
1027
  * nothing when no drag is active. For a custom placeholder, call the hook
854
1028
  * directly and render your own element with the returned `style`.
855
1029
  */
856
- function GridPlaceholder({ className, style }) {
857
- const placeholder = useGridPlaceholder();
1030
+ function GridPlaceholder({ group, className, style }) {
1031
+ const placeholder = useGridPlaceholder(group);
858
1032
  if (!placeholder) return null;
859
1033
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
860
1034
  "aria-hidden": "true",
@@ -867,18 +1041,15 @@ function GridPlaceholder({ className, style }) {
867
1041
  });
868
1042
  }
869
1043
  //#endregion
870
- //#region src/GridLayout.tsx
1044
+ //#region src/components/GridLayout.tsx
1045
+ const InProvider = (0, react.createContext)(false);
871
1046
  /** Strip the namespacing prefix React applies to keys inside `Children.map`. */
872
1047
  function keyToId(key) {
873
1048
  return key.startsWith(".$") ? key.slice(2) : key;
874
1049
  }
875
1050
  /** 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
- });
1051
+ function GridSurface({ className, style, children, ...opts }) {
1052
+ const { containerProps, group } = useGridContainer(opts);
882
1053
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
883
1054
  ...containerProps,
884
1055
  className: className ? `snapgrid ${className}` : "snapgrid",
@@ -886,38 +1057,50 @@ function GridSurface({ className, style, children }) {
886
1057
  ...containerProps.style,
887
1058
  ...style
888
1059
  } : 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
- ]
1060
+ children: [react.Children.map(children, (child) => {
1061
+ if (!(0, react.isValidElement)(child) || child.key == null) return child;
1062
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridItem, {
1063
+ id: keyToId(String(child.key)),
1064
+ group,
1065
+ children: child
1066
+ }, child.key);
1067
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridPlaceholder, { group })]
900
1068
  });
901
1069
  }
902
1070
  /**
903
1071
  * 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
1072
+ * backed by dnd-kit. A thin shell over {@link useGridContainer} and the headless
905
1073
  * hooks — children are keyed by their layout item's `i`. For full control over
906
- * markup/styling, use the provider + hooks directly.
1074
+ * markup/styling, use the hooks directly.
1075
+ *
1076
+ * Supplies the dnd-kit `DragDropProvider` for the turnkey case so consumers
1077
+ * don't manage one. Nest multiple `GridLayout`s and they share one provider
1078
+ * (the seam for cross-grid drags); a consumer's own provider is also honored.
907
1079
  */
908
1080
  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
- });
1081
+ const inProvider = (0, react.useContext)(InProvider);
1082
+ const surface = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridSurface, { ...props });
1083
+ if (inProvider) return surface;
1084
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_dnd_kit_react.DragDropProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InProvider.Provider, {
1085
+ value: true,
1086
+ children: surface
1087
+ }) });
1088
+ }
1089
+ /**
1090
+ * Share one dnd-kit `DragDropProvider` across several sibling grids so tiles can
1091
+ * be dragged between them. (Nested `GridLayout`s already share a provider; this
1092
+ * is for siblings.) A thin wrapper over `DragDropProvider` — the cross-grid seam
1093
+ * is the shared manager + collision target.
1094
+ */
1095
+ function SnapGridGroup({ children }) {
1096
+ if ((0, react.useContext)(InProvider)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children });
1097
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_dnd_kit_react.DragDropProvider, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InProvider.Provider, {
1098
+ value: true,
1099
+ children
1100
+ }) });
918
1101
  }
919
1102
  //#endregion
920
- //#region src/ResponsiveGridLayout.tsx
1103
+ //#region src/components/ResponsiveGridLayout.tsx
921
1104
  /**
922
1105
  * A responsive grid: switches column count and layout by breakpoint as `width`
923
1106
  * changes, generating a breakpoint's layout from the nearest one when absent.
@@ -997,13 +1180,18 @@ function useContainerWidth(options = {}) {
997
1180
  //#endregion
998
1181
  exports.DEFAULT_BREAKPOINTS = DEFAULT_BREAKPOINTS;
999
1182
  exports.DEFAULT_BREAKPOINT_COLS = DEFAULT_BREAKPOINT_COLS;
1183
+ Object.defineProperty(exports, "DragOverlay", {
1184
+ enumerable: true,
1185
+ get: function() {
1186
+ return _dnd_kit_react.DragOverlay;
1187
+ }
1188
+ });
1000
1189
  Object.defineProperty(exports, "Feedback", {
1001
1190
  enumerable: true,
1002
1191
  get: function() {
1003
1192
  return _dnd_kit_dom.Feedback;
1004
1193
  }
1005
1194
  });
1006
- exports.GridDragOverlay = GridDragOverlay;
1007
1195
  exports.GridItem = GridItem;
1008
1196
  exports.GridLayout = GridLayout;
1009
1197
  exports.GridPlaceholder = GridPlaceholder;
@@ -1021,7 +1209,6 @@ Object.defineProperty(exports, "PointerSensor", {
1021
1209
  });
1022
1210
  exports.ResponsiveGridLayout = ResponsiveGridLayout;
1023
1211
  exports.SnapGridGroup = SnapGridGroup;
1024
- exports.SnapGridProvider = SnapGridProvider;
1025
1212
  Object.defineProperty(exports, "getCompactor", {
1026
1213
  enumerable: true,
1027
1214
  get: function() {
@@ -1054,7 +1241,6 @@ Object.defineProperty(exports, "useDroppable", {
1054
1241
  }
1055
1242
  });
1056
1243
  exports.useGridContainer = useGridContainer;
1057
- exports.useGridDragOverlay = useGridDragOverlay;
1058
1244
  exports.useGridItem = useGridItem;
1059
1245
  exports.useGridPlaceholder = useGridPlaceholder;
1060
1246
  exports.useGridResizeHandle = useGridResizeHandle;