@wordpress/grid 0.1.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE.md +788 -0
  3. package/README.md +534 -0
  4. package/build/dashboard-grid/grid-item.cjs +308 -0
  5. package/build/dashboard-grid/grid-item.cjs.map +7 -0
  6. package/build/dashboard-grid/index.cjs +591 -0
  7. package/build/dashboard-grid/index.cjs.map +7 -0
  8. package/build/dashboard-grid/resolve-fill-widths.cjs +189 -0
  9. package/build/dashboard-grid/resolve-fill-widths.cjs.map +7 -0
  10. package/build/dashboard-grid/types.cjs +19 -0
  11. package/build/dashboard-grid/types.cjs.map +7 -0
  12. package/build/dashboard-lanes/index.cjs +558 -0
  13. package/build/dashboard-lanes/index.cjs.map +7 -0
  14. package/build/dashboard-lanes/lane-placement.cjs +110 -0
  15. package/build/dashboard-lanes/lane-placement.cjs.map +7 -0
  16. package/build/dashboard-lanes/lanes-item.cjs +295 -0
  17. package/build/dashboard-lanes/lanes-item.cjs.map +7 -0
  18. package/build/dashboard-lanes/types.cjs +19 -0
  19. package/build/dashboard-lanes/types.cjs.map +7 -0
  20. package/build/dashboard-lanes/use-lane-placement.cjs +206 -0
  21. package/build/dashboard-lanes/use-lane-placement.cjs.map +7 -0
  22. package/build/index.cjs +34 -0
  23. package/build/index.cjs.map +7 -0
  24. package/build/shared/drag-overlay-drop-animation.cjs +70 -0
  25. package/build/shared/drag-overlay-drop-animation.cjs.map +7 -0
  26. package/build/shared/grid-item-key.cjs +31 -0
  27. package/build/shared/grid-item-key.cjs.map +7 -0
  28. package/build/shared/grid-overlay.cjs +187 -0
  29. package/build/shared/grid-overlay.cjs.map +7 -0
  30. package/build/shared/item-exit-overlay.cjs +150 -0
  31. package/build/shared/item-exit-overlay.cjs.map +7 -0
  32. package/build/shared/resize-handle.cjs +224 -0
  33. package/build/shared/resize-handle.cjs.map +7 -0
  34. package/build/shared/resize-snap.cjs +47 -0
  35. package/build/shared/resize-snap.cjs.map +7 -0
  36. package/build/shared/types.cjs +19 -0
  37. package/build/shared/types.cjs.map +7 -0
  38. package/build/shared/use-item-exit-animation.cjs +148 -0
  39. package/build/shared/use-item-exit-animation.cjs.map +7 -0
  40. package/build/shared/use-layout-shift-animation.cjs +167 -0
  41. package/build/shared/use-layout-shift-animation.cjs.map +7 -0
  42. package/build-module/dashboard-grid/grid-item.mjs +273 -0
  43. package/build-module/dashboard-grid/grid-item.mjs.map +7 -0
  44. package/build-module/dashboard-grid/index.mjs +579 -0
  45. package/build-module/dashboard-grid/index.mjs.map +7 -0
  46. package/build-module/dashboard-grid/resolve-fill-widths.mjs +164 -0
  47. package/build-module/dashboard-grid/resolve-fill-widths.mjs.map +7 -0
  48. package/build-module/dashboard-grid/types.mjs +1 -0
  49. package/build-module/dashboard-grid/types.mjs.map +7 -0
  50. package/build-module/dashboard-lanes/index.mjs +547 -0
  51. package/build-module/dashboard-lanes/index.mjs.map +7 -0
  52. package/build-module/dashboard-lanes/lane-placement.mjs +85 -0
  53. package/build-module/dashboard-lanes/lane-placement.mjs.map +7 -0
  54. package/build-module/dashboard-lanes/lanes-item.mjs +260 -0
  55. package/build-module/dashboard-lanes/lanes-item.mjs.map +7 -0
  56. package/build-module/dashboard-lanes/types.mjs +1 -0
  57. package/build-module/dashboard-lanes/types.mjs.map +7 -0
  58. package/build-module/dashboard-lanes/use-lane-placement.mjs +181 -0
  59. package/build-module/dashboard-lanes/use-lane-placement.mjs.map +7 -0
  60. package/build-module/index.mjs +8 -0
  61. package/build-module/index.mjs.map +7 -0
  62. package/build-module/shared/drag-overlay-drop-animation.mjs +47 -0
  63. package/build-module/shared/drag-overlay-drop-animation.mjs.map +7 -0
  64. package/build-module/shared/grid-item-key.mjs +6 -0
  65. package/build-module/shared/grid-item-key.mjs.map +7 -0
  66. package/build-module/shared/grid-overlay.mjs +152 -0
  67. package/build-module/shared/grid-overlay.mjs.map +7 -0
  68. package/build-module/shared/item-exit-overlay.mjs +125 -0
  69. package/build-module/shared/item-exit-overlay.mjs.map +7 -0
  70. package/build-module/shared/resize-handle.mjs +193 -0
  71. package/build-module/shared/resize-handle.mjs.map +7 -0
  72. package/build-module/shared/resize-snap.mjs +21 -0
  73. package/build-module/shared/resize-snap.mjs.map +7 -0
  74. package/build-module/shared/types.mjs +1 -0
  75. package/build-module/shared/types.mjs.map +7 -0
  76. package/build-module/shared/use-item-exit-animation.mjs +128 -0
  77. package/build-module/shared/use-item-exit-animation.mjs.map +7 -0
  78. package/build-module/shared/use-layout-shift-animation.mjs +140 -0
  79. package/build-module/shared/use-layout-shift-animation.mjs.map +7 -0
  80. package/build-types/dashboard-grid/grid-item.d.ts +3 -0
  81. package/build-types/dashboard-grid/grid-item.d.ts.map +1 -0
  82. package/build-types/dashboard-grid/index.d.ts +35 -0
  83. package/build-types/dashboard-grid/index.d.ts.map +1 -0
  84. package/build-types/dashboard-grid/resolve-fill-widths.d.ts +26 -0
  85. package/build-types/dashboard-grid/resolve-fill-widths.d.ts.map +1 -0
  86. package/build-types/dashboard-grid/stories/index.story.d.ts +98 -0
  87. package/build-types/dashboard-grid/stories/index.story.d.ts.map +1 -0
  88. package/build-types/dashboard-grid/types.d.ts +232 -0
  89. package/build-types/dashboard-grid/types.d.ts.map +1 -0
  90. package/build-types/dashboard-lanes/index.d.ts +40 -0
  91. package/build-types/dashboard-lanes/index.d.ts.map +1 -0
  92. package/build-types/dashboard-lanes/lane-placement.d.ts +126 -0
  93. package/build-types/dashboard-lanes/lane-placement.d.ts.map +1 -0
  94. package/build-types/dashboard-lanes/lanes-item.d.ts +52 -0
  95. package/build-types/dashboard-lanes/lanes-item.d.ts.map +1 -0
  96. package/build-types/dashboard-lanes/stories/index.story.d.ts +64 -0
  97. package/build-types/dashboard-lanes/stories/index.story.d.ts.map +1 -0
  98. package/build-types/dashboard-lanes/types.d.ts +151 -0
  99. package/build-types/dashboard-lanes/types.d.ts.map +1 -0
  100. package/build-types/dashboard-lanes/use-lane-placement.d.ts +74 -0
  101. package/build-types/dashboard-lanes/use-lane-placement.d.ts.map +1 -0
  102. package/build-types/index.d.ts +6 -0
  103. package/build-types/index.d.ts.map +1 -0
  104. package/build-types/shared/drag-overlay-drop-animation.d.ts +13 -0
  105. package/build-types/shared/drag-overlay-drop-animation.d.ts.map +1 -0
  106. package/build-types/shared/grid-item-key.d.ts +6 -0
  107. package/build-types/shared/grid-item-key.d.ts.map +1 -0
  108. package/build-types/shared/grid-overlay.d.ts +19 -0
  109. package/build-types/shared/grid-overlay.d.ts.map +1 -0
  110. package/build-types/shared/item-exit-overlay.d.ts +20 -0
  111. package/build-types/shared/item-exit-overlay.d.ts.map +1 -0
  112. package/build-types/shared/resize-handle.d.ts +23 -0
  113. package/build-types/shared/resize-handle.d.ts.map +1 -0
  114. package/build-types/shared/resize-snap.d.ts +41 -0
  115. package/build-types/shared/resize-snap.d.ts.map +1 -0
  116. package/build-types/shared/types.d.ts +144 -0
  117. package/build-types/shared/types.d.ts.map +1 -0
  118. package/build-types/shared/use-item-exit-animation.d.ts +37 -0
  119. package/build-types/shared/use-item-exit-animation.d.ts.map +1 -0
  120. package/build-types/shared/use-layout-shift-animation.d.ts +77 -0
  121. package/build-types/shared/use-layout-shift-animation.d.ts.map +1 -0
  122. package/package.json +80 -0
  123. package/src/dashboard-grid/grid-item.module.css +94 -0
  124. package/src/dashboard-grid/grid-item.tsx +205 -0
  125. package/src/dashboard-grid/grid.module.css +134 -0
  126. package/src/dashboard-grid/index.tsx +713 -0
  127. package/src/dashboard-grid/resolve-fill-widths.ts +224 -0
  128. package/src/dashboard-grid/stories/index.story.tsx +930 -0
  129. package/src/dashboard-grid/test/keyboard-activation.test.tsx +76 -0
  130. package/src/dashboard-grid/test/resolve-fill-widths.test.ts +250 -0
  131. package/src/dashboard-grid/types.ts +271 -0
  132. package/src/dashboard-lanes/index.tsx +629 -0
  133. package/src/dashboard-lanes/lane-placement.ts +245 -0
  134. package/src/dashboard-lanes/lanes-item.module.css +93 -0
  135. package/src/dashboard-lanes/lanes-item.tsx +236 -0
  136. package/src/dashboard-lanes/lanes.module.css +152 -0
  137. package/src/dashboard-lanes/stories/index.story.tsx +518 -0
  138. package/src/dashboard-lanes/test/keyboard-activation.test.tsx +71 -0
  139. package/src/dashboard-lanes/test/lane-placement.test.ts +442 -0
  140. package/src/dashboard-lanes/test/use-lane-placement.test.tsx +358 -0
  141. package/src/dashboard-lanes/types.ts +176 -0
  142. package/src/dashboard-lanes/use-lane-placement.ts +313 -0
  143. package/src/index.ts +17 -0
  144. package/src/shared/actionable-area-slot.module.css +16 -0
  145. package/src/shared/drag-overlay-drop-animation.ts +66 -0
  146. package/src/shared/grid-item-key.ts +5 -0
  147. package/src/shared/grid-overlay.module.css +82 -0
  148. package/src/shared/grid-overlay.tsx +93 -0
  149. package/src/shared/item-exit-animation.module.css +49 -0
  150. package/src/shared/item-exit-overlay.tsx +57 -0
  151. package/src/shared/layout-shift-animation.module.css +16 -0
  152. package/src/shared/resize-handle.module.css +88 -0
  153. package/src/shared/resize-handle.tsx +163 -0
  154. package/src/shared/resize-snap.ts +63 -0
  155. package/src/shared/test/resize-snap.test.ts +35 -0
  156. package/src/shared/types.ts +164 -0
  157. package/src/shared/use-item-exit-animation.ts +199 -0
  158. package/src/shared/use-layout-shift-animation.ts +284 -0
