@dxos/react-ui-mosaic 0.9.1-staging.ee54ba693a → 0.11.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 (115) hide show
  1. package/dist/lib/chunk-Mosaic.mjs +1024 -0
  2. package/dist/lib/chunk-Mosaic.mjs.map +1 -0
  3. package/dist/lib/index.mjs +653 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/playwright.mjs +200 -0
  6. package/dist/lib/playwright.mjs.map +1 -0
  7. package/dist/lib/testing.mjs +127 -0
  8. package/dist/lib/testing.mjs.map +1 -0
  9. package/dist/lib/translations.mjs +11 -0
  10. package/dist/lib/translations.mjs.map +1 -0
  11. package/dist/types/src/components/Board/Board.d.ts +7 -27
  12. package/dist/types/src/components/Board/Board.d.ts.map +1 -1
  13. package/dist/types/src/components/Board/Board.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/Board/BoardColumnContext.d.ts +10 -0
  15. package/dist/types/src/components/Board/BoardColumnContext.d.ts.map +1 -0
  16. package/dist/types/src/components/Board/BoardContext.d.ts +26 -0
  17. package/dist/types/src/components/Board/BoardContext.d.ts.map +1 -0
  18. package/dist/types/src/components/Board/Column.d.ts +2 -7
  19. package/dist/types/src/components/Board/Column.d.ts.map +1 -1
  20. package/dist/types/src/components/Board/index.d.ts +2 -0
  21. package/dist/types/src/components/Board/index.d.ts.map +1 -1
  22. package/dist/types/src/components/Focus/Focus.d.ts.map +1 -1
  23. package/dist/types/src/components/Mosaic/Container.d.ts +6 -34
  24. package/dist/types/src/components/Mosaic/Container.d.ts.map +1 -1
  25. package/dist/types/src/components/Mosaic/Mosaic.d.ts +10 -15
  26. package/dist/types/src/components/Mosaic/Mosaic.d.ts.map +1 -1
  27. package/dist/types/src/components/Mosaic/MosaicContainerContext.d.ts +34 -0
  28. package/dist/types/src/components/Mosaic/MosaicContainerContext.d.ts.map +1 -0
  29. package/dist/types/src/components/Mosaic/MosaicTileContext.d.ts +31 -0
  30. package/dist/types/src/components/Mosaic/MosaicTileContext.d.ts.map +1 -0
  31. package/dist/types/src/components/Mosaic/Placeholder.d.ts +3 -3
  32. package/dist/types/src/components/Mosaic/Placeholder.d.ts.map +1 -1
  33. package/dist/types/src/components/Mosaic/Stack.d.ts +8 -2
  34. package/dist/types/src/components/Mosaic/Stack.d.ts.map +1 -1
  35. package/dist/types/src/components/Mosaic/Stack.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/Mosaic/Tile.d.ts +6 -32
  37. package/dist/types/src/components/Mosaic/Tile.d.ts.map +1 -1
  38. package/dist/types/src/components/Mosaic/VirtualStackPagination.stories.d.ts +13 -0
  39. package/dist/types/src/components/Mosaic/VirtualStackPagination.stories.d.ts.map +1 -0
  40. package/dist/types/src/components/Mosaic/index.d.ts +0 -1
  41. package/dist/types/src/components/Mosaic/index.d.ts.map +1 -1
  42. package/dist/types/src/components/SearchStack/SearchStack.d.ts +1 -1
  43. package/dist/types/src/components/SearchStack/SearchStack.d.ts.map +1 -1
  44. package/dist/types/src/hooks/index.d.ts +1 -0
  45. package/dist/types/src/hooks/index.d.ts.map +1 -1
  46. package/dist/types/src/hooks/useDefaultColumnEventHandler.d.ts +2 -2
  47. package/dist/types/src/hooks/useDefaultColumnEventHandler.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useEventHandlerAdapter.d.ts +3 -3
  49. package/dist/types/src/hooks/useEventHandlerAdapter.d.ts.map +1 -1
  50. package/dist/types/src/hooks/useVirtualizerPagination.d.ts +60 -0
  51. package/dist/types/src/hooks/useVirtualizerPagination.d.ts.map +1 -0
  52. package/dist/types/src/hooks/useVirtualizerPagination.test.d.ts +2 -0
  53. package/dist/types/src/hooks/useVirtualizerPagination.test.d.ts.map +1 -0
  54. package/dist/types/src/hooks/useVisibleItems.d.ts +2 -2
  55. package/dist/types/src/hooks/useVisibleItems.d.ts.map +1 -1
  56. package/dist/types/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +35 -37
  58. package/src/components/Board/Board.stories.tsx +7 -6
  59. package/src/components/Board/Board.tsx +9 -54
  60. package/src/components/Board/BoardColumnContext.ts +25 -0
  61. package/src/components/Board/BoardContext.ts +43 -0
  62. package/src/components/Board/Column.tsx +13 -27
  63. package/src/components/Board/Item.tsx +2 -2
  64. package/src/components/Board/index.ts +3 -0
  65. package/src/components/Mosaic/Container.tsx +31 -53
  66. package/src/components/Mosaic/DragHandle.tsx +1 -1
  67. package/src/components/Mosaic/{Mosaic.tsx → Mosaic.ts} +5 -16
  68. package/src/components/Mosaic/MosaicContainerContext.ts +44 -0
  69. package/src/components/Mosaic/MosaicTileContext.ts +34 -0
  70. package/src/components/Mosaic/Placeholder.tsx +7 -8
  71. package/src/components/Mosaic/ResizeHandle.stories.tsx +4 -4
  72. package/src/components/Mosaic/ResizeHandle.tsx +2 -2
  73. package/src/components/Mosaic/Stack.stories.tsx +49 -13
  74. package/src/components/Mosaic/Stack.tsx +30 -9
  75. package/src/components/Mosaic/Tile.tsx +33 -33
  76. package/src/components/Mosaic/VirtualStackPagination.stories.tsx +423 -0
  77. package/src/components/Mosaic/index.ts +0 -1
  78. package/src/hooks/index.ts +1 -0
  79. package/src/hooks/useDefaultColumnEventHandler.ts +2 -2
  80. package/src/hooks/useEventHandlerAdapter.ts +4 -5
  81. package/src/hooks/useVirtualizerPagination.test.tsx +105 -0
  82. package/src/hooks/useVirtualizerPagination.ts +473 -0
  83. package/src/hooks/useVisibleItems.ts +2 -2
  84. package/src/playwright/smoke.spec.ts +4 -1
  85. package/src/testing/DefaultStackTile.tsx +1 -1
  86. package/src/testing/decorator.tsx +3 -3
  87. package/dist/lib/browser/chunk-OBVIYZLR.mjs +0 -1119
  88. package/dist/lib/browser/chunk-OBVIYZLR.mjs.map +0 -7
  89. package/dist/lib/browser/index.mjs +0 -701
  90. package/dist/lib/browser/index.mjs.map +0 -7
  91. package/dist/lib/browser/meta.json +0 -1
  92. package/dist/lib/browser/playwright/index.mjs +0 -256
  93. package/dist/lib/browser/playwright/index.mjs.map +0 -7
  94. package/dist/lib/browser/testing/index.mjs +0 -136
  95. package/dist/lib/browser/testing/index.mjs.map +0 -7
  96. package/dist/lib/browser/translations.mjs +0 -18
  97. package/dist/lib/browser/translations.mjs.map +0 -7
  98. package/dist/lib/node-esm/chunk-4IEMCMIV.mjs +0 -1121
  99. package/dist/lib/node-esm/chunk-4IEMCMIV.mjs.map +0 -7
  100. package/dist/lib/node-esm/index.mjs +0 -702
  101. package/dist/lib/node-esm/index.mjs.map +0 -7
  102. package/dist/lib/node-esm/meta.json +0 -1
  103. package/dist/lib/node-esm/playwright/index.mjs +0 -258
  104. package/dist/lib/node-esm/playwright/index.mjs.map +0 -7
  105. package/dist/lib/node-esm/testing/index.mjs +0 -137
  106. package/dist/lib/node-esm/testing/index.mjs.map +0 -7
  107. package/dist/lib/node-esm/translations.mjs +0 -20
  108. package/dist/lib/node-esm/translations.mjs.map +0 -7
  109. package/dist/types/src/components/Mosaic/Root.d.ts +0 -35
  110. package/dist/types/src/components/Mosaic/Root.d.ts.map +0 -1
  111. package/dist/types/src/components/Mosaic/types.d.ts +0 -86
  112. package/dist/types/src/components/Mosaic/types.d.ts.map +0 -1
  113. package/src/components/Mosaic/Root.tsx +0 -288
  114. package/src/components/Mosaic/types.ts +0 -94
  115. /package/src/components/Focus/{Focus.tsx → Focus.ts} +0 -0
