@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE.md +788 -0
- package/README.md +534 -0
- package/build/dashboard-grid/grid-item.cjs +308 -0
- package/build/dashboard-grid/grid-item.cjs.map +7 -0
- package/build/dashboard-grid/index.cjs +591 -0
- package/build/dashboard-grid/index.cjs.map +7 -0
- package/build/dashboard-grid/resolve-fill-widths.cjs +189 -0
- package/build/dashboard-grid/resolve-fill-widths.cjs.map +7 -0
- package/build/dashboard-grid/types.cjs +19 -0
- package/build/dashboard-grid/types.cjs.map +7 -0
- package/build/dashboard-lanes/index.cjs +558 -0
- package/build/dashboard-lanes/index.cjs.map +7 -0
- package/build/dashboard-lanes/lane-placement.cjs +110 -0
- package/build/dashboard-lanes/lane-placement.cjs.map +7 -0
- package/build/dashboard-lanes/lanes-item.cjs +295 -0
- package/build/dashboard-lanes/lanes-item.cjs.map +7 -0
- package/build/dashboard-lanes/types.cjs +19 -0
- package/build/dashboard-lanes/types.cjs.map +7 -0
- package/build/dashboard-lanes/use-lane-placement.cjs +206 -0
- package/build/dashboard-lanes/use-lane-placement.cjs.map +7 -0
- package/build/index.cjs +34 -0
- package/build/index.cjs.map +7 -0
- package/build/shared/drag-overlay-drop-animation.cjs +70 -0
- package/build/shared/drag-overlay-drop-animation.cjs.map +7 -0
- package/build/shared/grid-item-key.cjs +31 -0
- package/build/shared/grid-item-key.cjs.map +7 -0
- package/build/shared/grid-overlay.cjs +187 -0
- package/build/shared/grid-overlay.cjs.map +7 -0
- package/build/shared/item-exit-overlay.cjs +150 -0
- package/build/shared/item-exit-overlay.cjs.map +7 -0
- package/build/shared/resize-handle.cjs +224 -0
- package/build/shared/resize-handle.cjs.map +7 -0
- package/build/shared/resize-snap.cjs +47 -0
- package/build/shared/resize-snap.cjs.map +7 -0
- package/build/shared/types.cjs +19 -0
- package/build/shared/types.cjs.map +7 -0
- package/build/shared/use-item-exit-animation.cjs +148 -0
- package/build/shared/use-item-exit-animation.cjs.map +7 -0
- package/build/shared/use-layout-shift-animation.cjs +167 -0
- package/build/shared/use-layout-shift-animation.cjs.map +7 -0
- package/build-module/dashboard-grid/grid-item.mjs +273 -0
- package/build-module/dashboard-grid/grid-item.mjs.map +7 -0
- package/build-module/dashboard-grid/index.mjs +579 -0
- package/build-module/dashboard-grid/index.mjs.map +7 -0
- package/build-module/dashboard-grid/resolve-fill-widths.mjs +164 -0
- package/build-module/dashboard-grid/resolve-fill-widths.mjs.map +7 -0
- package/build-module/dashboard-grid/types.mjs +1 -0
- package/build-module/dashboard-grid/types.mjs.map +7 -0
- package/build-module/dashboard-lanes/index.mjs +547 -0
- package/build-module/dashboard-lanes/index.mjs.map +7 -0
- package/build-module/dashboard-lanes/lane-placement.mjs +85 -0
- package/build-module/dashboard-lanes/lane-placement.mjs.map +7 -0
- package/build-module/dashboard-lanes/lanes-item.mjs +260 -0
- package/build-module/dashboard-lanes/lanes-item.mjs.map +7 -0
- package/build-module/dashboard-lanes/types.mjs +1 -0
- package/build-module/dashboard-lanes/types.mjs.map +7 -0
- package/build-module/dashboard-lanes/use-lane-placement.mjs +181 -0
- package/build-module/dashboard-lanes/use-lane-placement.mjs.map +7 -0
- package/build-module/index.mjs +8 -0
- package/build-module/index.mjs.map +7 -0
- package/build-module/shared/drag-overlay-drop-animation.mjs +47 -0
- package/build-module/shared/drag-overlay-drop-animation.mjs.map +7 -0
- package/build-module/shared/grid-item-key.mjs +6 -0
- package/build-module/shared/grid-item-key.mjs.map +7 -0
- package/build-module/shared/grid-overlay.mjs +152 -0
- package/build-module/shared/grid-overlay.mjs.map +7 -0
- package/build-module/shared/item-exit-overlay.mjs +125 -0
- package/build-module/shared/item-exit-overlay.mjs.map +7 -0
- package/build-module/shared/resize-handle.mjs +193 -0
- package/build-module/shared/resize-handle.mjs.map +7 -0
- package/build-module/shared/resize-snap.mjs +21 -0
- package/build-module/shared/resize-snap.mjs.map +7 -0
- package/build-module/shared/types.mjs +1 -0
- package/build-module/shared/types.mjs.map +7 -0
- package/build-module/shared/use-item-exit-animation.mjs +128 -0
- package/build-module/shared/use-item-exit-animation.mjs.map +7 -0
- package/build-module/shared/use-layout-shift-animation.mjs +140 -0
- package/build-module/shared/use-layout-shift-animation.mjs.map +7 -0
- package/build-types/dashboard-grid/grid-item.d.ts +3 -0
- package/build-types/dashboard-grid/grid-item.d.ts.map +1 -0
- package/build-types/dashboard-grid/index.d.ts +35 -0
- package/build-types/dashboard-grid/index.d.ts.map +1 -0
- package/build-types/dashboard-grid/resolve-fill-widths.d.ts +26 -0
- package/build-types/dashboard-grid/resolve-fill-widths.d.ts.map +1 -0
- package/build-types/dashboard-grid/stories/index.story.d.ts +98 -0
- package/build-types/dashboard-grid/stories/index.story.d.ts.map +1 -0
- package/build-types/dashboard-grid/types.d.ts +232 -0
- package/build-types/dashboard-grid/types.d.ts.map +1 -0
- package/build-types/dashboard-lanes/index.d.ts +40 -0
- package/build-types/dashboard-lanes/index.d.ts.map +1 -0
- package/build-types/dashboard-lanes/lane-placement.d.ts +126 -0
- package/build-types/dashboard-lanes/lane-placement.d.ts.map +1 -0
- package/build-types/dashboard-lanes/lanes-item.d.ts +52 -0
- package/build-types/dashboard-lanes/lanes-item.d.ts.map +1 -0
- package/build-types/dashboard-lanes/stories/index.story.d.ts +64 -0
- package/build-types/dashboard-lanes/stories/index.story.d.ts.map +1 -0
- package/build-types/dashboard-lanes/types.d.ts +151 -0
- package/build-types/dashboard-lanes/types.d.ts.map +1 -0
- package/build-types/dashboard-lanes/use-lane-placement.d.ts +74 -0
- package/build-types/dashboard-lanes/use-lane-placement.d.ts.map +1 -0
- package/build-types/index.d.ts +6 -0
- package/build-types/index.d.ts.map +1 -0
- package/build-types/shared/drag-overlay-drop-animation.d.ts +13 -0
- package/build-types/shared/drag-overlay-drop-animation.d.ts.map +1 -0
- package/build-types/shared/grid-item-key.d.ts +6 -0
- package/build-types/shared/grid-item-key.d.ts.map +1 -0
- package/build-types/shared/grid-overlay.d.ts +19 -0
- package/build-types/shared/grid-overlay.d.ts.map +1 -0
- package/build-types/shared/item-exit-overlay.d.ts +20 -0
- package/build-types/shared/item-exit-overlay.d.ts.map +1 -0
- package/build-types/shared/resize-handle.d.ts +23 -0
- package/build-types/shared/resize-handle.d.ts.map +1 -0
- package/build-types/shared/resize-snap.d.ts +41 -0
- package/build-types/shared/resize-snap.d.ts.map +1 -0
- package/build-types/shared/types.d.ts +144 -0
- package/build-types/shared/types.d.ts.map +1 -0
- package/build-types/shared/use-item-exit-animation.d.ts +37 -0
- package/build-types/shared/use-item-exit-animation.d.ts.map +1 -0
- package/build-types/shared/use-layout-shift-animation.d.ts +77 -0
- package/build-types/shared/use-layout-shift-animation.d.ts.map +1 -0
- package/package.json +80 -0
- package/src/dashboard-grid/grid-item.module.css +94 -0
- package/src/dashboard-grid/grid-item.tsx +205 -0
- package/src/dashboard-grid/grid.module.css +134 -0
- package/src/dashboard-grid/index.tsx +713 -0
- package/src/dashboard-grid/resolve-fill-widths.ts +224 -0
- package/src/dashboard-grid/stories/index.story.tsx +930 -0
- package/src/dashboard-grid/test/keyboard-activation.test.tsx +76 -0
- package/src/dashboard-grid/test/resolve-fill-widths.test.ts +250 -0
- package/src/dashboard-grid/types.ts +271 -0
- package/src/dashboard-lanes/index.tsx +629 -0
- package/src/dashboard-lanes/lane-placement.ts +245 -0
- package/src/dashboard-lanes/lanes-item.module.css +93 -0
- package/src/dashboard-lanes/lanes-item.tsx +236 -0
- package/src/dashboard-lanes/lanes.module.css +152 -0
- package/src/dashboard-lanes/stories/index.story.tsx +518 -0
- package/src/dashboard-lanes/test/keyboard-activation.test.tsx +71 -0
- package/src/dashboard-lanes/test/lane-placement.test.ts +442 -0
- package/src/dashboard-lanes/test/use-lane-placement.test.tsx +358 -0
- package/src/dashboard-lanes/types.ts +176 -0
- package/src/dashboard-lanes/use-lane-placement.ts +313 -0
- package/src/index.ts +17 -0
- package/src/shared/actionable-area-slot.module.css +16 -0
- package/src/shared/drag-overlay-drop-animation.ts +66 -0
- package/src/shared/grid-item-key.ts +5 -0
- package/src/shared/grid-overlay.module.css +82 -0
- package/src/shared/grid-overlay.tsx +93 -0
- package/src/shared/item-exit-animation.module.css +49 -0
- package/src/shared/item-exit-overlay.tsx +57 -0
- package/src/shared/layout-shift-animation.module.css +16 -0
- package/src/shared/resize-handle.module.css +88 -0
- package/src/shared/resize-handle.tsx +163 -0
- package/src/shared/resize-snap.ts +63 -0
- package/src/shared/test/resize-snap.test.ts +35 -0
- package/src/shared/types.ts +164 -0
- package/src/shared/use-item-exit-animation.ts +199 -0
- 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
|
+
);
|