@@ -0,0 +1,713 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import {
5
+ DndContext,
6
+ DragOverlay,
7
+ KeyboardSensor,
8
+ PointerSensor,
9
+ useSensor,
10
+ useSensors,
11
+ } from '@dnd-kit/core';
12
+ import {
13
+ arrayMove,
14
+ SortableContext,
15
+ sortableKeyboardCoordinates,
16
+ } from '@dnd-kit/sortable';
17
+ import type { DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
18
+ import clsx from 'clsx';
19
+
20
+ /**
21
+ * WordPress dependencies
22
+ */
23
+ import { useResizeObserver, useEvent, useMergeRefs } from '@wordpress/compose';
24
+ import {
25
+ forwardRef,
26
+ useMemo,
27
+ Children,
28
+ cloneElement,
29
+ isValidElement,
30
+ useLayoutEffect,
31
+ useRef,
32
+ useState,
33
+ } from '@wordpress/element';
34
+
35
+ /**
36
+ * Internal dependencies
37
+ */
38
+ import { GridItem } from './grid-item';
39
+ import { GridOverlay } from '../shared/grid-overlay';
40
+ import { gridSpanToPixelSize } from '../shared/resize-snap';
41
+ import layoutAnimationStyles from '../shared/layout-shift-animation.module.css';
42
+ import { ItemExitOverlay } from '../shared/item-exit-overlay';
43
+ import {
44
+ getLayoutFingerprint,
45
+ useLayoutShiftAnimation,
46
+ } from '../shared/use-layout-shift-animation';
47
+ import { useItemExitAnimation } from '../shared/use-item-exit-animation';
48
+ import { resolveFillWidths } from './resolve-fill-widths';
49
+ import type { DashboardGridLayoutItem, DashboardGridProps } from './types';
50
+ import type { ResizeSnapSize } from '../shared/resize-snap';
51
+ import type { ResizeDelta } from '../shared/types';
52
+ import { createDashboardDragDropAnimation } from '../shared/drag-overlay-drop-animation';
53
+ import styles from './grid.module.css';
54
+
55
+ const dashboardDragDropAnimation = createDashboardDragDropAnimation(
56
+ styles[ 'drag-preview-frame' ],
57
+ styles.dragPreviewFrameExiting
58
+ );
59
+
60
+ // Fallback gap in pixels for math that runs before the computed gap
61
+ // can be read from the DOM. Matches the `'xl'` step the surface
62
+ // resolves to in CSS (`--wpds-dimension-gap-xl`); the next layout
63
+ // effect overwrites this with the actual computed value.
64
+ const FALLBACK_GAP_PX = 24;
65
+
66
+ // Default column cap when no explicit `columns` or `minColumnWidth` is
67
+ // supplied. Layered semantics: `columns` acts as a cap and
68
+ // `minColumnWidth` as a per-tile floor; if neither is set we still
69
+ // need a finite count to render against.
70
+ const DEFAULT_COLUMNS = 6;
71
+
72
+ // Reorder is driven by `temporaryLayout` + CSS Grid, not by dnd-kit
73
+ // transforms. Hoist the no-op strategy outside the component so its
74
+ // reference is stable across renders — passing a fresh `() => null`
75
+ // to `<SortableContext>` updates its context value and triggers all
76
+ // `useSortable` subscribers to re-render every frame.
77
+ const NO_SORT_STRATEGY = () => null;
78
+
79
+ /**
80
+ * 2D packed dashboard grid with drag-to-reorder and resize handles.
81
+ * Supports fixed-column and responsive modes, `number | 'fill' | 'full'`
82
+ * widths, and multi-row tiles.
83
+ *
84
+ * Each child's `key` must match an entry in the `layout` array;
85
+ * children without a match render at the end of the grid without
86
+ * explicit placement and fall through CSS Grid's auto-flow.
87
+ *
88
+ * @example
89
+ * ```jsx
90
+ * const layout = [
91
+ * { key: 'a', width: 2 },
92
+ * { key: 'b', width: 'fill' },
93
+ * { key: 'c', width: 'full' },
94
+ * ];
95
+ *
96
+ * <DashboardGrid
97
+ * layout={ layout }
98
+ * columns={ 6 }
99
+ * editMode
100
+ * onChangeLayout={ setLayout }
101
+ * >
102
+ * <div key="a">A</div>
103
+ * <div key="b">B</div>
104
+ * <div key="c">C</div>
105
+ * </DashboardGrid>
106
+ * ```
107
+ *
108
+ * @param props Component props.
109
+ * @param ref Forwarded to the grid's root `<div>`.
110
+ */
111
+ export const DashboardGrid = forwardRef< HTMLDivElement, DashboardGridProps >(
112
+ function DashboardGrid( props, ref ) {
113
+ const {
114
+ layout,
115
+ columns,
116
+ children,
117
+ className,
118
+ style,
119
+ rowHeight = 'auto',
120
+ minColumnWidth,
121
+ editMode = false,
122
+ onChangeLayout,
123
+ onPreviewLayout,
124
+ renderResizeHandle,
125
+ renderDragPreview,
126
+ renderGridOverlay,
127
+ ...divProps
128
+ } = props;
129
+ // Preview layout applied during drag/resize before committing.
130
+ const [ temporaryLayout, setTemporaryLayout ] = useState<
131
+ DashboardGridLayoutItem[] | undefined
132
+ >();
133
+ // Drives `<DragOverlay>` content while a drag is in progress.
134
+ const [ activeId, setActiveId ] = useState< string | null >( null );
135
+ // True while any tile is being resized. Combined with `activeId`,
136
+ // it drives the grid-wide `inert` flag on actionable areas so
137
+ // hovering over another tile's buttons can't steal the gesture.
138
+ const [ isResizing, setIsResizing ] = useState( false );
139
+ // Snapped span in pixels for the resize-preview outline on the
140
+ // active tile. The tile content follows the cursor continuously;
141
+ // this preview shows the grid size that will commit on release.
142
+ const [ resizeSnapPreview, setResizeSnapPreview ] = useState< {
143
+ id: string;
144
+ snap: ResizeSnapSize;
145
+ } | null >( null );
146
+ // Mirror of `temporaryLayout` read synchronously on drag end —
147
+ // the state update from `handleDragMove` may still be batched.
148
+ const latestLayoutRef = useRef<
149
+ DashboardGridLayoutItem[] | undefined
150
+ >();
151
+ // Cursor center at the last applied reorder. Used to skip the
152
+ // cascade of re-measured `onDragMove` events after a layout
153
+ // change, when the cursor has not actually moved.
154
+ const lastReorderCursorRef = useRef< {
155
+ x: number;
156
+ y: number;
157
+ } | null >( null );
158
+ // Width/height snapshot at the start of a resize session. The
159
+ // resize handle reports `delta` absolute from the gesture start,
160
+ // so the baseline must stay frozen — reading from the already
161
+ // mutated `activeLayout` would compound the delta each frame.
162
+ const resizeBaselineRef = useRef< {
163
+ width: number;
164
+ height: number;
165
+ } | null >( null );
166
+ const captureLayoutSnapshotRef = useRef< () => void >( () => {} );
167
+ const childrenCacheRef = useRef< Map< string, React.ReactElement > >(
168
+ new Map()
169
+ );
170
+ const activeLayout = temporaryLayout ?? layout;
171
+
172
+ const [ gridRoot, setGridRoot ] = useState< HTMLDivElement | null >(
173
+ null
174
+ );
175
+ const [ containerWidth, setContainerWidth ] = useState( 0 );
176
+ const [ containerHeight, setContainerHeight ] = useState( 0 );
177
+ const [ gapPx, setGapPx ] = useState( FALLBACK_GAP_PX );
178
+ const resizeObserverRef = useResizeObserver(
179
+ ( [ { contentRect } ] ) => {
180
+ setContainerWidth( contentRect.width );
181
+ setContainerHeight( contentRect.height );
182
+ }
183
+ );
184
+ const mergedGridRef = useMergeRefs( [
185
+ setGridRoot,
186
+ resizeObserverRef,
187
+ ref,
188
+ ] );
189
+
190
+ // Measure before paint to avoid a single-column flash in
191
+ // responsive mode; `useResizeObserver` delivers async. The
192
+ // computed `column-gap` is read from the resolved CSS so the
193
+ // math tracks the design-system token under any density.
194
+ useLayoutEffect( () => {
195
+ if ( ! gridRoot ) {
196
+ return;
197
+ }
198
+ const { width, height } = gridRoot.getBoundingClientRect();
199
+ if ( width > 0 ) {
200
+ setContainerWidth( width );
201
+ }
202
+ if ( height > 0 ) {
203
+ setContainerHeight( height );
204
+ }
205
+ const parsed = Number.parseFloat(
206
+ window.getComputedStyle( gridRoot ).columnGap
207
+ );
208
+ if ( Number.isFinite( parsed ) && parsed > 0 ) {
209
+ setGapPx( parsed );
210
+ }
211
+ }, [ gridRoot ] );
212
+ const effectiveColumns = useMemo( () => {
213
+ if ( ! minColumnWidth ) {
214
+ return columns ?? DEFAULT_COLUMNS;
215
+ }
216
+
217
+ const totalWidthPerColumn = minColumnWidth + gapPx;
218
+ const maxFit = Math.max(
219
+ 1,
220
+ Math.floor( ( containerWidth + gapPx ) / totalWidthPerColumn )
221
+ );
222
+ return columns !== undefined ? Math.min( columns, maxFit ) : maxFit;
223
+ }, [ minColumnWidth, gapPx, containerWidth, columns ] );
224
+ const columnWidth =
225
+ ( containerWidth - ( effectiveColumns - 1 ) * gapPx ) /
226
+ effectiveColumns;
227
+ const minResizeWidthPx = gridSpanToPixelSize(
228
+ 1,
229
+ 1,
230
+ columnWidth,
231
+ gapPx,
232
+ null
233
+ ).widthPx;
234
+ const rowHeightPx = typeof rowHeight === 'number' ? rowHeight : null;
235
+ const minResizeHeightPx =
236
+ rowHeightPx === null
237
+ ? undefined
238
+ : gridSpanToPixelSize( 1, 1, columnWidth, gapPx, rowHeightPx )
239
+ .heightPx ?? undefined;
240
+
241
+ const layoutMap = useMemo( () => {
242
+ const map = new Map< string, DashboardGridLayoutItem >();
243
+ activeLayout.forEach( ( item ) => map.set( item.key, item ) );
244
+ return map;
245
+ }, [ activeLayout ] );
246
+
247
+ // Stable-identity key set, preserved across renders whenever the
248
+ // *contents* of the key set are unchanged — even if the consumer
249
+ // passes a fresh `layout` array reference (common when `layout`
250
+ // is derived inline from state). Without this, downstream memos
251
+ // would invalidate on every parent re-render and the children
252
+ // walk skip during gestures wouldn't hold.
253
+ const layoutKeys = useMemo(
254
+ () => new Set( layout.map( ( item ) => item.key ) ),
255
+ [ layout ]
256
+ );
257
+
258
+ // Sorted item keys, identity-stable when the resulting sequence is
259
+ // unchanged. Avoids producing a fresh `items` array on every parent
260
+ // re-render so `<SortableContext>` doesn't update its context value
261
+ // and notify every `useSortable` subscriber unnecessarily.
262
+ const sortedItems = useMemo(
263
+ () =>
264
+ activeLayout
265
+ .map( ( item, index ) => ( { item, index } ) )
266
+ .sort(
267
+ ( a, b ) =>
268
+ ( a.item.order ?? a.index ) -
269
+ ( b.item.order ?? b.index )
270
+ )
271
+ .map( ( { item } ) => item.key ),
272
+ [ activeLayout ]
273
+ );
274
+ const items = sortedItems;
275
+
276
+ // Resolve `width: 'fill'` items to concrete column spans.
277
+ const resolvedItemMap = useMemo( () => {
278
+ const fillWidths = resolveFillWidths(
279
+ items,
280
+ layoutMap,
281
+ effectiveColumns
282
+ );
283
+ if ( fillWidths.size === 0 ) {
284
+ return layoutMap;
285
+ }
286
+ const map = new Map< string, DashboardGridLayoutItem >();
287
+ for ( const [ key, item ] of layoutMap ) {
288
+ const fillW = fillWidths.get( key );
289
+ map.set(
290
+ key,
291
+ fillW !== undefined ? { ...item, width: fillW } : item
292
+ );
293
+ }
294
+ return map;
295
+ }, [ items, layoutMap, effectiveColumns ] );
296
+
297
+ const [ childrenMap, actionableAreaMap, remaining, renderedByKey ] =
298
+ useMemo( () => {
299
+ const childMap = new Map< string, React.ReactElement >();
300
+ const actionableMap = new Map< string, React.ReactNode >();
301
+ const rest: React.ReactNode[] = [];
302
+ const byKey = new Map< string, React.ReactElement >();
303
+
304
+ Children.forEach( children, ( child ) => {
305
+ if ( ! isValidElement( child ) ) {
306
+ rest.push( child );
307
+ return;
308
+ }
309
+
310
+ const key = child.key?.toString();
311
+ if ( ! key ) {
312
+ rest.push( child );
313
+ return;
314
+ }
315
+
316
+ // Strip `actionableArea` so it does not leak to the DOM;
317
+ // the grid lifts it to a slot separately.
318
+ const { actionableArea } = child.props;
319
+ const stripped =
320
+ actionableArea !== undefined
321
+ ? cloneElement( child, {
322
+ actionableArea: undefined,
323
+ } )
324
+ : child;
325
+
326
+ byKey.set( key, stripped );
327
+
328
+ if ( layoutKeys.has( key ) ) {
329
+ if ( actionableArea !== undefined ) {
330
+ actionableMap.set( key, actionableArea );
331
+ }
332
+ childMap.set( key, stripped );
333
+ } else {
334
+ rest.push( child );
335
+ }
336
+ } );
337
+
338
+ return [ childMap, actionableMap, rest, byKey ];
339
+ }, [ children, layoutKeys ] );
340
+
341
+ // Persist the latest rendered children so a removed tile's content
342
+ // is still available for its exit overlay. Filled from an effect so a
343
+ // discarded render never writes to the cache.
344
+ useLayoutEffect( () => {
345
+ for ( const [ key, child ] of renderedByKey ) {
346
+ childrenCacheRef.current.set( key, child );
347
+ }
348
+ }, [ renderedByKey ] );
349
+
350
+ const sensors = useSensors(
351
+ useSensor( PointerSensor ),
352
+ useSensor( KeyboardSensor, {
353
+ coordinateGetter: sortableKeyboardCoordinates,
354
+ } )
355
+ );
356
+
357
+ const handleDragStart = useEvent( ( event: DragStartEvent ) => {
358
+ setActiveId( String( event.active.id ) );
359
+ lastReorderCursorRef.current = null;
360
+ } );
361
+
362
+ const handleDragCancel = useEvent( () => {
363
+ setActiveId( null );
364
+ latestLayoutRef.current = undefined;
365
+ lastReorderCursorRef.current = null;
366
+ resizeBaselineRef.current = null;
367
+ setIsResizing( false );
368
+ setResizeSnapPreview( null );
369
+ setTemporaryLayout( undefined );
370
+ } );
371
+
372
+ // Re-evaluate the insertion slot on every pointer move, not
373
+ // just when `over.id` changes — otherwise a "swap back" with
374
+ // the cursor still on the same tile would never fire.
375
+ const handleDragMove = useEvent( ( event: DragMoveEvent ) => {
376
+ const { active, over } = event;
377
+ if ( ! over || active.id === over.id ) {
378
+ return;
379
+ }
380
+
381
+ const activeRect = active.rect.current.translated;
382
+ if ( ! activeRect ) {
383
+ return;
384
+ }
385
+
386
+ const activeCenterX = activeRect.left + activeRect.width / 2;
387
+ const activeCenterY = activeRect.top + activeRect.height / 2;
388
+
389
+ // Skip re-measured events after a layout change: require
390
+ // meaningful cursor movement between reorders.
391
+ const lastCursor = lastReorderCursorRef.current;
392
+ if ( lastCursor ) {
393
+ const dx = activeCenterX - lastCursor.x;
394
+ const dy = activeCenterY - lastCursor.y;
395
+ if ( dx * dx + dy * dy < 100 ) {
396
+ return;
397
+ }
398
+ }
399
+
400
+ const overCenterX = over.rect.left + over.rect.width / 2;
401
+ const insertAfter = activeCenterX > overCenterX;
402
+
403
+ const currentIndex = items.indexOf( String( active.id ) );
404
+ const overIndex = items.indexOf( String( over.id ) );
405
+ let newIndex: number;
406
+ if ( insertAfter ) {
407
+ newIndex = currentIndex > overIndex ? overIndex + 1 : overIndex;
408
+ } else {
409
+ newIndex = currentIndex > overIndex ? overIndex : overIndex - 1;
410
+ }
411
+ newIndex = Math.max( 0, Math.min( newIndex, items.length - 1 ) );
412
+
413
+ if ( newIndex === currentIndex ) {
414
+ return;
415
+ }
416
+
417
+ const updatedItems = arrayMove( items, currentIndex, newIndex );
418
+ const updatedLayout = activeLayout.map( ( item ) => ( {
419
+ ...item,
420
+ order: updatedItems.indexOf( item.key ),
421
+ } ) );
422
+
423
+ lastReorderCursorRef.current = {
424
+ x: activeCenterX,
425
+ y: activeCenterY,
426
+ };
427
+ latestLayoutRef.current = updatedLayout;
428
+ captureLayoutSnapshotRef.current();
429
+ setTemporaryLayout( updatedLayout );
430
+ onPreviewLayout?.( updatedLayout );
431
+ } );
432
+
433
+ // Commit the latest temporary layout and clear local state.
434
+ // Reads from the ref to bypass React's state batching.
435
+ const persistTemporaryLayout = useEvent( () => {
436
+ const latest = latestLayoutRef.current;
437
+ latestLayoutRef.current = undefined;
438
+ resizeBaselineRef.current = null;
439
+ setIsResizing( false );
440
+ setResizeSnapPreview( null );
441
+ if ( ! onChangeLayout || ! latest ) {
442
+ setTemporaryLayout( undefined );
443
+ return;
444
+ }
445
+
446
+ onChangeLayout( latest );
447
+ setTemporaryLayout( undefined );
448
+ } );
449
+
450
+ const handleResize = useEvent( ( id: string, delta: ResizeDelta ) => {
451
+ if ( ! editMode ) {
452
+ return;
453
+ }
454
+
455
+ if ( ! isResizing ) {
456
+ setIsResizing( true );
457
+ }
458
+
459
+ const relativeDelta = {
460
+ width: Math.round( delta.width / ( columnWidth + gapPx ) ),
461
+ height:
462
+ rowHeight === 'auto'
463
+ ? 0
464
+ : Math.round( delta.height / ( rowHeight + gapPx ) ),
465
+ };
466
+
467
+ // Snapshot the baseline once at gesture start. The handle's
468
+ // `delta` is absolute from the gesture start, so summing it
469
+ // with the live (already mutated) `activeLayout` width would
470
+ // compound and oscillate — and stepping back through the
471
+ // zero-delta zone would never restore the original size.
472
+ if ( ! resizeBaselineRef.current ) {
473
+ const baseItem = activeLayout.find(
474
+ ( item ) => item.key === id
475
+ );
476
+ const resolvedItem = resolvedItemMap.get( id );
477
+ // `'fill'`/`'full'` resize from the rendered span
478
+ // and convert to a numeric width.
479
+ let baseWidth: number;
480
+ if ( baseItem?.width === 'full' ) {
481
+ baseWidth = effectiveColumns;
482
+ } else if ( baseItem?.width === 'fill' ) {
483
+ baseWidth =
484
+ typeof resolvedItem?.width === 'number'
485
+ ? resolvedItem.width
486
+ : 1;
487
+ } else {
488
+ baseWidth = baseItem?.width ?? 1;
489
+ }
490
+ resizeBaselineRef.current = {
491
+ width: baseWidth,
492
+ height: baseItem?.height ?? 1,
493
+ };
494
+ }
495
+ const baseline = resizeBaselineRef.current;
496
+ const newWidth = Math.max(
497
+ 1,
498
+ Math.min(
499
+ baseline.width + relativeDelta.width,
500
+ effectiveColumns
501
+ )
502
+ );
503
+ const newHeight = Math.max(
504
+ 1,
505
+ baseline.height + relativeDelta.height
506
+ );
507
+
508
+ setResizeSnapPreview( {
509
+ id,
510
+ snap: gridSpanToPixelSize(
511
+ newWidth,
512
+ newHeight,
513
+ columnWidth,
514
+ gapPx,
515
+ rowHeightPx
516
+ ),
517
+ } );
518
+
519
+ // Bail when the snapped size matches the layout already
520
+ // staged for commit. The tile still tracks the cursor
521
+ // continuously; only the preview outline and pending commit
522
+ // need updating when the snap target changes.
523
+ const pendingItem = latestLayoutRef.current?.find(
524
+ ( item ) => item.key === id
525
+ );
526
+ const currentItem =
527
+ pendingItem ?? activeLayout.find( ( item ) => item.key === id );
528
+ if (
529
+ currentItem &&
530
+ currentItem.width === newWidth &&
531
+ ( currentItem.height ?? 1 ) === newHeight
532
+ ) {
533
+ return;
534
+ }
535
+
536
+ const updatedLayout = activeLayout.map( ( item ) =>
537
+ item.key === id
538
+ ? { ...item, width: newWidth, height: newHeight }
539
+ : item
540
+ );
541
+
542
+ latestLayoutRef.current = updatedLayout;
543
+ captureLayoutSnapshotRef.current();
544
+ setTemporaryLayout( updatedLayout );
545
+ onPreviewLayout?.( updatedLayout );
546
+ } );
547
+
548
+ // Drag-overlay clone composition: the surface always wraps with a
549
+ // thin functional frame (lift, cursor, pointer pass-through). When
550
+ // `renderDragPreview` is supplied, the consumer's wrapper sits
551
+ // inside the frame around the cloned children; otherwise the
552
+ // cloned children render directly so any persistent chrome on
553
+ // them carries through unchanged.
554
+ const activeClone = activeId ? childrenMap.get( activeId ) : null;
555
+ const DragPreview = renderDragPreview;
556
+ const dragOverlayContent =
557
+ activeId && activeClone ? (
558
+ <div className={ styles[ 'drag-preview-frame' ] }>
559
+ <div className={ styles[ 'drag-preview-frame__lift' ] }>
560
+ { DragPreview ? (
561
+ <DragPreview itemId={ activeId }>
562
+ { activeClone }
563
+ </DragPreview>
564
+ ) : (
565
+ activeClone
566
+ ) }
567
+ </div>
568
+ </div>
569
+ ) : null;
570
+
571
+ // Edit-mode background. Rendered unconditionally so it can
572
+ // cross-fade on edit-mode toggles (`isActive` drives the
573
+ // transition); memoized so drag/resize re-renders skip it while
574
+ // inputs are stable. A numeric `rowHeight` adds row markers;
575
+ // `'auto'` collapses to `undefined` and omits them.
576
+ const Overlay = renderGridOverlay ?? GridOverlay;
577
+ const overlayRowHeight =
578
+ typeof rowHeight === 'number' ? rowHeight : undefined;
579
+ const overlayRows = useMemo( () => {
580
+ if ( overlayRowHeight === undefined || containerHeight <= 0 ) {
581
+ return undefined;
582
+ }
583
+ const rowTile = overlayRowHeight + gapPx;
584
+ return Math.max(
585
+ 1,
586
+ Math.floor( ( containerHeight + gapPx ) / rowTile )
587
+ );
588
+ }, [ overlayRowHeight, containerHeight, gapPx ] );
589
+ const gridOverlay = useMemo(
590
+ () => (
591
+ <Overlay
592
+ columns={ effectiveColumns }
593
+ rowHeight={ overlayRowHeight }
594
+ rows={ overlayRows }
595
+ isActive={ editMode }
596
+ />
597
+ ),
598
+ [
599
+ Overlay,
600
+ editMode,
601
+ effectiveColumns,
602
+ overlayRowHeight,
603
+ overlayRows,
604
+ ]
605
+ );
606
+
607
+ const layoutFingerprint = useMemo(
608
+ () => getLayoutFingerprint( [ ...resolvedItemMap.values() ] ),
609
+ [ resolvedItemMap ]
610
+ );
611
+ const excludeLayoutAnimationKey =
612
+ activeId ?? ( isResizing ? resizeSnapPreview?.id : null );
613
+ const { captureLayoutSnapshot, getPositionsBeforeLastChange } =
614
+ useLayoutShiftAnimation( {
615
+ container: gridRoot,
616
+ enabled: editMode,
617
+ layoutFingerprint,
618
+ excludeItemKey: excludeLayoutAnimationKey,
619
+ } );
620
+ const { exitingItems, clearExitingItem } = useItemExitAnimation( {
621
+ container: gridRoot,
622
+ enabled: editMode,
623
+ layoutKeys,
624
+ getPositionsBeforeLastChange,
625
+ childrenCacheRef,
626
+ } );
627
+ // Transform transitions on tiles for FLIP (drag, resize, removal).
628
+ const layoutAnimating = editMode;
629
+ useLayoutEffect( () => {
630
+ captureLayoutSnapshotRef.current = captureLayoutSnapshot;
631
+ }, [ captureLayoutSnapshot ] );
632
+
633
+ return (
634
+ <DndContext
635
+ sensors={ sensors }
636
+ onDragStart={ handleDragStart }
637
+ onDragCancel={ handleDragCancel }
638
+ onDragMove={ handleDragMove }
639
+ onDragEnd={ () => {
640
+ persistTemporaryLayout();
641
+ lastReorderCursorRef.current = null;
642
+ setActiveId( null );
643
+ } }
644
+ >
645
+ { /* No-op strategy: reorder comes from `temporaryLayout`
646
+ + CSS Grid, not dnd-kit transforms. */ }
647
+ <SortableContext items={ items } strategy={ NO_SORT_STRATEGY }>
648
+ <div
649
+ { ...divProps }
650
+ ref={ mergedGridRef }
651
+ className={ clsx(
652
+ styles.grid,
653
+ layoutAnimating &&
654
+ layoutAnimationStyles[ 'layout-animating' ],
655
+ className
656
+ ) }
657
+ data-wp-grid-dragging={ activeId || undefined }
658
+ data-wp-grid-resizing={ isResizing || undefined }
659
+ style={ {
660
+ ...style,
661
+ gridTemplateColumns: `repeat(${ effectiveColumns }, minmax(0, 1fr))`,
662
+ gridAutoRows: rowHeight,
663
+ } }
664
+ >
665
+ { gridOverlay }
666
+ { items.map( ( id ) => (
667
+ <GridItem
668
+ key={ id }
669
+ item={
670
+ resolvedItemMap.get(
671
+ id
672
+ ) as DashboardGridLayoutItem
673
+ }
674
+ maxColumns={ effectiveColumns }
675
+ disabled={ ! editMode }
676
+ verticalResizable={ rowHeight !== 'auto' }
677
+ interacting={ activeId !== null || isResizing }
678
+ dragging={ activeId !== null }
679
+ onResize={ handleResize }
680
+ onResizeEnd={ persistTemporaryLayout }
681
+ resizeSnapPreview={
682
+ resizeSnapPreview?.id === id
683
+ ? resizeSnapPreview.snap
684
+ : null
685
+ }
686
+ minResizeWidthPx={ minResizeWidthPx }
687
+ minResizeHeightPx={ minResizeHeightPx }
688
+ actionableArea={ actionableAreaMap.get( id ) }
689
+ renderResizeHandle={ renderResizeHandle }
690
+ >
691
+ { childrenMap.get( id ) }
692
+ </GridItem>
693
+ ) ) }
694
+ { remaining }
695
+ { exitingItems.map( ( { key, rect, child } ) => (
696
+ <ItemExitOverlay
697
+ key={ `exiting-${ key }` }
698
+ itemKey={ key }
699
+ rect={ rect }
700
+ onAnimationEnd={ () => clearExitingItem( key ) }
701
+ >
702
+ { child }
703
+ </ItemExitOverlay>
704
+ ) ) }
705
+ </div>
706
+ </SortableContext>
707
+ <DragOverlay dropAnimation={ dashboardDragDropAnimation }>
708
+ { dragOverlayContent }
709
+ </DragOverlay>
710
+ </DndContext>
711
+ );
712
+ }
713
+ );