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