@wordpress/grid 0.1.1-next.v.202606191442.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,442 @@
1
+ /**
2
+ * Internal dependencies
3
+ */
4
+ import { computeLanePlacements } from '../lane-placement';
5
+ import type { LanePlacementItem } from '../lane-placement';
6
+
7
+ function items(
8
+ defs: Array< {
9
+ key: string;
10
+ span?: number;
11
+ height: number;
12
+ lane?: number;
13
+ } >
14
+ ): LanePlacementItem[] {
15
+ return defs.map( ( def ) => ( {
16
+ key: def.key,
17
+ span: def.span ?? 1,
18
+ height: def.height,
19
+ ...( def.lane !== undefined ? { lane: def.lane } : {} ),
20
+ } ) );
21
+ }
22
+
23
+ describe( 'computeLanePlacements', () => {
24
+ describe( 'empty and trivial inputs', () => {
25
+ it( 'returns an empty map and zero height for no items', () => {
26
+ const result = computeLanePlacements( {
27
+ items: [],
28
+ lanes: 4,
29
+ gap: 16,
30
+ flowTolerance: 0,
31
+ } );
32
+ expect( result.placements.size ).toBe( 0 );
33
+ expect( result.totalHeight ).toBe( 0 );
34
+ } );
35
+
36
+ it( 'places a single item at lane 0, top 0', () => {
37
+ const result = computeLanePlacements( {
38
+ items: items( [ { key: 'a', height: 100 } ] ),
39
+ lanes: 4,
40
+ gap: 16,
41
+ flowTolerance: 0,
42
+ } );
43
+ expect( result.placements.get( 'a' ) ).toEqual( {
44
+ key: 'a',
45
+ lane: 0,
46
+ top: 0,
47
+ span: 1,
48
+ } );
49
+ expect( result.totalHeight ).toBe( 100 );
50
+ } );
51
+ } );
52
+
53
+ describe( 'shortest-lane skyline', () => {
54
+ it( 'fills empty lanes left to right when all are zero', () => {
55
+ const result = computeLanePlacements( {
56
+ items: items( [
57
+ { key: 'a', height: 100 },
58
+ { key: 'b', height: 100 },
59
+ { key: 'c', height: 100 },
60
+ { key: 'd', height: 100 },
61
+ ] ),
62
+ lanes: 4,
63
+ gap: 0,
64
+ flowTolerance: 0,
65
+ } );
66
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
67
+ expect( result.placements.get( 'b' )?.lane ).toBe( 1 );
68
+ expect( result.placements.get( 'c' )?.lane ).toBe( 2 );
69
+ expect( result.placements.get( 'd' )?.lane ).toBe( 3 );
70
+ expect( result.totalHeight ).toBe( 100 );
71
+ } );
72
+
73
+ it( 'sends the next item to the shortest lane', () => {
74
+ // After placing tall A in lane 0 and short B in lane 1, the
75
+ // next item must land in lane 1 (the shortest).
76
+ const result = computeLanePlacements( {
77
+ items: items( [
78
+ { key: 'a', height: 200 },
79
+ { key: 'b', height: 50 },
80
+ { key: 'c', height: 80 },
81
+ ] ),
82
+ lanes: 2,
83
+ gap: 0,
84
+ flowTolerance: 0,
85
+ } );
86
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
87
+ expect( result.placements.get( 'b' )?.lane ).toBe( 1 );
88
+ expect( result.placements.get( 'c' )?.lane ).toBe( 1 );
89
+ expect( result.placements.get( 'c' )?.top ).toBe( 50 );
90
+ } );
91
+
92
+ it( 'continues to balance lanes across many items', () => {
93
+ const result = computeLanePlacements( {
94
+ items: items( [
95
+ { key: 'a', height: 100 },
96
+ { key: 'b', height: 50 },
97
+ { key: 'c', height: 30 },
98
+ { key: 'd', height: 30 },
99
+ { key: 'e', height: 30 },
100
+ ] ),
101
+ lanes: 3,
102
+ gap: 0,
103
+ flowTolerance: 0,
104
+ } );
105
+ // Lanes after each step: a → [100, 0, 0]; b → [100, 50, 0];
106
+ // c → [100, 50, 30]; d → [100, 50, 60]; e → [100, 80, 60].
107
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
108
+ expect( result.placements.get( 'b' )?.lane ).toBe( 1 );
109
+ expect( result.placements.get( 'c' )?.lane ).toBe( 2 );
110
+ expect( result.placements.get( 'd' )?.lane ).toBe( 2 );
111
+ expect( result.placements.get( 'e' )?.lane ).toBe( 1 );
112
+ expect( result.totalHeight ).toBe( 100 );
113
+ } );
114
+ } );
115
+
116
+ describe( 'gap accounting', () => {
117
+ it( 'does not add a gap before the first item in a lane', () => {
118
+ const result = computeLanePlacements( {
119
+ items: items( [ { key: 'a', height: 100 } ] ),
120
+ lanes: 1,
121
+ gap: 16,
122
+ flowTolerance: 0,
123
+ } );
124
+ expect( result.placements.get( 'a' )?.top ).toBe( 0 );
125
+ } );
126
+
127
+ it( 'adds a single gap between stacked items', () => {
128
+ const result = computeLanePlacements( {
129
+ items: items( [
130
+ { key: 'a', height: 100 },
131
+ { key: 'b', height: 50 },
132
+ ] ),
133
+ lanes: 1,
134
+ gap: 16,
135
+ flowTolerance: 0,
136
+ } );
137
+ expect( result.placements.get( 'a' )?.top ).toBe( 0 );
138
+ expect( result.placements.get( 'b' )?.top ).toBe( 116 );
139
+ expect( result.totalHeight ).toBe( 166 );
140
+ } );
141
+
142
+ it( 'tracks gaps independently per lane', () => {
143
+ const result = computeLanePlacements( {
144
+ items: items( [
145
+ { key: 'a', height: 100 },
146
+ { key: 'b', height: 100 },
147
+ { key: 'c', height: 50 },
148
+ ] ),
149
+ lanes: 2,
150
+ gap: 8,
151
+ flowTolerance: 0,
152
+ } );
153
+ expect( result.placements.get( 'a' )?.top ).toBe( 0 );
154
+ expect( result.placements.get( 'b' )?.top ).toBe( 0 );
155
+ // c stacks on the shorter lane; both started at 100 so
156
+ // either is acceptable, but with tolerance 0 the lane with
157
+ // the lowest baseline wins (tied → earliest lane keeps).
158
+ expect( result.placements.get( 'c' )?.top ).toBe( 108 );
159
+ } );
160
+ } );
161
+
162
+ describe( 'spanning', () => {
163
+ it( 'spans contiguous lanes and uses the tallest one as baseline', () => {
164
+ // A in lane 0 (h=100), B in lane 1 (h=50). C spans 2:
165
+ // must start where lane 0 (100) is clear → top=100+gap.
166
+ const result = computeLanePlacements( {
167
+ items: items( [
168
+ { key: 'a', height: 100 },
169
+ { key: 'b', height: 50 },
170
+ { key: 'c', span: 2, height: 80 },
171
+ ] ),
172
+ lanes: 2,
173
+ gap: 10,
174
+ flowTolerance: 0,
175
+ } );
176
+ expect( result.placements.get( 'c' ) ).toEqual( {
177
+ key: 'c',
178
+ lane: 0,
179
+ top: 110,
180
+ span: 2,
181
+ } );
182
+ expect( result.totalHeight ).toBe( 190 );
183
+ } );
184
+
185
+ it( 'finds the shortest run when multiple span positions exist', () => {
186
+ // Three lanes, baselines [100, 0, 100] after seeding.
187
+ // A span-2 item should pick lanes 1-2 (max=100) over
188
+ // lanes 0-1 (max=100); ties broken by earliest, so 0-1.
189
+ const result = computeLanePlacements( {
190
+ items: items( [
191
+ { key: 'seed-l', height: 100 },
192
+ { key: 'seed-r', lane: 2, height: 100 },
193
+ { key: 'span', span: 2, height: 50 },
194
+ ] ),
195
+ lanes: 3,
196
+ gap: 0,
197
+ flowTolerance: 0,
198
+ } );
199
+ expect( result.placements.get( 'span' )?.lane ).toBe( 0 );
200
+ } );
201
+
202
+ it( 'clamps span to the lane count', () => {
203
+ const result = computeLanePlacements( {
204
+ items: items( [ { key: 'a', span: 99, height: 100 } ] ),
205
+ lanes: 4,
206
+ gap: 0,
207
+ flowTolerance: 0,
208
+ } );
209
+ expect( result.placements.get( 'a' )?.span ).toBe( 4 );
210
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
211
+ } );
212
+
213
+ it( 'clamps span to a minimum of 1', () => {
214
+ const result = computeLanePlacements( {
215
+ items: items( [ { key: 'a', span: 0, height: 100 } ] ),
216
+ lanes: 4,
217
+ gap: 0,
218
+ flowTolerance: 0,
219
+ } );
220
+ expect( result.placements.get( 'a' )?.span ).toBe( 1 );
221
+ } );
222
+ } );
223
+
224
+ describe( 'flow tolerance', () => {
225
+ it( 'with tolerance 0, picks the strictly shortest lane', () => {
226
+ // Lanes after seeding: [100, 99]. Item should pick lane 1
227
+ // because 99 < 100 by 1, exceeding tolerance 0.
228
+ const result = computeLanePlacements( {
229
+ items: items( [
230
+ { key: 's0', height: 100 },
231
+ { key: 's1', lane: 1, height: 99 },
232
+ { key: 'next', height: 30 },
233
+ ] ),
234
+ lanes: 2,
235
+ gap: 0,
236
+ flowTolerance: 0,
237
+ } );
238
+ expect( result.placements.get( 'next' )?.lane ).toBe( 1 );
239
+ } );
240
+
241
+ it( 'with a generous tolerance, prefers the earlier lane', () => {
242
+ // Lanes [100, 99]. Tolerance 5 absorbs the 1px difference;
243
+ // reading order wins → lane 0.
244
+ const result = computeLanePlacements( {
245
+ items: items( [
246
+ { key: 's0', height: 100 },
247
+ { key: 's1', lane: 1, height: 99 },
248
+ { key: 'next', height: 30 },
249
+ ] ),
250
+ lanes: 2,
251
+ gap: 0,
252
+ flowTolerance: 5,
253
+ } );
254
+ expect( result.placements.get( 'next' )?.lane ).toBe( 0 );
255
+ } );
256
+
257
+ it( 'tolerance does not override a clearly shorter lane', () => {
258
+ // Lanes [100, 30]. Even with tolerance 5, the 70px gap
259
+ // between baselines is decisive → lane 1.
260
+ const result = computeLanePlacements( {
261
+ items: items( [
262
+ { key: 's0', height: 100 },
263
+ { key: 's1', lane: 1, height: 30 },
264
+ { key: 'next', height: 30 },
265
+ ] ),
266
+ lanes: 2,
267
+ gap: 0,
268
+ flowTolerance: 5,
269
+ } );
270
+ expect( result.placements.get( 'next' )?.lane ).toBe( 1 );
271
+ } );
272
+ } );
273
+
274
+ describe( 'explicit placement', () => {
275
+ it( 'honors an explicit lane index', () => {
276
+ const result = computeLanePlacements( {
277
+ items: items( [ { key: 'a', lane: 2, height: 50 } ] ),
278
+ lanes: 4,
279
+ gap: 0,
280
+ flowTolerance: 0,
281
+ } );
282
+ expect( result.placements.get( 'a' )?.lane ).toBe( 2 );
283
+ expect( result.placements.get( 'a' )?.top ).toBe( 0 );
284
+ } );
285
+
286
+ it( 'places explicit items first regardless of source order', () => {
287
+ // Source order: auto, explicit. Explicit must be placed
288
+ // first so the auto item flows around it.
289
+ const result = computeLanePlacements( {
290
+ items: items( [
291
+ { key: 'auto', height: 100 },
292
+ { key: 'pinned', lane: 0, height: 50 },
293
+ ] ),
294
+ lanes: 2,
295
+ gap: 0,
296
+ flowTolerance: 0,
297
+ } );
298
+ expect( result.placements.get( 'pinned' )?.lane ).toBe( 0 );
299
+ expect( result.placements.get( 'pinned' )?.top ).toBe( 0 );
300
+ // auto can't take lane 0 (occupied at top) — it lands in
301
+ // lane 1, the only zero-baseline lane available.
302
+ expect( result.placements.get( 'auto' )?.lane ).toBe( 1 );
303
+ expect( result.placements.get( 'auto' )?.top ).toBe( 0 );
304
+ } );
305
+
306
+ it( 'clamps an out-of-range explicit lane', () => {
307
+ const result = computeLanePlacements( {
308
+ items: items( [ { key: 'a', lane: 99, height: 50 } ] ),
309
+ lanes: 4,
310
+ gap: 0,
311
+ flowTolerance: 0,
312
+ } );
313
+ // `lane: 99` clamps to `lanes - span = 4 - 1 = 3`.
314
+ expect( result.placements.get( 'a' )?.lane ).toBe( 3 );
315
+ } );
316
+
317
+ it( 'clamps a negative explicit lane to 0', () => {
318
+ const result = computeLanePlacements( {
319
+ items: items( [ { key: 'a', lane: -5, height: 50 } ] ),
320
+ lanes: 4,
321
+ gap: 0,
322
+ flowTolerance: 0,
323
+ } );
324
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
325
+ } );
326
+
327
+ it( 'clamps an explicit lane that would push the span off-grid', () => {
328
+ // span 3 cannot start at lane 2 of a 4-lane grid (would
329
+ // occupy lanes 2-3-4 but lane 4 is out of range).
330
+ const result = computeLanePlacements( {
331
+ items: items( [ { key: 'a', lane: 2, span: 3, height: 50 } ] ),
332
+ lanes: 4,
333
+ gap: 0,
334
+ flowTolerance: 0,
335
+ } );
336
+ expect( result.placements.get( 'a' )?.lane ).toBe( 1 );
337
+ expect( result.placements.get( 'a' )?.span ).toBe( 3 );
338
+ } );
339
+ } );
340
+
341
+ describe( 'input clamping and edge cases', () => {
342
+ it( 'clamps lanes to a minimum of 1', () => {
343
+ const result = computeLanePlacements( {
344
+ items: items( [
345
+ { key: 'a', height: 50 },
346
+ { key: 'b', height: 50 },
347
+ ] ),
348
+ lanes: 0,
349
+ gap: 0,
350
+ flowTolerance: 0,
351
+ } );
352
+ expect( result.placements.get( 'a' )?.lane ).toBe( 0 );
353
+ expect( result.placements.get( 'b' )?.lane ).toBe( 0 );
354
+ expect( result.totalHeight ).toBe( 100 );
355
+ } );
356
+
357
+ it( 'treats a negative gap as zero', () => {
358
+ const result = computeLanePlacements( {
359
+ items: items( [
360
+ { key: 'a', height: 50 },
361
+ { key: 'b', height: 50 },
362
+ ] ),
363
+ lanes: 1,
364
+ gap: -10,
365
+ flowTolerance: 0,
366
+ } );
367
+ expect( result.placements.get( 'b' )?.top ).toBe( 50 );
368
+ } );
369
+
370
+ it( 'treats a negative tolerance as zero', () => {
371
+ // With tolerance treated as 0, the lane with the strictly
372
+ // lower baseline always wins.
373
+ const result = computeLanePlacements( {
374
+ items: items( [
375
+ { key: 's0', height: 100 },
376
+ { key: 's1', lane: 1, height: 99 },
377
+ { key: 'next', height: 30 },
378
+ ] ),
379
+ lanes: 2,
380
+ gap: 0,
381
+ flowTolerance: -50,
382
+ } );
383
+ expect( result.placements.get( 'next' )?.lane ).toBe( 1 );
384
+ } );
385
+
386
+ it( 'treats negative item heights as zero', () => {
387
+ const result = computeLanePlacements( {
388
+ items: items( [
389
+ { key: 'a', height: -50 },
390
+ { key: 'b', height: 30 },
391
+ ] ),
392
+ lanes: 1,
393
+ gap: 0,
394
+ flowTolerance: 0,
395
+ } );
396
+ expect( result.placements.get( 'b' )?.top ).toBe( 0 );
397
+ expect( result.totalHeight ).toBe( 30 );
398
+ } );
399
+ } );
400
+
401
+ describe( 'realistic scenarios', () => {
402
+ it( 'packs a mixed dashboard layout into 3 lanes', () => {
403
+ const result = computeLanePlacements( {
404
+ items: items( [
405
+ { key: 'kpi', height: 120 },
406
+ { key: 'chart', span: 2, height: 240 },
407
+ { key: 'note', height: 80 },
408
+ { key: 'feed', height: 300 },
409
+ { key: 'tasks', height: 160 },
410
+ ] ),
411
+ lanes: 3,
412
+ gap: 16,
413
+ flowTolerance: 16,
414
+ } );
415
+ expect( result.placements.size ).toBe( 5 );
416
+ expect( result.placements.get( 'kpi' )?.top ).toBe( 0 );
417
+ expect( result.placements.get( 'chart' )?.lane ).toBe( 1 );
418
+ expect( result.placements.get( 'chart' )?.span ).toBe( 2 );
419
+ expect( result.totalHeight ).toBeGreaterThan( 0 );
420
+ } );
421
+
422
+ it( 'is deterministic across calls with identical input', () => {
423
+ const input = {
424
+ items: items( [
425
+ { key: 'a', height: 100 },
426
+ { key: 'b', height: 50 },
427
+ { key: 'c', span: 2, height: 80 },
428
+ { key: 'd', height: 30 },
429
+ ] ),
430
+ lanes: 3,
431
+ gap: 8,
432
+ flowTolerance: 4,
433
+ };
434
+ const a = computeLanePlacements( input );
435
+ const b = computeLanePlacements( input );
436
+ expect( Array.from( a.placements.entries() ) ).toEqual(
437
+ Array.from( b.placements.entries() )
438
+ );
439
+ expect( a.totalHeight ).toBe( b.totalHeight );
440
+ } );
441
+ } );
442
+ } );