@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,518 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import type { Meta, StoryObj } from '@storybook/react-vite';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { useState, useMemo } from '@wordpress/element';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import { DashboardLanes } from '..';
15
+ import type { DashboardLanesLayoutItem } from '../types';
16
+ import type { GridOverlayRenderProps } from '../../shared/types';
17
+
18
+ const meta: Meta< typeof DashboardLanes > = {
19
+ title: 'Grid/DashboardLanes',
20
+ component: DashboardLanes,
21
+ tags: [ 'status-experimental' ],
22
+ args: {
23
+ columns: 4,
24
+ flowTolerance: 16,
25
+ rowUnit: 4,
26
+ editMode: false,
27
+ },
28
+ argTypes: {
29
+ children: { control: false },
30
+ columns: {
31
+ control: { type: 'number', min: 1, max: 12, step: 1 },
32
+ description: 'Total lanes in fixed mode.',
33
+ },
34
+ minColumnWidth: {
35
+ control: { type: 'number', min: 80, max: 600, step: 8 },
36
+ description:
37
+ 'Enables responsive mode. Per-lane lower bound in pixels.',
38
+ },
39
+ flowTolerance: {
40
+ control: { type: 'number', min: 0, max: 64, step: 1 },
41
+ description:
42
+ 'Pixel tolerance for source-order tiebreaking when two lanes have similar baselines.',
43
+ },
44
+ rowUnit: {
45
+ control: { type: 'number', min: 1, max: 16, step: 1 },
46
+ description:
47
+ 'Polyfill snap unit (px). Ignored on browsers with native `display: grid-lanes` support.',
48
+ },
49
+ editMode: {
50
+ control: { type: 'boolean' },
51
+ description: 'Enables drag-to-reorder and horizontal resize.',
52
+ },
53
+ onChangeLayout: { action: 'onChangeLayout' },
54
+ onPreviewLayout: { action: 'onPreviewLayout' },
55
+ },
56
+ parameters: {
57
+ componentStatus: {
58
+ status: 'use-with-caution',
59
+ whereUsed: 'global',
60
+ notes: 'This package is under heavy development and likely to change.',
61
+ },
62
+ },
63
+ };
64
+ export default meta;
65
+
66
+ type Story = StoryObj< typeof DashboardLanes >;
67
+
68
+ type Tone = 'brand' | 'info' | 'success' | 'warning' | 'error' | 'neutral';
69
+
70
+ const bgTokens: Record< Tone, string > = {
71
+ brand: 'var(--wpds-color-background-surface-brand)',
72
+ info: 'var(--wpds-color-background-surface-info)',
73
+ success: 'var(--wpds-color-background-surface-success)',
74
+ warning: 'var(--wpds-color-background-surface-warning)',
75
+ error: 'var(--wpds-color-background-surface-error)',
76
+ neutral: 'var(--wpds-color-background-surface-neutral-weak)',
77
+ };
78
+
79
+ const fgTokens: Record< Tone, string > = {
80
+ // `brand` has no dedicated fg-content token in the design system,
81
+ // so neutral content reads safely against the brand surface tint.
82
+ brand: 'var(--wpds-color-foreground-content-neutral)',
83
+ info: 'var(--wpds-color-foreground-content-info)',
84
+ success: 'var(--wpds-color-foreground-content-success)',
85
+ warning: 'var(--wpds-color-foreground-content-warning)',
86
+ error: 'var(--wpds-color-foreground-content-error)',
87
+ neutral: 'var(--wpds-color-foreground-content-neutral)',
88
+ };
89
+
90
+ function Tile( {
91
+ tone,
92
+ height,
93
+ index,
94
+ children,
95
+ ...props
96
+ }: {
97
+ tone: Tone;
98
+ height: number;
99
+ index?: number;
100
+ children?: React.ReactNode;
101
+ } & React.HTMLAttributes< HTMLDivElement > ) {
102
+ return (
103
+ <div
104
+ { ...props }
105
+ style={ {
106
+ backgroundColor: bgTokens[ tone ],
107
+ color: fgTokens[ tone ],
108
+ padding: '12px 16px',
109
+ display: 'flex',
110
+ alignItems: 'flex-end',
111
+ justifyContent: 'center',
112
+ position: 'relative',
113
+ overflow: 'hidden',
114
+ height,
115
+ boxSizing: 'border-box',
116
+ fontFamily: 'var(--wpds-typography-font-family-body)',
117
+ fontSize: 'var(--wpds-typography-font-size-sm)',
118
+ borderRadius: 6,
119
+ ...props?.style,
120
+ } }
121
+ >
122
+ { index !== undefined && (
123
+ <span
124
+ aria-hidden
125
+ style={ {
126
+ position: 'absolute',
127
+ inset: 0,
128
+ display: 'flex',
129
+ alignItems: 'center',
130
+ justifyContent: 'center',
131
+ fontSize: '3rem',
132
+ fontWeight: 700,
133
+ opacity: 0.3,
134
+ pointerEvents: 'none',
135
+ userSelect: 'none',
136
+ } }
137
+ >
138
+ { index }
139
+ </span>
140
+ ) }
141
+ { children && (
142
+ <span style={ { position: 'relative' } }>{ children }</span>
143
+ ) }
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Mixed-height tiles in a fixed lane count. Items pack from
150
+ * left-to-right by source order, falling into whichever lane has
151
+ * the lowest baseline at the moment of placement.
152
+ */
153
+ export const Default: Story = {
154
+ args: {
155
+ columns: 4,
156
+ layout: [
157
+ { key: 'a' },
158
+ { key: 'b' },
159
+ { key: 'c' },
160
+ { key: 'd' },
161
+ { key: 'e' },
162
+ { key: 'f' },
163
+ { key: 'g' },
164
+ { key: 'h' },
165
+ ],
166
+ children: [
167
+ <Tile key="a" tone="brand" height={ 120 } index={ 1 }>
168
+ 120px
169
+ </Tile>,
170
+ <Tile key="b" tone="info" height={ 200 } index={ 2 }>
171
+ 200px
172
+ </Tile>,
173
+ <Tile key="c" tone="success" height={ 80 } index={ 3 }>
174
+ 80px
175
+ </Tile>,
176
+ <Tile key="d" tone="warning" height={ 160 } index={ 4 }>
177
+ 160px
178
+ </Tile>,
179
+ <Tile key="e" tone="error" height={ 100 } index={ 5 }>
180
+ 100px
181
+ </Tile>,
182
+ <Tile key="f" tone="neutral" height={ 240 } index={ 6 }>
183
+ 240px
184
+ </Tile>,
185
+ <Tile key="g" tone="brand" height={ 140 } index={ 7 }>
186
+ 140px
187
+ </Tile>,
188
+ <Tile key="h" tone="info" height={ 90 } index={ 8 }>
189
+ 90px
190
+ </Tile>,
191
+ ],
192
+ },
193
+ };
194
+
195
+ /**
196
+ * Responsive lane count: derived from container width using
197
+ * `minColumnWidth` as the per-lane lower bound. Resize the preview
198
+ * frame to see the lane count adapt.
199
+ */
200
+ export const Responsive: Story = {
201
+ args: {
202
+ minColumnWidth: 200,
203
+ layout: [
204
+ { key: 'a' },
205
+ { key: 'b' },
206
+ { key: 'c' },
207
+ { key: 'd' },
208
+ { key: 'e' },
209
+ { key: 'f' },
210
+ ],
211
+ children: [
212
+ <Tile key="a" tone="brand" height={ 120 } index={ 1 } />,
213
+ <Tile key="b" tone="info" height={ 200 } index={ 2 } />,
214
+ <Tile key="c" tone="success" height={ 80 } index={ 3 } />,
215
+ <Tile key="d" tone="warning" height={ 160 } index={ 4 } />,
216
+ <Tile key="e" tone="error" height={ 100 } index={ 5 } />,
217
+ <Tile key="f" tone="neutral" height={ 240 } index={ 6 } />,
218
+ ],
219
+ },
220
+ };
221
+
222
+ /**
223
+ * Layered configuration: `columns` caps the lane count and
224
+ * `minColumnWidth` enforces a per-tile width floor. The surface
225
+ * renders up to `columns` lanes on wide containers and reduces the
226
+ * count on narrow ones whenever fitting all of them would push
227
+ * tiles below `minColumnWidth`.
228
+ */
229
+ export const Layered: Story = {
230
+ args: {
231
+ columns: 4,
232
+ minColumnWidth: 200,
233
+ layout: [
234
+ { key: 'a' },
235
+ { key: 'b' },
236
+ { key: 'c' },
237
+ { key: 'd' },
238
+ { key: 'e' },
239
+ { key: 'f' },
240
+ ],
241
+ children: [
242
+ <Tile key="a" tone="brand" height={ 120 } index={ 1 } />,
243
+ <Tile key="b" tone="info" height={ 200 } index={ 2 } />,
244
+ <Tile key="c" tone="success" height={ 80 } index={ 3 } />,
245
+ <Tile key="d" tone="warning" height={ 160 } index={ 4 } />,
246
+ <Tile key="e" tone="error" height={ 100 } index={ 5 } />,
247
+ <Tile key="f" tone="neutral" height={ 240 } index={ 6 } />,
248
+ ],
249
+ },
250
+ };
251
+
252
+ /**
253
+ * Items with `width: 2` span two lanes. The skyline picks a span
254
+ * position that minimizes the resulting baseline across spanned
255
+ * lanes.
256
+ */
257
+ export const Spanning: Story = {
258
+ args: {
259
+ columns: 4,
260
+ layout: [
261
+ { key: 'a' },
262
+ { key: 'wide', width: 2 },
263
+ { key: 'b' },
264
+ { key: 'c' },
265
+ { key: 'd' },
266
+ { key: 'taller-wide', width: 2 },
267
+ { key: 'e' },
268
+ ],
269
+ children: [
270
+ <Tile key="a" tone="brand" height={ 120 } index={ 1 } />,
271
+ <Tile key="wide" tone="info" height={ 100 } index={ 2 }>
272
+ span 2
273
+ </Tile>,
274
+ <Tile key="b" tone="success" height={ 80 } index={ 3 } />,
275
+ <Tile key="c" tone="warning" height={ 200 } index={ 4 } />,
276
+ <Tile key="d" tone="error" height={ 90 } index={ 5 } />,
277
+ <Tile key="taller-wide" tone="neutral" height={ 160 } index={ 6 }>
278
+ span 2
279
+ </Tile>,
280
+ <Tile key="e" tone="brand" height={ 110 } index={ 7 } />,
281
+ ],
282
+ },
283
+ };
284
+
285
+ /**
286
+ * Edit mode: drag to reorder, resize from the bottom-right corner
287
+ * (horizontal only — heights are content-driven). Drop commits the
288
+ * new layout via `onChangeLayout`.
289
+ *
290
+ * While `editMode` is on, `<DashboardLanes />` paints its default
291
+ * overlay behind the tiles to mark the lane tracks. Lanes paint
292
+ * columns only — there are no row markers because heights are
293
+ * content-driven.
294
+ *
295
+ * Theme the default look in place via `--wp-grid-overlay-tile-bg`,
296
+ * or replace the visual wholesale
297
+ * by passing `renderGridOverlay`. See the `Custom Grid Overlay`
298
+ * story below for a full override example.
299
+ */
300
+ export const EditMode: Story = {
301
+ args: {
302
+ columns: 4,
303
+ editMode: true,
304
+ },
305
+ render: function EditModeStory( args ) {
306
+ const initial: ( DashboardLanesLayoutItem & {
307
+ tone: Tone;
308
+ height: number;
309
+ label: string;
310
+ } )[] = [
311
+ { key: 'a', tone: 'brand', height: 120, label: '120px' },
312
+ { key: 'b', tone: 'info', height: 200, label: '200px' },
313
+ {
314
+ key: 'wide',
315
+ width: 2,
316
+ tone: 'success',
317
+ height: 100,
318
+ label: 'span 2',
319
+ },
320
+ { key: 'c', tone: 'warning', height: 160, label: '160px' },
321
+ { key: 'd', tone: 'error', height: 90, label: '90px' },
322
+ { key: 'e', tone: 'neutral', height: 240, label: '240px' },
323
+ { key: 'f', tone: 'brand', height: 140, label: '140px' },
324
+ ];
325
+
326
+ const [ tiles, setTiles ] = useState( initial );
327
+
328
+ const layout: DashboardLanesLayoutItem[] = tiles.map(
329
+ ( { tone: _tone, height: _height, label: _label, ...item } ) => item
330
+ );
331
+
332
+ const onChangeLayout = ( next: DashboardLanesLayoutItem[] ) => {
333
+ setTiles(
334
+ next.map( ( item ) => {
335
+ const existing = tiles.find( ( t ) => t.key === item.key );
336
+ return {
337
+ ...item,
338
+ tone: existing?.tone ?? 'neutral',
339
+ height: existing?.height ?? 100,
340
+ label: existing?.label ?? item.key,
341
+ };
342
+ } )
343
+ );
344
+ };
345
+
346
+ const tileElements = useMemo(
347
+ () =>
348
+ tiles.map( ( tile, i ) => (
349
+ <Tile
350
+ key={ tile.key }
351
+ tone={ tile.tone }
352
+ height={ tile.height }
353
+ index={ i + 1 }
354
+ >
355
+ { tile.label }
356
+ </Tile>
357
+ ) ),
358
+ [ tiles ]
359
+ );
360
+
361
+ return (
362
+ <DashboardLanes
363
+ { ...args }
364
+ layout={ layout }
365
+ onChangeLayout={ onChangeLayout }
366
+ >
367
+ { tileElements }
368
+ </DashboardLanes>
369
+ );
370
+ },
371
+ };
372
+
373
+ /**
374
+ * Example custom overlay supplied to `<DashboardLanes />` through the
375
+ * `renderGridOverlay` prop. Receives `{ columns, isActive }` from the
376
+ * surface (no `rowHeight` because lane heights are content-driven).
377
+ * The custom must honor `isActive` for the same cross-fade behavior
378
+ * as the default; the surface always mounts the overlay.
379
+ *
380
+ * @param props Render props supplied by the surface.
381
+ * @param props.columns Number of lane tracks to mirror.
382
+ * @param props.isActive Whether the overlay should be visible.
383
+ */
384
+ function NumberedLanesOverlay( { columns, isActive }: GridOverlayRenderProps ) {
385
+ return (
386
+ <div
387
+ aria-hidden
388
+ style={ {
389
+ position: 'absolute',
390
+ inset: 0,
391
+ display: 'grid',
392
+ gridTemplateColumns: `repeat(${ columns }, minmax(0, 1fr))`,
393
+ gap: 'var(--wpds-dimension-gap-xl)',
394
+ pointerEvents: 'none',
395
+ opacity: isActive ? 1 : 0,
396
+ visibility: isActive ? 'visible' : 'hidden',
397
+ transition: isActive
398
+ ? 'opacity 200ms ease, visibility 0s linear 0s'
399
+ : 'opacity 200ms ease, visibility 0s linear 200ms',
400
+ backgroundImage: `repeating-linear-gradient(135deg, color-mix(in srgb, var(--wpds-color-background-surface-info) 24%, transparent) 0 6px, transparent 6px 12px)`,
401
+ } }
402
+ >
403
+ { Array.from( { length: columns } ).map( ( _, i ) => (
404
+ <div
405
+ key={ i }
406
+ style={ {
407
+ outline:
408
+ '1px dashed var(--wpds-color-stroke-surface-info)',
409
+ backgroundColor:
410
+ 'color-mix(in srgb, var(--wpds-color-background-surface-info) 10%, transparent)',
411
+ position: 'relative',
412
+ } }
413
+ >
414
+ <span
415
+ style={ {
416
+ position: 'absolute',
417
+ top: 4,
418
+ insetInlineStart: 4,
419
+ fontSize: 10,
420
+ padding: '1px 6px',
421
+ borderRadius: 2,
422
+ background:
423
+ 'var(--wpds-color-background-surface-info)',
424
+ color: 'var(--wpds-color-foreground-content-info)',
425
+ fontFamily:
426
+ 'var(--wpds-typography-font-family-mono)',
427
+ } }
428
+ >
429
+ { i + 1 }
430
+ </span>
431
+ </div>
432
+ ) ) }
433
+ </div>
434
+ );
435
+ }
436
+
437
+ /**
438
+ * Replaces the surface's default edit-mode overlay with a custom
439
+ * visual through the `renderGridOverlay` prop. The same contract as
440
+ * `<DashboardGrid />`'s override path, with `rowHeight` omitted from
441
+ * the render props because lanes are content-driven vertically.
442
+ *
443
+ * Pass `renderGridOverlay={ () => null }` to suppress the overlay
444
+ * entirely while keeping `editMode` interactions on.
445
+ */
446
+ export const CustomGridOverlayStory: Story = {
447
+ name: 'Custom Grid Overlay',
448
+ args: {
449
+ columns: 4,
450
+ editMode: true,
451
+ },
452
+ render: function CustomGridOverlayRender( args ) {
453
+ const initial: ( DashboardLanesLayoutItem & {
454
+ tone: Tone;
455
+ height: number;
456
+ label: string;
457
+ } )[] = [
458
+ { key: 'a', tone: 'brand', height: 140, label: '140px' },
459
+ { key: 'b', tone: 'info', height: 200, label: '200px' },
460
+ {
461
+ key: 'wide',
462
+ width: 2,
463
+ tone: 'success',
464
+ height: 120,
465
+ label: 'span 2',
466
+ },
467
+ { key: 'c', tone: 'warning', height: 180, label: '180px' },
468
+ { key: 'd', tone: 'error', height: 100, label: '100px' },
469
+ { key: 'e', tone: 'neutral', height: 220, label: '220px' },
470
+ ];
471
+
472
+ const [ tiles, setTiles ] = useState( initial );
473
+
474
+ const layout: DashboardLanesLayoutItem[] = tiles.map(
475
+ ( { tone: _tone, height: _height, label: _label, ...item } ) => item
476
+ );
477
+
478
+ const onChangeLayout = ( next: DashboardLanesLayoutItem[] ) => {
479
+ setTiles(
480
+ next.map( ( item ) => {
481
+ const existing = tiles.find( ( t ) => t.key === item.key );
482
+ return {
483
+ ...item,
484
+ tone: existing?.tone ?? 'neutral',
485
+ height: existing?.height ?? 100,
486
+ label: existing?.label ?? item.key,
487
+ };
488
+ } )
489
+ );
490
+ };
491
+
492
+ const tileElements = useMemo(
493
+ () =>
494
+ tiles.map( ( tile, i ) => (
495
+ <Tile
496
+ key={ tile.key }
497
+ tone={ tile.tone }
498
+ height={ tile.height }
499
+ index={ i + 1 }
500
+ >
501
+ { tile.label }
502
+ </Tile>
503
+ ) ),
504
+ [ tiles ]
505
+ );
506
+
507
+ return (
508
+ <DashboardLanes
509
+ { ...args }
510
+ layout={ layout }
511
+ onChangeLayout={ onChangeLayout }
512
+ renderGridOverlay={ NumberedLanesOverlay }
513
+ >
514
+ { tileElements }
515
+ </DashboardLanes>
516
+ );
517
+ },
518
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * External dependencies
7
+ */
8
+ import { render } from '@testing-library/react';
9
+
10
+ /**
11
+ * Internal dependencies
12
+ */
13
+ import { DashboardLanes } from '..';
14
+
15
+ class MockResizeObserver {
16
+ observed: Set< Element > = new Set();
17
+ observe( element: Element ) {
18
+ this.observed.add( element );
19
+ }
20
+ unobserve( element: Element ) {
21
+ this.observed.delete( element );
22
+ }
23
+ disconnect() {
24
+ this.observed.clear();
25
+ }
26
+ }
27
+
28
+ let originalResizeObserver: typeof ResizeObserver;
29
+
30
+ beforeEach( () => {
31
+ originalResizeObserver = global.ResizeObserver;
32
+ ( global as unknown as { ResizeObserver: unknown } ).ResizeObserver =
33
+ MockResizeObserver;
34
+ } );
35
+
36
+ afterEach( () => {
37
+ ( global as unknown as { ResizeObserver: unknown } ).ResizeObserver =
38
+ originalResizeObserver;
39
+ } );
40
+
41
+ describe( 'DashboardLanes keyboard activation', () => {
42
+ it( 'places the dnd-kit keyboard activator on the inner wrapper, not the outer item', () => {
43
+ // Verifies the DOM hierarchy: keyboard activation needs the
44
+ // focused node and the keydown listener to share a node, so
45
+ // the activator must live nested inside the outer item.
46
+ /* eslint-disable testing-library/no-container, testing-library/no-node-access */
47
+ const { container } = render(
48
+ <DashboardLanes layout={ [ { key: 'a' } ] } columns={ 2 } editMode>
49
+ <div key="a">A</div>
50
+ </DashboardLanes>
51
+ );
52
+
53
+ // Edit mode also renders a resize handle with `role="button"`;
54
+ // `aria-roledescription="sortable"` isolates the activator.
55
+ const activator = container.querySelector(
56
+ '[role="button"][aria-roledescription="sortable"]'
57
+ );
58
+ expect( activator ).not.toBeNull();
59
+ expect( activator ).toHaveAttribute( 'tabindex', '0' );
60
+
61
+ // Outer item is identified by `data-wp-grid-item-key`; the activator
62
+ // must be its descendant.
63
+ const lanesItem = container.querySelector(
64
+ '[data-wp-grid-item-key="a"]'
65
+ );
66
+ expect( lanesItem ).not.toBeNull();
67
+ expect( activator ).not.toBe( lanesItem );
68
+ expect( lanesItem!.contains( activator! ) ).toBe( true );
69
+ /* eslint-enable testing-library/no-container, testing-library/no-node-access */
70
+ } );
71
+ } );