@@ -0,0 +1,473 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Virtualizer } from '@tanstack/react-virtual';
6
+ import { type MutableRefObject, useCallback, useLayoutEffect, useRef, useState } from 'react';
7
+
8
+ import { type GetId } from '@dxos/react-ui-dnd';
9
+
10
+ /** Rows from either loaded edge at which the next/previous page is requested. */
11
+ const DEFAULT_THRESHOLD = 12;
12
+
13
+ /** Index at/above which `getNext` fires; small windows clamp to the last row only. */
14
+ const nextPageIndexThreshold = (itemCount: number, threshold: number): number =>
15
+ itemCount > threshold ? itemCount - threshold : itemCount - 1;
16
+
17
+ /** True when loaded content exceeds the scroll viewport (user can actually scroll). */
18
+ const isScrollable = (virtualizer: Virtualizer<any, any>): boolean => {
19
+ const viewportHeight = virtualizer.scrollElement?.clientHeight ?? 0;
20
+ return viewportHeight > 0 && virtualizer.getTotalSize() > viewportHeight + 1;
21
+ };
22
+
23
+ export type VirtualizerPaginationController = {
24
+ /** Requests the next (e.g. older) page. Omit to disable the bottom-edge trigger. */
25
+ getNext?: () => void;
26
+ /** Requests the previous (e.g. newer) page. Omit to disable the top-edge trigger. */
27
+ getPrevious?: () => void;
28
+ /** When true, suppresses the top-edge trigger (window is anchored at the live head). */
29
+ atHead?: boolean;
30
+ /** When true, suppresses both edge triggers while a page is in flight. */
31
+ isLoading?: boolean;
32
+ };
33
+
34
+ export type UseVirtualizerPaginationProps<TItem = any> = {
35
+ /** Currently loaded window, in the order rendered by the virtualizer. */
36
+ items: readonly TItem[] | TItem[] | undefined;
37
+
38
+ /** ID getter, aligned with the virtualizer's own `getId`/`getItemKey`. */
39
+ getId: GetId<TItem>;
40
+
41
+ /** Load-next/load-previous callbacks. Both edges are inert while omitted. */
42
+ pagination?: VirtualizerPaginationController;
43
+
44
+ /** Rows from either loaded edge at which the adjacent page is requested. @default 12. */
45
+ threshold?: number;
46
+ };
47
+
48
+ export type UseVirtualizerPaginationResult = {
49
+ onChange: (virtualizer: Virtualizer<any, any>) => void;
50
+ /** Extra scrollable space (px) to feed into the virtualizer's `paddingStart`. */
51
+ leadingSpace: number;
52
+ };
53
+
54
+ //
55
+ // Edge-trigger evaluation
56
+ //
57
+
58
+ /** A retained item's identity and pre-change offset. */
59
+ type Anchor = { id: string; start: number };
60
+
61
+ /** Position of the loaded window relative to the viewport, independent of what's rendered. */
62
+ type EdgeGeometry = {
63
+ scrollOffset: number;
64
+ viewportHeight: number;
65
+ /** Index of the topmost/bottommost *rendered* row; undefined if nothing overlaps the viewport. */
66
+ firstVisibleIndex: number | undefined;
67
+ lastVisibleIndex: number | undefined;
68
+ /** Absolute start/end of the whole loaded window, defined even when nothing is rendered there. */
69
+ windowStart: number | undefined;
70
+ windowEnd: number | undefined;
71
+ };
72
+
73
+ const readEdgeGeometry = (virtualizer: Virtualizer<any, any>): EdgeGeometry => {
74
+ const virtualItems = virtualizer.getVirtualItems();
75
+ // `measurementsCache` covers the whole array even when nothing is rendered (deep in the spacer).
76
+ const measurements = virtualizer.measurementsCache;
77
+ return {
78
+ scrollOffset: virtualizer.scrollOffset ?? 0,
79
+ viewportHeight: virtualizer.scrollElement?.clientHeight ?? 0,
80
+ firstVisibleIndex: virtualItems.at(0)?.index,
81
+ lastVisibleIndex: virtualItems.at(-1)?.index,
82
+ windowStart: measurements[0]?.start,
83
+ windowEnd: measurements.at(-1)?.end,
84
+ };
85
+ };
86
+
87
+ const isNearBottomEdge = (geometry: EdgeGeometry, itemCount: number, threshold: number): boolean =>
88
+ geometry.lastVisibleIndex !== undefined
89
+ ? geometry.lastVisibleIndex >= nextPageIndexThreshold(itemCount, threshold)
90
+ : geometry.windowEnd != null && geometry.scrollOffset + geometry.viewportHeight > geometry.windowEnd;
91
+
92
+ const isNearTopEdge = (geometry: EdgeGeometry, threshold: number): boolean =>
93
+ geometry.firstVisibleIndex !== undefined
94
+ ? geometry.firstVisibleIndex < threshold
95
+ : geometry.windowStart != null && geometry.scrollOffset < geometry.windowStart;
96
+
97
+ /** Loaded content doesn't reach the viewport's top edge (e.g. scrolled into the leading spacer). */
98
+ const isBlankAbove = (geometry: EdgeGeometry): boolean =>
99
+ geometry.windowStart != null && geometry.windowStart > geometry.scrollOffset;
100
+
101
+ /** Loaded content doesn't reach the viewport's bottom edge. */
102
+ const isBlankBelow = (geometry: EdgeGeometry): boolean =>
103
+ geometry.windowEnd != null && geometry.windowEnd < geometry.scrollOffset + geometry.viewportHeight;
104
+
105
+ /** Per-direction dedup/throttle state for `evaluateTriggers`, independent of item type. */
106
+ type TriggerState = {
107
+ lastNextRequestedItemsRef: MutableRefObject<unknown>;
108
+ lastPreviousRequestedItemsRef: MutableRefObject<unknown>;
109
+ lastGetNextScrollRef: MutableRefObject<number | null>;
110
+ lastGetPreviousScrollRef: MutableRefObject<number | null>;
111
+ paginationRef: MutableRefObject<VirtualizerPaginationController | undefined>;
112
+ };
113
+
114
+ /** Not fired at this exact scroll offset since the edge was last left, unless blank re-arms it. */
115
+ const canRequestNext = (state: TriggerState, geometry: EdgeGeometry): boolean =>
116
+ state.lastGetNextScrollRef.current === null ||
117
+ geometry.scrollOffset !== state.lastGetNextScrollRef.current ||
118
+ isBlankBelow(geometry);
119
+
120
+ const canRequestPrevious = (state: TriggerState, geometry: EdgeGeometry): boolean =>
121
+ state.lastGetPreviousScrollRef.current === null ||
122
+ geometry.scrollOffset !== state.lastGetPreviousScrollRef.current ||
123
+ isBlankAbove(geometry);
124
+
125
+ const requestNext = (state: TriggerState, items: unknown, scrollOffset: number): void => {
126
+ state.lastNextRequestedItemsRef.current = items;
127
+ state.lastGetNextScrollRef.current = scrollOffset;
128
+ const getNext = state.paginationRef.current?.getNext;
129
+ queueMicrotask(() => getNext?.());
130
+ };
131
+
132
+ const requestPrevious = (state: TriggerState, items: unknown, scrollOffset: number): void => {
133
+ state.lastPreviousRequestedItemsRef.current = items;
134
+ state.lastGetPreviousScrollRef.current = scrollOffset;
135
+ const getPrevious = state.paginationRef.current?.getPrevious;
136
+ queueMicrotask(() => getPrevious?.());
137
+ };
138
+
139
+ type EvaluateTriggersOptions = {
140
+ virtualizer: Virtualizer<any, any>;
141
+ items: unknown;
142
+ pagination: VirtualizerPaginationController | undefined;
143
+ itemCount: number;
144
+ threshold: number;
145
+ state: TriggerState;
146
+ };
147
+
148
+ /**
149
+ * Requests `getNext`/`getPrevious` once the loaded window's edge nears the viewport. Deduped per
150
+ * direction and per `items` snapshot; re-armed while the viewport sits in blank space so a chain
151
+ * of pages loads back-to-back without further user scrolling.
152
+ */
153
+ const evaluateTriggers = ({ virtualizer, items, pagination, itemCount, threshold, state }: EvaluateTriggersOptions) => {
154
+ const geometry = readEdgeGeometry(virtualizer);
155
+ const scrollable = isScrollable(virtualizer);
156
+ const smallWindow = itemCount <= threshold;
157
+ const nearBottom = isNearBottomEdge(geometry, itemCount, threshold);
158
+ const nearTop = isNearTopEdge(geometry, threshold);
159
+
160
+ if (!nearBottom) {
161
+ state.lastGetNextScrollRef.current = null;
162
+ }
163
+ if (!nearTop) {
164
+ state.lastGetPreviousScrollRef.current = null;
165
+ }
166
+
167
+ const triggerNext =
168
+ !!pagination?.getNext &&
169
+ !pagination.isLoading &&
170
+ scrollable &&
171
+ nearBottom &&
172
+ (!smallWindow || geometry.scrollOffset > 0) &&
173
+ canRequestNext(state, geometry);
174
+ const triggerPrevious =
175
+ !!pagination?.getPrevious &&
176
+ !pagination.isLoading &&
177
+ scrollable &&
178
+ nearTop &&
179
+ pagination.atHead !== true &&
180
+ canRequestPrevious(state, geometry);
181
+
182
+ if (!triggerNext) {
183
+ state.lastNextRequestedItemsRef.current = undefined;
184
+ }
185
+ if (!triggerPrevious) {
186
+ state.lastPreviousRequestedItemsRef.current = undefined;
187
+ }
188
+
189
+ if (triggerNext && state.lastNextRequestedItemsRef.current !== items) {
190
+ requestNext(state, items, geometry.scrollOffset);
191
+ } else if (triggerPrevious && state.lastPreviousRequestedItemsRef.current !== items) {
192
+ requestPrevious(state, items, geometry.scrollOffset);
193
+ }
194
+ };
195
+
196
+ //
197
+ // Front-edge (eviction/prepend) reconciliation
198
+ //
199
+
200
+ /** Total height (px) of `prevItems[0, oldIndexOfNewFirst)`, per its outgoing measurements. */
201
+ const evictedPrefixHeight = (virtualizer: Virtualizer<any, any>, oldIndexOfNewFirst: number): number => {
202
+ const measurements = virtualizer.measurementsCache;
203
+ return (measurements[oldIndexOfNewFirst]?.start ?? 0) - (measurements[0]?.start ?? 0);
204
+ };
205
+
206
+ /**
207
+ * True when `items` is `prevItems` sliced from `oldIndexOfNewFirst` (a genuine slide/eviction/append).
208
+ * False for a reorder -- e.g. a conversation bumped to the head by a mid-sync reply -- whose old
209
+ * mid-list offset would otherwise be misread as evicted height and grow the spacer for good.
210
+ */
211
+ const isContiguousSlide = <TItem>(
212
+ prevItems: readonly TItem[],
213
+ items: readonly TItem[],
214
+ oldIndexOfNewFirst: number,
215
+ getId: GetId<TItem>,
216
+ ): boolean => {
217
+ const overlap = Math.min(items.length, prevItems.length - oldIndexOfNewFirst);
218
+ for (let index = 0; index < overlap; index++) {
219
+ if (getId(items[index]) !== getId(prevItems[oldIndexOfNewFirst + index])) {
220
+ return false;
221
+ }
222
+ }
223
+ return true;
224
+ };
225
+
226
+ /**
227
+ * Finds an item retained across a prepend, anchored on its pre-change offset. Scans from the tail
228
+ * (the common case -- a prepend leaves the rest of the window untouched) and doesn't require the
229
+ * item to be currently rendered, since `measurementsCache` covers the whole array.
230
+ */
231
+ const findRetainedAnchor = <TItem>(
232
+ prevItems: readonly TItem[],
233
+ newIds: Set<unknown>,
234
+ getId: GetId<TItem>,
235
+ virtualizer: Virtualizer<any, any>,
236
+ ): Anchor | null => {
237
+ for (let index = prevItems.length - 1; index >= 0; index--) {
238
+ const id = getId(prevItems[index]);
239
+ if (newIds.has(id)) {
240
+ const start = virtualizer.measurementsCache[index]?.start;
241
+ return start != null ? { id, start } : null;
242
+ }
243
+ }
244
+ return null;
245
+ };
246
+
247
+ type FrontEdgeChange =
248
+ | { kind: 'none' }
249
+ | { kind: 'evicted'; evictedHeight: number }
250
+ | { kind: 'prepended'; anchor: Anchor | null };
251
+
252
+ /**
253
+ * Classifies how `items` changed at the front, relative to `prevItems`, using only the outgoing
254
+ * window's own measurements (no need to wait for the incoming render). `'evicted'` covers both a
255
+ * prefix eviction (the window slid forward) and a pure append (the window grew) -- either way the
256
+ * item now at the front was already loaded, so its height is already known, *and* the retained
257
+ * items keep their old relative order (`isContiguousSlide`). `'prepended'` covers everything else:
258
+ * a `getPrevious` prepend, a reset to a disjoint range, or an existing item reordered to the head
259
+ * (e.g. conversation grouping bumping a thread on a new reply) -- despite the new head having been
260
+ * loaded before, nothing was actually evicted.
261
+ */
262
+ const classifyFrontEdgeChange = <TItem>(
263
+ prevItems: readonly TItem[] | TItem[] | undefined,
264
+ items: readonly TItem[] | TItem[] | undefined,
265
+ getId: GetId<TItem>,
266
+ virtualizer: Virtualizer<any, any> | null,
267
+ ): FrontEdgeChange => {
268
+ if (!virtualizer || !prevItems) {
269
+ return { kind: 'none' };
270
+ }
271
+ const firstNewId = items?.[0] != null ? getId(items[0]) : undefined;
272
+ const oldIndexOfNewFirst = firstNewId != null ? prevItems.findIndex((item) => getId(item) === firstNewId) : -1;
273
+ if (oldIndexOfNewFirst >= 0 && items && isContiguousSlide(prevItems, items, oldIndexOfNewFirst, getId)) {
274
+ return { kind: 'evicted', evictedHeight: evictedPrefixHeight(virtualizer, oldIndexOfNewFirst) };
275
+ }
276
+ const newIds = new Set(items?.map((item) => getId(item)));
277
+ return { kind: 'prepended', anchor: findRetainedAnchor(prevItems, newIds, getId, virtualizer) };
278
+ };
279
+
280
+ //
281
+ // Prepend correction (deferred to a layout effect; see the hook's own doc comment for why)
282
+ //
283
+
284
+ type PrependCorrection =
285
+ | { kind: 'no-overlap' }
286
+ | { kind: 'pending' }
287
+ | { kind: 'absorbed'; nextSpace: number }
288
+ | { kind: 'snap'; nextSpace: number; scrollAdjust: number };
289
+
290
+ /**
291
+ * `'no-overlap'`: the anchor isn't in the new `items` (e.g. a reset) -- the spacer no longer
292
+ * describes anything. `'pending'`: the incoming render hasn't measured the anchor yet. `'absorbed'`:
293
+ * the shift is fully absorbed by the spacer, no scroll rewrite needed. `'snap'`: growing past an
294
+ * empty spacer (or the `atHead` reset) leaves a remainder that has to move the scroll offset.
295
+ */
296
+ const computePrependCorrection = <TItem>(
297
+ virtualizer: Virtualizer<any, any>,
298
+ items: readonly TItem[] | TItem[],
299
+ getId: GetId<TItem>,
300
+ anchor: Anchor,
301
+ currentSpace: number,
302
+ atHead: boolean,
303
+ ): PrependCorrection => {
304
+ const newIndex = items.findIndex((item) => getId(item) === anchor.id);
305
+ if (newIndex < 0) {
306
+ return { kind: 'no-overlap' };
307
+ }
308
+ const newStart = virtualizer.measurementsCache[newIndex]?.start;
309
+ if (newStart == null) {
310
+ return { kind: 'pending' };
311
+ }
312
+ const shift = newStart - anchor.start;
313
+ const nextSpace = atHead ? 0 : Math.max(0, currentSpace - shift);
314
+ const scrollAdjust = shift + (nextSpace - currentSpace);
315
+ return scrollAdjust === 0 ? { kind: 'absorbed', nextSpace } : { kind: 'snap', nextSpace, scrollAdjust };
316
+ };
317
+
318
+ //
319
+ // Hook
320
+ //
321
+
322
+ /**
323
+ * Wires a paginated data source into a `Mosaic.VirtualStack`: requests the next/previous page near
324
+ * either loaded edge (`evaluateTriggers`), and keeps the scrollbar stable across the resulting
325
+ * `items` change by maintaining a leading spacer instead of rewriting the scroll offset.
326
+ *
327
+ * Called internally by `Mosaic.VirtualStack` when its `pagination` prop is set -- not normally
328
+ * called directly.
329
+ *
330
+ * `@tanstack/react-virtual` recomputes every item's pixel offset from the current array on each
331
+ * render, with no compensation for insertion/removal at the front -- rewriting the scroll offset
332
+ * to chase that shift visibly jumps the scrollbar thumb. Instead, `leadingSpace` (fed back into
333
+ * `paddingStart`) grows by exactly the evicted height and shrinks by exactly the prepended height,
334
+ * so retained items keep their absolute position and the thumb only scales as the total grows.
335
+ *
336
+ * Eviction (`classifyFrontEdgeChange`'s `'evicted'`) is corrected synchronously during the same
337
+ * render that observes the new `items`, since the evicted height is already known -- deferring it
338
+ * would let the browser observe (and clamp `scrollTop` against) a momentary, too-short frame.
339
+ * Prepending (`'prepended'`) is corrected one render later, from a layout effect
340
+ * (`computePrependCorrection`), since the prepended items' heights aren't known until the incoming
341
+ * render measures them; that lag is safe because prepending only ever grows content before the
342
+ * spacer shrinks to match.
343
+ *
344
+ * The layout effect also re-runs `evaluateTriggers` on every `items` change, evicted/appended or
345
+ * prepended alike -- `@tanstack/react-virtual` only calls `onChange` when the visible range (or
346
+ * `isScrolling`) changes, not merely when `items` does. Once the loaded window's tail is already
347
+ * fully rendered and the scrollbar sits at its physical max, further scrolling can't move that
348
+ * range at all, so `onChange` stops firing -- relying on it alone would permanently stall a
349
+ * `getNext` chain right after its own page lands, before the user scrolls again.
350
+ *
351
+ * Assumes the stack renders with `draggable={false}` (virtual indices map 1:1 onto `items`).
352
+ */
353
+ export const useVirtualizerPagination = <TItem = any>({
354
+ items,
355
+ getId,
356
+ pagination,
357
+ threshold = DEFAULT_THRESHOLD,
358
+ }: UseVirtualizerPaginationProps<TItem>): UseVirtualizerPaginationResult => {
359
+ const itemCount = items?.length ?? 0;
360
+
361
+ const [leadingSpace, setLeadingSpace] = useState(0);
362
+ const leadingSpaceRef = useRef(leadingSpace);
363
+ const setSpacer = useCallback((nextSpace: number) => {
364
+ if (nextSpace !== leadingSpaceRef.current) {
365
+ leadingSpaceRef.current = nextSpace;
366
+ setLeadingSpace(nextSpace);
367
+ }
368
+ }, []);
369
+
370
+ const virtualizerRef = useRef<Virtualizer<any, any> | null>(null);
371
+ const anchorRef = useRef<Anchor | null>(null);
372
+ const prevItemsRef = useRef(items);
373
+ // Suppresses trigger evaluation for the scroll event that our own correction below causes.
374
+ const restoringRef = useRef(false);
375
+
376
+ const triggerState: TriggerState = {
377
+ lastNextRequestedItemsRef: useRef<unknown>(undefined),
378
+ lastPreviousRequestedItemsRef: useRef<unknown>(undefined),
379
+ lastGetNextScrollRef: useRef<number | null>(null),
380
+ lastGetPreviousScrollRef: useRef<number | null>(null),
381
+ paginationRef: useRef(pagination),
382
+ };
383
+ triggerState.paginationRef.current = pagination;
384
+
385
+ const rearmTriggers = useCallback(
386
+ (virtualizer: Virtualizer<any, any>) => {
387
+ evaluateTriggers({
388
+ virtualizer,
389
+ items,
390
+ pagination: triggerState.paginationRef.current,
391
+ itemCount,
392
+ threshold,
393
+ state: triggerState,
394
+ });
395
+ },
396
+ // eslint-disable-next-line react-hooks/exhaustive-deps
397
+ [items, itemCount, threshold],
398
+ );
399
+
400
+ // Classifies the front-edge change during render, before `Mosaic.VirtualStack` re-renders with
401
+ // the new `items` -- at this point `virtualizerRef.current` still reflects the outgoing array.
402
+ // Skipped entirely when `pagination` is unset, so a caller not using pagination never gets
403
+ // spacer/anchor bookkeeping for an unrelated `items` change (e.g. a sort or filter).
404
+ if (prevItemsRef.current !== items) {
405
+ if (pagination) {
406
+ const change = classifyFrontEdgeChange(prevItemsRef.current, items, getId, virtualizerRef.current);
407
+ if (change.kind === 'evicted') {
408
+ setSpacer(leadingSpaceRef.current + change.evictedHeight);
409
+ anchorRef.current = null;
410
+ } else if (change.kind === 'prepended') {
411
+ anchorRef.current = change.anchor;
412
+ if (!change.anchor) {
413
+ // No retained item to anchor on (a disjoint reset): the layout effect below still
414
+ // re-arms triggers for this case, but its own correction/reset is anchor-driven, so
415
+ // clear the spacer here instead of waiting on it.
416
+ setSpacer(0);
417
+ }
418
+ }
419
+ }
420
+ prevItemsRef.current = items;
421
+ }
422
+
423
+ const onChange = useCallback(
424
+ (virtualizer: Virtualizer<any, any>) => {
425
+ virtualizerRef.current = virtualizer;
426
+ if (restoringRef.current) {
427
+ restoringRef.current = false;
428
+ return;
429
+ }
430
+ rearmTriggers(virtualizer);
431
+ },
432
+ [rearmTriggers],
433
+ );
434
+
435
+ useLayoutEffect(() => {
436
+ const virtualizer = virtualizerRef.current;
437
+ const anchor = anchorRef.current;
438
+ anchorRef.current = null;
439
+ if (!virtualizer || !items) {
440
+ return;
441
+ }
442
+ if (!anchor) {
443
+ // Evicted/appended (a `getNext` page or a slide), or an unclassified change -- no prepend to
444
+ // correct, but still re-arm (see the hook doc for why this can't be skipped).
445
+ rearmTriggers(virtualizer);
446
+ return;
447
+ }
448
+ const atHead = triggerState.paginationRef.current?.atHead === true;
449
+ const correction = computePrependCorrection(virtualizer, items, getId, anchor, leadingSpaceRef.current, atHead);
450
+ switch (correction.kind) {
451
+ case 'pending':
452
+ return;
453
+ case 'no-overlap':
454
+ setSpacer(0);
455
+ rearmTriggers(virtualizer);
456
+ return;
457
+ case 'absorbed':
458
+ setSpacer(correction.nextSpace);
459
+ rearmTriggers(virtualizer);
460
+ return;
461
+ case 'snap':
462
+ // A one-shot move, not a chain, so `rearmTriggers` is skipped: `virtualizer.scrollOffset`
463
+ // won't reflect this until the (suppressed) scroll event fires.
464
+ setSpacer(correction.nextSpace);
465
+ restoringRef.current = true;
466
+ virtualizer.scrollToOffset((virtualizer.scrollOffset ?? 0) + correction.scrollAdjust);
467
+ return;
468
+ }
469
+ // eslint-disable-next-line react-hooks/exhaustive-deps
470
+ }, [items, getId, setSpacer, rearmTriggers]);
471
+
472
+ return { onChange, leadingSpace };
473
+ };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { useMemo } from 'react';
6
6
 
