@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,629 @@
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 { LanesItem } from './lanes-item';
39
+ import { useLanePlacement } from './use-lane-placement';
40
+ import { GridOverlay } from '../shared/grid-overlay';
41
+ import { gridSpanToPixelSize } from '../shared/resize-snap';
42
+ import layoutAnimationStyles from '../shared/layout-shift-animation.module.css';
43
+ import { ItemExitOverlay } from '../shared/item-exit-overlay';
44
+ import {
45
+ getLayoutFingerprint,
46
+ getPlacementFingerprint,
47
+ useLayoutShiftAnimation,
48
+ } from '../shared/use-layout-shift-animation';
49
+ import { useItemExitAnimation } from '../shared/use-item-exit-animation';
50
+ import type { DashboardLanesLayoutItem, DashboardLanesProps } from './types';
51
+ import type { ResizeSnapSize } from '../shared/resize-snap';
52
+ import type { ResizeDelta } from '../shared/types';
53
+ import { createDashboardDragDropAnimation } from '../shared/drag-overlay-drop-animation';
54
+ import styles from './lanes.module.css';
55
+
56
+ const dashboardDragDropAnimation = createDashboardDragDropAnimation(
57
+ styles[ 'drag-preview-frame' ],
58
+ styles.dragPreviewFrameExiting
59
+ );
60
+
61
+ // Fallback gap in pixels for math that runs before the computed gap
62
+ // can be read from the DOM. Matches the `'xl'` step the surface
63
+ // resolves to in CSS (`--wpds-dimension-gap-xl`); the next layout
64
+ // effect overwrites this with the actual computed value.
65
+ const FALLBACK_GAP_PX = 24;
66
+
67
+ // Default lane cap when no explicit `columns` or `minColumnWidth` is
68
+ // supplied. Layered semantics: `columns` acts as a cap and
69
+ // `minColumnWidth` as a per-tile floor; if neither is set we still
70
+ // need a finite count to render against.
71
+ const DEFAULT_COLUMNS = 6;
72
+
73
+ const NO_SORT_STRATEGY = () => null;
74
+
75
+ /**
76
+ * Masonry-style surface aligned with `display: grid-lanes`. Items
77
+ * declare a column span; heights are driven by content; placement
78
+ * follows the source-ordered, shortest-lane algorithm with
79
+ * `flow-tolerance` tiebreaking.
80
+ *
81
+ * On browsers that support `display: grid-lanes` natively, the
82
+ * component emits the spec's CSS and lets the engine handle layout.
83
+ * Otherwise, `useLanePlacement` measures item heights and assigns
84
+ * explicit `grid-column-start` / `grid-row-start` values that
85
+ * approximate the same result inside CSS Grid.
86
+ *
87
+ * Each child's `key` must match an entry in the `layout` array;
88
+ * children without a match render at the end of the surface without
89
+ * explicit placement and fall through the lanes auto-flow.
90
+ *
91
+ * @example
92
+ * ```jsx
93
+ * <DashboardLanes
94
+ * layout={ [
95
+ * { key: 'a' },
96
+ * { key: 'b', width: 2 },
97
+ * { key: 'c' },
98
+ * ] }
99
+ * columns={ 3 }
100
+ * editMode
101
+ * onChangeLayout={ setLayout }
102
+ * >
103
+ * <Tile key="a">A</Tile>
104
+ * <Tile key="b">B</Tile>
105
+ * <Tile key="c">C</Tile>
106
+ * </DashboardLanes>
107
+ * ```
108
+ *
109
+ * @param props Component props.
110
+ * @param ref Forwarded to the surface's root `<div>`.
111
+ */
112
+ export const DashboardLanes = forwardRef< HTMLDivElement, DashboardLanesProps >(
113
+ function DashboardLanes( props, ref ) {
114
+ const {
115
+ layout,
116
+ columns,
117
+ children,
118
+ className,
119
+ style,
120
+ flowTolerance = 16,
121
+ rowUnit = 4,
122
+ minColumnWidth,
123
+ editMode = false,
124
+ onChangeLayout,
125
+ onPreviewLayout,
126
+ renderResizeHandle,
127
+ renderDragPreview,
128
+ renderGridOverlay,
129
+ ...divProps
130
+ } = props;
131
+
132
+ const [ temporaryLayout, setTemporaryLayout ] = useState<
133
+ DashboardLanesLayoutItem[] | undefined
134
+ >();
135
+ const [ activeId, setActiveId ] = useState< string | null >( null );
136
+ const [ isResizing, setIsResizing ] = useState( false );
137
+ const [ resizeSnapPreview, setResizeSnapPreview ] = useState< {
138
+ id: string;
139
+ snap: ResizeSnapSize;
140
+ } | null >( null );
141
+ const latestLayoutRef = useRef<
142
+ DashboardLanesLayoutItem[] | undefined
143
+ >();
144
+ const lastReorderCursorRef = useRef< {
145
+ x: number;
146
+ y: number;
147
+ } | null >( null );
148
+ const resizeBaselineRef = useRef< number | null >( null );
149
+ const captureLayoutSnapshotRef = useRef< () => void >( () => {} );
150
+ const childrenCacheRef = useRef< Map< string, React.ReactElement > >(
151
+ new Map()
152
+ );
153
+ const activeLayout = temporaryLayout ?? layout;
154
+
155
+ const [ container, setContainer ] = useState< HTMLDivElement | null >(
156
+ null
157
+ );
158
+ const [ containerWidth, setContainerWidth ] = useState( 0 );
159
+ const [ gapPx, setGapPx ] = useState( FALLBACK_GAP_PX );
160
+ const resizeObserverRef = useResizeObserver(
161
+ ( [ { contentRect } ] ) => {
162
+ setContainerWidth( contentRect.width );
163
+ }
164
+ );
165
+ const mergedRootRef = useMergeRefs( [
166
+ setContainer,
167
+ resizeObserverRef,
168
+ ref,
169
+ ] );
170
+
171
+ // Measure synchronously before paint and snapshot the computed
172
+ // `column-gap` so the placement math tracks the design-system
173
+ // token under any density.
174
+ useLayoutEffect( () => {
175
+ if ( ! container ) {
176
+ return;
177
+ }
178
+ const { width } = container.getBoundingClientRect();
179
+ if ( width > 0 ) {
180
+ setContainerWidth( width );
181
+ }
182
+ const parsed = Number.parseFloat(
183
+ window.getComputedStyle( container ).columnGap
184
+ );
185
+ if ( Number.isFinite( parsed ) && parsed > 0 ) {
186
+ setGapPx( parsed );
187
+ }
188
+ }, [ container ] );
189
+ const effectiveColumns = useMemo( () => {
190
+ if ( ! minColumnWidth ) {
191
+ return columns ?? DEFAULT_COLUMNS;
192
+ }
193
+ const totalWidthPerColumn = minColumnWidth + gapPx;
194
+ const maxFit = Math.max(
195
+ 1,
196
+ Math.floor( ( containerWidth + gapPx ) / totalWidthPerColumn )
197
+ );
198
+ return columns !== undefined ? Math.min( columns, maxFit ) : maxFit;
199
+ }, [ minColumnWidth, gapPx, containerWidth, columns ] );
200
+ const columnWidth =
201
+ ( containerWidth - ( effectiveColumns - 1 ) * gapPx ) /
202
+ effectiveColumns;
203
+ const minResizeWidthPx = gridSpanToPixelSize(
204
+ 1,
205
+ 1,
206
+ columnWidth,
207
+ gapPx,
208
+ null
209
+ ).widthPx;
210
+
211
+ const layoutMap = useMemo( () => {
212
+ const map = new Map< string, DashboardLanesLayoutItem >();
213
+ activeLayout.forEach( ( item ) => map.set( item.key, item ) );
214
+ return map;
215
+ }, [ activeLayout ] );
216
+
217
+ // Stable-identity key set for the children walk (see grid.tsx).
218
+ const layoutKeys = useMemo(
219
+ () => new Set( layout.map( ( item ) => item.key ) ),
220
+ [ layout ]
221
+ );
222
+
223
+ // Sorted item keys, identity-stable when the resulting sequence
224
+ // is unchanged (avoids invalidating SortableContext).
225
+ const sortedItems = useMemo(
226
+ () =>
227
+ activeLayout
228
+ .map( ( item, index ) => ( { item, index } ) )
229
+ .sort(
230
+ ( a, b ) =>
231
+ ( a.item.order ?? a.index ) -
232
+ ( b.item.order ?? b.index )
233
+ )
234
+ .map( ( { item } ) => item.key ),
235
+ [ activeLayout ]
236
+ );
237
+ const items = sortedItems;
238
+
239
+ // Placement input for the hook: each item with its clamped span
240
+ // in source (sorted) order. `lane` forwards the optional explicit
241
+ // pin from the layout item; the algorithm clamps out-of-range
242
+ // values, so no surface-level guard is needed.
243
+ const placementItems = useMemo( () => {
244
+ return items.map( ( key ) => {
245
+ const item = layoutMap.get( key );
246
+ const width = item?.width;
247
+ const span =
248
+ typeof width === 'number'
249
+ ? Math.max( 1, Math.min( width, effectiveColumns ) )
250
+ : 1;
251
+ return { key, span, lane: item?.lane };
252
+ } );
253
+ }, [ items, layoutMap, effectiveColumns ] );
254
+
255
+ const { itemStyles } = useLanePlacement( container, {
256
+ items: placementItems,
257
+ lanes: effectiveColumns,
258
+ gap: gapPx,
259
+ flowTolerance,
260
+ rowUnit,
261
+ } );
262
+
263
+ const [ childrenMap, actionableAreaMap, remaining, renderedByKey ] =
264
+ useMemo( () => {
265
+ const childMap = new Map< string, React.ReactElement >();
266
+ const actionableMap = new Map< string, React.ReactNode >();
267
+ const rest: React.ReactNode[] = [];
268
+ const byKey = new Map< string, React.ReactElement >();
269
+
270
+ Children.forEach( children, ( child ) => {
271
+ if ( ! isValidElement( child ) ) {
272
+ rest.push( child );
273
+ return;
274
+ }
275
+ const key = child.key?.toString();
276
+ if ( ! key ) {
277
+ rest.push( child );
278
+ return;
279
+ }
280
+
281
+ // Strip `actionableArea` so it does not leak to the DOM;
282
+ // the grid lifts it to a slot separately.
283
+ const { actionableArea } = child.props as {
284
+ actionableArea?: React.ReactNode;
285
+ };
286
+ const stripped =
287
+ actionableArea !== undefined
288
+ ? cloneElement(
289
+ child as React.ReactElement< {
290
+ actionableArea?: React.ReactNode;
291
+ } >,
292
+ { actionableArea: undefined }
293
+ )
294
+ : ( child as React.ReactElement );
295
+
296
+ byKey.set( key, stripped );
297
+
298
+ if ( layoutKeys.has( key ) ) {
299
+ if ( actionableArea !== undefined ) {
300
+ actionableMap.set( key, actionableArea );
301
+ }
302
+ childMap.set( key, stripped );
303
+ } else {
304
+ rest.push( child );
305
+ }
306
+ } );
307
+
308
+ return [ childMap, actionableMap, rest, byKey ];
309
+ }, [ children, layoutKeys ] );
310
+
311
+ // Persist the latest rendered children so a removed tile's content
312
+ // is still available for its exit overlay. Filled from an effect so a
313
+ // discarded render never writes to the cache.
314
+ useLayoutEffect( () => {
315
+ for ( const [ key, child ] of renderedByKey ) {
316
+ childrenCacheRef.current.set( key, child );
317
+ }
318
+ }, [ renderedByKey ] );
319
+
320
+ const sensors = useSensors(
321
+ useSensor( PointerSensor ),
322
+ useSensor( KeyboardSensor, {
323
+ coordinateGetter: sortableKeyboardCoordinates,
324
+ } )
325
+ );
326
+
327
+ const handleDragStart = useEvent( ( event: DragStartEvent ) => {
328
+ setActiveId( String( event.active.id ) );
329
+ lastReorderCursorRef.current = null;
330
+ } );
331
+
332
+ const handleDragCancel = useEvent( () => {
333
+ setActiveId( null );
334
+ latestLayoutRef.current = undefined;
335
+ lastReorderCursorRef.current = null;
336
+ resizeBaselineRef.current = null;
337
+ setIsResizing( false );
338
+ setResizeSnapPreview( null );
339
+ setTemporaryLayout( undefined );
340
+ } );
341
+
342
+ const handleDragMove = useEvent( ( event: DragMoveEvent ) => {
343
+ const { active, over } = event;
344
+ if ( ! over || active.id === over.id ) {
345
+ return;
346
+ }
347
+ const activeRect = active.rect.current.translated;
348
+ if ( ! activeRect ) {
349
+ return;
350
+ }
351
+ const activeCenterX = activeRect.left + activeRect.width / 2;
352
+ const activeCenterY = activeRect.top + activeRect.height / 2;
353
+
354
+ const lastCursor = lastReorderCursorRef.current;
355
+ if ( lastCursor ) {
356
+ const dx = activeCenterX - lastCursor.x;
357
+ const dy = activeCenterY - lastCursor.y;
358
+ if ( dx * dx + dy * dy < 100 ) {
359
+ return;
360
+ }
361
+ }
362
+
363
+ const overCenterX = over.rect.left + over.rect.width / 2;
364
+ const insertAfter = activeCenterX > overCenterX;
365
+
366
+ const currentIndex = items.indexOf( String( active.id ) );
367
+ const overIndex = items.indexOf( String( over.id ) );
368
+ let newIndex: number;
369
+ if ( insertAfter ) {
370
+ newIndex = currentIndex > overIndex ? overIndex + 1 : overIndex;
371
+ } else {
372
+ newIndex = currentIndex > overIndex ? overIndex : overIndex - 1;
373
+ }
374
+ newIndex = Math.max( 0, Math.min( newIndex, items.length - 1 ) );
375
+
376
+ if ( newIndex === currentIndex ) {
377
+ return;
378
+ }
379
+
380
+ const updatedItems = arrayMove( items, currentIndex, newIndex );
381
+ // Build a key→index lookup so the .map below is O(n)
382
+ // instead of O(n²) from per-item `indexOf` calls.
383
+ const orderByKey = new Map< string, number >();
384
+ updatedItems.forEach( ( key, index ) => {
385
+ orderByKey.set( key, index );
386
+ } );
387
+ const updatedLayout = activeLayout.map( ( item ) => ( {
388
+ ...item,
389
+ order: orderByKey.get( item.key ) ?? 0,
390
+ } ) );
391
+
392
+ lastReorderCursorRef.current = {
393
+ x: activeCenterX,
394
+ y: activeCenterY,
395
+ };
396
+ latestLayoutRef.current = updatedLayout;
397
+ captureLayoutSnapshotRef.current();
398
+ setTemporaryLayout( updatedLayout );
399
+ onPreviewLayout?.( updatedLayout );
400
+ } );
401
+
402
+ const persistTemporaryLayout = useEvent( () => {
403
+ const latest = latestLayoutRef.current;
404
+ latestLayoutRef.current = undefined;
405
+ resizeBaselineRef.current = null;
406
+ setIsResizing( false );
407
+ setResizeSnapPreview( null );
408
+ if ( ! onChangeLayout || ! latest ) {
409
+ setTemporaryLayout( undefined );
410
+ return;
411
+ }
412
+
413
+ onChangeLayout( latest );
414
+ setTemporaryLayout( undefined );
415
+ } );
416
+
417
+ const handleResize = useEvent( ( id: string, delta: ResizeDelta ) => {
418
+ if ( ! editMode ) {
419
+ return;
420
+ }
421
+ if ( ! isResizing ) {
422
+ setIsResizing( true );
423
+ }
424
+
425
+ const relativeDelta = Math.round(
426
+ delta.width / ( columnWidth + gapPx )
427
+ );
428
+
429
+ if ( resizeBaselineRef.current === null ) {
430
+ const baseItem = layoutMap.get( id );
431
+ const baseWidth =
432
+ typeof baseItem?.width === 'number' ? baseItem.width : 1;
433
+ resizeBaselineRef.current = baseWidth;
434
+ }
435
+ const baseline = resizeBaselineRef.current;
436
+ const newWidth = Math.max(
437
+ 1,
438
+ Math.min( baseline + relativeDelta, effectiveColumns )
439
+ );
440
+
441
+ setResizeSnapPreview( {
442
+ id,
443
+ snap: gridSpanToPixelSize(
444
+ newWidth,
445
+ 1,
446
+ columnWidth,
447
+ gapPx,
448
+ null
449
+ ),
450
+ } );
451
+
452
+ const pendingItem = latestLayoutRef.current?.find(
453
+ ( item ) => item.key === id
454
+ );
455
+ const currentItem = pendingItem ?? layoutMap.get( id );
456
+ if ( currentItem && currentItem.width === newWidth ) {
457
+ return;
458
+ }
459
+
460
+ const updatedLayout = activeLayout.map( ( item ) =>
461
+ item.key === id ? { ...item, width: newWidth } : item
462
+ );
463
+
464
+ latestLayoutRef.current = updatedLayout;
465
+ captureLayoutSnapshotRef.current();
466
+ setTemporaryLayout( updatedLayout );
467
+ onPreviewLayout?.( updatedLayout );
468
+ } );
469
+
470
+ const interacting = activeId !== null || isResizing;
471
+
472
+ // Drag-overlay clone composition: the surface always wraps with a
473
+ // thin functional frame (lift, cursor, pointer pass-through). When
474
+ // `renderDragPreview` is supplied, the consumer's wrapper sits
475
+ // inside the frame around the cloned children; otherwise the
476
+ // cloned children render directly so any persistent chrome on
477
+ // them carries through unchanged.
478
+ const activeClone = activeId ? childrenMap.get( activeId ) : null;
479
+ const DragPreview = renderDragPreview;
480
+ const dragOverlayContent =
481
+ activeId && activeClone ? (
482
+ <div className={ styles[ 'drag-preview-frame' ] }>
483
+ <div className={ styles[ 'drag-preview-frame__lift' ] }>
484
+ { DragPreview ? (
485
+ <DragPreview itemId={ activeId }>
486
+ { activeClone }
487
+ </DragPreview>
488
+ ) : (
489
+ activeClone
490
+ ) }
491
+ </div>
492
+ </div>
493
+ ) : null;
494
+
495
+ // Edit-mode background. Lanes are content-driven vertically, so
496
+ // the overlay mirrors columns only. Rendered unconditionally so
497
+ // it can cross-fade on edit-mode toggles (`isActive` drives the
498
+ // transition); memoized so drag/resize re-renders skip it.
499
+ const Overlay = renderGridOverlay ?? GridOverlay;
500
+ const gridOverlay = useMemo(
501
+ () => (
502
+ <Overlay columns={ effectiveColumns } isActive={ editMode } />
503
+ ),
504
+ [ Overlay, editMode, effectiveColumns ]
505
+ );
506
+
507
+ const layoutFingerprint = useMemo( () => {
508
+ const layoutSig = getLayoutFingerprint( activeLayout );
509
+ const placementSig = getPlacementFingerprint( itemStyles );
510
+ return `${ layoutSig }\0${ placementSig }`;
511
+ }, [ activeLayout, itemStyles ] );
512
+ const excludeLayoutAnimationKey =
513
+ activeId ?? ( isResizing ? resizeSnapPreview?.id : null );
514
+ const { captureLayoutSnapshot, getPositionsBeforeLastChange } =
515
+ useLayoutShiftAnimation( {
516
+ container,
517
+ enabled: editMode,
518
+ layoutFingerprint,
519
+ excludeItemKey: excludeLayoutAnimationKey,
520
+ } );
521
+ const { exitingItems, clearExitingItem } = useItemExitAnimation( {
522
+ container,
523
+ enabled: editMode,
524
+ layoutKeys,
525
+ getPositionsBeforeLastChange,
526
+ childrenCacheRef,
527
+ } );
528
+ const layoutAnimating = editMode;
529
+ useLayoutEffect( () => {
530
+ captureLayoutSnapshotRef.current = captureLayoutSnapshot;
531
+ }, [ captureLayoutSnapshot ] );
532
+
533
+ return (
534
+ <DndContext
535
+ sensors={ sensors }
536
+ onDragStart={ handleDragStart }
537
+ onDragCancel={ handleDragCancel }
538
+ onDragMove={ handleDragMove }
539
+ onDragEnd={ () => {
540
+ persistTemporaryLayout();
541
+ lastReorderCursorRef.current = null;
542
+ setActiveId( null );
543
+ } }
544
+ >
545
+ <SortableContext items={ items } strategy={ NO_SORT_STRATEGY }>
546
+ <div
547
+ { ...divProps }
548
+ ref={ mergedRootRef }
549
+ className={ clsx(
550
+ styles.lanes,
551
+ layoutAnimating &&
552
+ layoutAnimationStyles[ 'layout-animating' ],
553
+ className
554
+ ) }
555
+ data-wp-grid-dragging={ activeId || undefined }
556
+ data-wp-grid-resizing={ isResizing || undefined }
557
+ style={
558
+ {
559
+ ...style,
560
+ gridTemplateColumns: `repeat(${ effectiveColumns }, minmax(0, 1fr))`,
561
+ // `column-gap` and `row-gap` are set in
562
+ // `lanes.module.css` from the
563
+ // design-system gap token, with an
564
+ // `@supports` block that zeroes `row-gap`
565
+ // in polyfill mode (the skyline already
566
+ // encodes vertical spacing in each tile's
567
+ // `top`). Driving the toggle from CSS
568
+ // keeps SSR and client output identical
569
+ // regardless of native support.
570
+ '--wp-grid-lane-row-unit': `${ Math.max(
571
+ 1,
572
+ rowUnit
573
+ ) }px`,
574
+ } as React.CSSProperties
575
+ }
576
+ >
577
+ { gridOverlay }
578
+ { items.map( ( id ) => {
579
+ const child = childrenMap.get( id );
580
+ if ( ! child ) {
581
+ return null;
582
+ }
583
+ return (
584
+ <LanesItem
585
+ key={ id }
586
+ itemKey={ id }
587
+ placementStyle={
588
+ itemStyles.get( id ) ?? {}
589
+ }
590
+ disabled={ ! editMode }
591
+ interacting={ interacting }
592
+ dragging={ activeId !== null }
593
+ onResize={ handleResize }
594
+ onResizeEnd={ persistTemporaryLayout }
595
+ resizeSnapPreview={
596
+ resizeSnapPreview?.id === id
597
+ ? resizeSnapPreview.snap
598
+ : null
599
+ }
600
+ minResizeWidthPx={ minResizeWidthPx }
601
+ actionableArea={ actionableAreaMap.get(
602
+ id
603
+ ) }
604
+ renderResizeHandle={ renderResizeHandle }
605
+ >
606
+ { child }
607
+ </LanesItem>
608
+ );
609
+ } ) }
610
+ { remaining }
611
+ { exitingItems.map( ( { key, rect, child } ) => (
612
+ <ItemExitOverlay
613
+ key={ `exiting-${ key }` }
614
+ itemKey={ key }
615
+ rect={ rect }
616
+ onAnimationEnd={ () => clearExitingItem( key ) }
617
+ >
618
+ { child }
619
+ </ItemExitOverlay>
620
+ ) ) }
621
+ </div>
622
+ </SortableContext>
623
+ <DragOverlay dropAnimation={ dashboardDragDropAnimation }>
624
+ { dragOverlayContent }
625
+ </DragOverlay>
626
+ </DndContext>
627
+ );
628
+ }
629
+ );