7
- import { type GetId, type MosaicTileData } from '../components';
7
+ import { type DndTileData, type GetId } from '@dxos/react-ui-dnd';
8
8
 
9
9
  export type UseVisibleItemsProps<TItem = any> = {
10
10
  /** Container id. */
@@ -14,7 +14,7 @@ export type UseVisibleItemsProps<TItem = any> = {
14
14
  items?: readonly TItem[] | TItem[];
15
15
 
16
16
  /** Currently dragging item. */
17
- dragging: MosaicTileData<TItem> | undefined;
17
+ dragging: DndTileData<TItem> | undefined;
18
18
 
19
19
  /** ID getter */
20
20
  getId: GetId<TItem>;
@@ -20,7 +20,10 @@ test.describe('Board', () => {
20
20
 
21
21
  test.beforeEach(async ({ browser }) => {
22
22
  ({ page } = await setupPage(browser, { url: STORY_URL }));
23
- await page.getByTestId('board-column').first().waitFor({ state: 'visible' });
23
+ // Generous, and explicit: storybook compiles the story on first request, so whichever test runs
24
+ // first pays it. Waiting here attributes a slow story load to this locator rather than reporting a
25
+ // bare `beforeEach` timeout.
26
+ await page.getByTestId('board-column').first().waitFor({ state: 'visible', timeout: 45_000 });
24
27
  board = new BoardManager(page.locator('body'));
25
28
  });
26
29
 
@@ -34,7 +34,7 @@ export const DefaultStackTile: MosaicStackTileComponent<Obj.Any> = (props) => {
34
34
  */}
35
35
  <Mosaic.Tile {...props} asChild>
36
36
  <Focus.Item asChild>
37
- <Card.Root className='dx-current dx-hover'>
37
+ <Card.Root fullWidth classNames='dx-current dx-hover'>
38
38
  <Card.Header>
39
39
  <Card.DragHandle ref={dragHandleRef} />
40
40
  <Card.Title>{Obj.getLabel(props.data) ?? props.data.id}</Card.Title>
@@ -5,12 +5,12 @@
5
5
  import { type Decorator } from '@storybook/react-vite';
6
6
  import React from 'react';
7
7
 
8
- import { Mosaic } from '../components';
8
+ import { Dnd } from '@dxos/react-ui-dnd';
9
9
 
10
10
  export const withMosaic = (): Decorator => {
11
11
  return (Story) => (
12
- <Mosaic.Root>
12
+ <Dnd.Root>
13
13
  <Story />
14
- </Mosaic.Root>
14
+ </Dnd.Root>
15
15
  );
16
16
  };