@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,423 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Schema from 'effect/Schema';
8
+ import React, { type FC, useCallback, useMemo, useRef, useState } from 'react';
9
+
10
+ import { Database, DXN, Feed, Filter, Obj, Order, Query, Type } from '@dxos/echo';
11
+ import { usePagination, useQuery } from '@dxos/echo-react';
12
+ import { random } from '@dxos/random';
13
+ import { type Client, useClient } from '@dxos/react-client';
14
+ import { useSpaces } from '@dxos/react-client/echo';
15
+ import { persistentClientServices, withClientProvider } from '@dxos/react-client/testing';
16
+ import { Button, Card, Input, Panel, ScrollArea, Select, Toolbar } from '@dxos/react-ui';
17
+ import { Dnd } from '@dxos/react-ui-dnd';
18
+ import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
19
+
20
+ import { Focus } from '../Focus';
21
+ import { Mosaic } from './Mosaic';
22
+ import { type MosaicTileProps } from './Tile';
23
+
24
+ const PAGE_SIZE = 50;
25
+ const MAX_WINDOW_SIZE = PAGE_SIZE * 10;
26
+
27
+ //
28
+ // In-memory story (no ECHO): windows a plain array to show `Mosaic.VirtualStack`'s `pagination`
29
+ // prop needs only a `{ getNext, getPrevious }` shape.
30
+ //
31
+
32
+ /** Total size of the synthetic, in-memory data source. */
33
+ const TOTAL_ITEMS = 5_000;
34
+
35
+ type ListItem = { id: string; index: number };
36
+
37
+ type Range = { skip: number; limit: number };
38
+
39
+ /**
40
+ * Windows a plain in-memory array the same way `usePagination` windows a live query: a page
41
+ * size, a bounded max window, and getNext/getPrevious that grow the window until it hits
42
+ * `MAX_WINDOW_SIZE`, then slide it instead.
43
+ */
44
+ const usePaginatedItems = (total: number) => {
45
+ const [range, setRange] = useState<Range>({ skip: 0, limit: PAGE_SIZE });
46
+ const pendingRef = useRef(false);
47
+
48
+ const items = useMemo<ListItem[]>(() => {
49
+ const count = Math.max(0, Math.min(range.limit, total - range.skip));
50
+ return Array.from({ length: count }, (_, i) => {
51
+ const index = range.skip + i;
52
+ return { id: String(index), index };
53
+ });
54
+ }, [range, total]);
55
+
56
+ const getNext = useCallback(() => {
57
+ if (pendingRef.current) {
58
+ return;
59
+ }
60
+ pendingRef.current = true;
61
+ queueMicrotask(() => {
62
+ pendingRef.current = false;
63
+ });
64
+ setRange((prev) => {
65
+ if (prev.skip + prev.limit >= total) {
66
+ return prev;
67
+ }
68
+ const nextLimit = prev.limit + PAGE_SIZE;
69
+ return nextLimit <= MAX_WINDOW_SIZE
70
+ ? { ...prev, limit: nextLimit }
71
+ : { limit: MAX_WINDOW_SIZE, skip: prev.skip + PAGE_SIZE };
72
+ });
73
+ }, [total]);
74
+
75
+ const getPrevious = useCallback(() => {
76
+ if (pendingRef.current) {
77
+ return;
78
+ }
79
+ pendingRef.current = true;
80
+ queueMicrotask(() => {
81
+ pendingRef.current = false;
82
+ });
83
+ setRange((prev) => (prev.skip === 0 ? prev : { ...prev, skip: Math.max(0, prev.skip - PAGE_SIZE) }));
84
+ }, []);
85
+
86
+ return { items, getNext, getPrevious, atHead: range.skip === 0 };
87
+ };
88
+
89
+ const ListItemTile: FC<MosaicTileProps<ListItem>> = ({ data, location, current }) => (
90
+ <Mosaic.Tile id={data.id} data={data} location={location} current={current} asChild>
91
+ <Focus.Item asChild>
92
+ <Card.Root>
93
+ <Card.Header>
94
+ <Card.Title>Item #{data.index}</Card.Title>
95
+ </Card.Header>
96
+ </Card.Root>
97
+ </Focus.Item>
98
+ </Mosaic.Tile>
99
+ );
100
+
101
+ const VirtualStackPaginationStory = () => {
102
+ const { items, getNext, getPrevious, atHead } = usePaginatedItems(TOTAL_ITEMS);
103
+ const pagination = useMemo(() => ({ getNext, getPrevious, atHead }), [getNext, getPrevious, atHead]);
104
+ const [viewport, setViewport] = useState<HTMLElement | null>(null);
105
+
106
+ const range = items.length > 0 ? `${items[0].index}–${items[items.length - 1].index}` : '–';
107
+
108
+ return (
109
+ <Dnd.Root>
110
+ <Panel.Root>
111
+ <Panel.Toolbar asChild>
112
+ <Toolbar.Root>
113
+ <div className='flex grow justify-center'>
114
+ Range {range} of {TOTAL_ITEMS} ({items.length} loaded) {atHead && '(at head)'}
115
+ </div>
116
+ </Toolbar.Root>
117
+ </Panel.Toolbar>
118
+ <Panel.Content asChild>
119
+ <Mosaic.Container asChild eventHandler={{ id: 'virtual-stack-pagination', canDrop: () => false }}>
120
+ <ScrollArea.Root orientation='vertical'>
121
+ <ScrollArea.Viewport ref={setViewport}>
122
+ <Mosaic.VirtualStack
123
+ Tile={ListItemTile}
124
+ items={items}
125
+ draggable={false}
126
+ getId={(item) => item.id}
127
+ getScrollElement={() => viewport}
128
+ estimateSize={() => 56}
129
+ gap={4}
130
+ pagination={pagination}
131
+ />
132
+ </ScrollArea.Viewport>
133
+ </ScrollArea.Root>
134
+ </Mosaic.Container>
135
+ </Panel.Content>
136
+ </Panel.Root>
137
+ </Dnd.Root>
138
+ );
139
+ };
140
+
141
+ //
142
+ // Feed-backed story (live ECHO): end-to-end pagination over a feed, with runtime append and sort.
143
+ //
144
+
145
+ /** Items seeded into the feed on space creation. */
146
+ const INITIAL_ITEMS = 100;
147
+ /** Default value of the toolbar's "add" count input. */
148
+ const DEFAULT_ADD_COUNT = 50;
149
+
150
+ class CounterItem extends Type.makeObject<CounterItem>(DXN.make('com.example.type.counterItem', '0.1.0'))(
151
+ Schema.Struct({
152
+ index: Schema.Number,
153
+ word: Schema.String,
154
+ }),
155
+ ) {}
156
+
157
+ /**
158
+ * Persistent, monotonically-advancing index used to number new items. Lives in the space db (not
159
+ * the feed) so item numbering continues correctly across reloads rather than restarting.
160
+ */
161
+ class Counter extends Type.makeObject<Counter>(DXN.make('com.example.type.paginationCounter', '0.1.0'))(
162
+ Schema.Struct({
163
+ next: Schema.Number,
164
+ }),
165
+ ) {}
166
+
167
+ type SortField = 'natural' | 'number' | 'word';
168
+ type SortDirection = 'asc' | 'desc';
169
+
170
+ const makeItems = (start: number, count: number): CounterItem[] =>
171
+ Array.from({ length: count }, (_, i) => Obj.make(CounterItem, { index: start + i, word: random.lorem.word() }));
172
+
173
+ const appendItems = (db: Database.Database, feed: Feed.Feed, start: number, count: number): Promise<void> =>
174
+ Feed.append(feed, makeItems(start, count)).pipe(Effect.provide(Database.layer(db)), Effect.runPromise);
175
+
176
+ const seedFeed = async (db: Database.Database): Promise<void> => {
177
+ db.add(Obj.make(Counter, { next: INITIAL_ITEMS }));
178
+ const feed = db.add(Feed.make({ name: 'counter-feed' }));
179
+ await appendItems(db, feed, 0, INITIAL_ITEMS);
180
+ };
181
+
182
+ /**
183
+ * Reuses the persisted identity/space/feed, creating and seeding them only on the first ever load.
184
+ * Runs after `client.initialize()`, by which point `client.spaces.get()` already reflects any
185
+ * persisted spaces (the space list awaits its initial update on open), so an existing space is
186
+ * reused rather than duplicated on reload.
187
+ */
188
+ const initializeStory = async (client: Client): Promise<void> => {
189
+ if (!client.halo.identity.get()) {
190
+ await client.halo.createIdentity();
191
+ }
192
+ const space = client.spaces.get()[0] ?? (await client.spaces.create());
193
+ await space.waitUntilReady();
194
+ const feeds = await space.db.query(Filter.type(Feed.Feed)).run();
195
+ if (feeds.length === 0) {
196
+ await seedFeed(space.db);
197
+ } else {
198
+ // Feed exists but its `Counter` is missing (partial seed / older data): restore it from the
199
+ // feed's current length so numbering continues and the story isn't stuck without a counter.
200
+ const counters = await space.db.query(Filter.type(Counter)).run();
201
+ if (counters.length === 0) {
202
+ const existing = await space.db.query(Query.select(Filter.type(CounterItem)).from(feeds[0])).run();
203
+ space.db.add(Obj.make(Counter, { next: existing.length }));
204
+ }
205
+ }
206
+ // Flush so the `Counter` (a space-db object) is indexed and persisted; otherwise it survives in
207
+ // Automerge but the index-backed `useQuery(Filter.type(Counter))` can't find it after reload.
208
+ await space.db.flush({ indexes: true });
209
+ };
210
+
211
+ const orderFor = (field: SortField, direction: SortDirection) => {
212
+ switch (field) {
213
+ case 'number':
214
+ return Order.property<CounterItem>('index', direction);
215
+ case 'word':
216
+ return Order.property<CounterItem>('word', direction);
217
+ case 'natural':
218
+ default:
219
+ return Order.natural(direction);
220
+ }
221
+ };
222
+
223
+ const CounterItemTile: FC<MosaicTileProps<CounterItem>> = ({ data, location, current }) => (
224
+ <Mosaic.Tile id={data.id} data={data} location={location} current={current} asChild>
225
+ <Focus.Item asChild>
226
+ <Card.Root>
227
+ <Card.Header>
228
+ <Card.Title>
229
+ #{data.index} · {data.word}
230
+ </Card.Title>
231
+ </Card.Header>
232
+ </Card.Root>
233
+ </Focus.Item>
234
+ </Mosaic.Tile>
235
+ );
236
+
237
+ const FeedPaginationStory = () => {
238
+ const client = useClient();
239
+ const [space] = useSpaces();
240
+ const db = space?.db;
241
+ const [feed] = useQuery(db, Filter.type(Feed.Feed));
242
+ // Persisted item count / next index (the story is the feed's only writer), so the header shows a
243
+ // "loaded of total" figure and item numbering continues across reloads without a full-feed count.
244
+ const [counter] = useQuery(db, Filter.type(Counter));
245
+
246
+ const [addCount, setAddCount] = useState(DEFAULT_ADD_COUNT);
247
+ const [sortField, setSortField] = useState<SortField>('natural');
248
+ const [direction, setDirection] = useState<SortDirection>('desc');
249
+ const [viewport, setViewport] = useState<HTMLElement | null>(null);
250
+
251
+ // Both branches select `CounterItem` so `items` infers as `CounterItem[]`; the fallback (no feed
252
+ // yet) matches nothing until the query re-points once the feed loads. Re-pointing on a sort
253
+ // change resets to the first page.
254
+ const query = useMemo(
255
+ () =>
256
+ feed
257
+ ? Query.select(Filter.type(CounterItem)).from(feed).orderBy(orderFor(sortField, direction)).limit(PAGE_SIZE)
258
+ : Query.select(Filter.type(CounterItem)).limit(PAGE_SIZE),
259
+ [feed, sortField, direction],
260
+ );
261
+ const { items, getNext, getPrevious, hasMore, isLoading, atHead } = usePagination(db, query, {
262
+ maxWindowSize: MAX_WINDOW_SIZE,
263
+ });
264
+
265
+ const pagination = useMemo(() => ({ getNext, getPrevious, atHead }), [getNext, getPrevious, atHead]);
266
+
267
+ const handleAdd = useCallback(() => {
268
+ if (!db || !feed || !counter || addCount <= 0) {
269
+ return;
270
+ }
271
+ void appendItems(db, feed, counter.next, addCount);
272
+ Obj.update(counter, (counter) => {
273
+ counter.next += addCount;
274
+ });
275
+ }, [db, feed, counter, addCount]);
276
+
277
+ // Wipes the persisted identity/storage and reloads so the story boots a fresh seeded space.
278
+ const handleReset = useCallback(() => {
279
+ void client.reset().then(() => window.location.reload());
280
+ }, [client]);
281
+
282
+ if (!db || !feed || !counter) {
283
+ return <Loading data={{ db: !!db, feed: !!feed, counter: !!counter }} />;
284
+ }
285
+
286
+ const total = counter.next;
287
+
288
+ return (
289
+ <Dnd.Root>
290
+ <Panel.Root>
291
+ <Panel.Toolbar asChild>
292
+ <Toolbar.Root>
293
+ <div className='shrink-0' style={{ inlineSize: '6rem' }}>
294
+ <Input.Root>
295
+ <Input.TextInput
296
+ type='number'
297
+ min={1}
298
+ value={addCount}
299
+ onChange={(event) => setAddCount(event.target.valueAsNumber || 0)}
300
+ classNames='w-full'
301
+ />
302
+ </Input.Root>
303
+ </div>
304
+ <Button onClick={handleAdd} classNames='shrink-0'>
305
+ Add
306
+ </Button>
307
+ <Button onClick={handleReset} classNames='shrink-0'>
308
+ Reset
309
+ </Button>
310
+ <Select.Root
311
+ value={sortField}
312
+ onValueChange={(value) => setSortField(value === 'number' || value === 'word' ? value : 'natural')}
313
+ >
314
+ <Toolbar.Button asChild>
315
+ <Select.TriggerButton classNames='shrink-0' />
316
+ </Toolbar.Button>
317
+ <Select.Portal>
318
+ <Select.Content>
319
+ <Select.Viewport>
320
+ <Select.Option value='natural'>Natural</Select.Option>
321
+ <Select.Option value='number'>Number</Select.Option>
322
+ <Select.Option value='word'>Word</Select.Option>
323
+ </Select.Viewport>
324
+ <Select.Arrow />
325
+ </Select.Content>
326
+ </Select.Portal>
327
+ </Select.Root>
328
+ <Select.Root value={direction} onValueChange={(value) => setDirection(value === 'asc' ? 'asc' : 'desc')}>
329
+ <Toolbar.Button asChild>
330
+ <Select.TriggerButton classNames='shrink-0' />
331
+ </Toolbar.Button>
332
+ <Select.Portal>
333
+ <Select.Content>
334
+ <Select.Viewport>
335
+ <Select.Option value='asc'>Ascending</Select.Option>
336
+ <Select.Option value='desc'>Descending</Select.Option>
337
+ </Select.Viewport>
338
+ <Select.Arrow />
339
+ </Select.Content>
340
+ </Select.Portal>
341
+ </Select.Root>
342
+ <div className='grow text-end truncate whitespace-nowrap'>
343
+ {items.length} loaded of {total}
344
+ {!atHead && ' · detached'}
345
+ {isLoading && ' · loading…'}
346
+ {!hasMore && ' · end'}
347
+ </div>
348
+ </Toolbar.Root>
349
+ </Panel.Toolbar>
350
+ <Panel.Content asChild>
351
+ <Mosaic.Container asChild eventHandler={{ id: 'virtual-stack-feed-pagination', canDrop: () => false }}>
352
+ <ScrollArea.Root orientation='vertical'>
353
+ <ScrollArea.Viewport ref={setViewport}>
354
+ <Mosaic.VirtualStack
355
+ Tile={CounterItemTile}
356
+ items={items}
357
+ draggable={false}
358
+ getId={(item) => item.id}
359
+ getScrollElement={() => viewport}
360
+ estimateSize={() => 56}
361
+ gap={4}
362
+ pagination={pagination}
363
+ />
364
+ </ScrollArea.Viewport>
365
+ </ScrollArea.Root>
366
+ </Mosaic.Container>
367
+ </Panel.Content>
368
+ </Panel.Root>
369
+ </Dnd.Root>
370
+ );
371
+ };
372
+
373
+ const meta: Meta = {
374
+ title: 'ui/react-ui-mosaic/Stack/Pagination',
375
+ decorators: [withLayout({ layout: 'column' }), withTheme()],
376
+ parameters: {
377
+ layout: 'fullscreen',
378
+ },
379
+ };
380
+
381
+ export default meta;
382
+
383
+ type Story = StoryObj;
384
+
385
+ /**
386
+ * `Mosaic.VirtualStack`'s `pagination` prop over a plain in-memory array -- no ECHO query or
387
+ * `usePagination` involved. Scroll down to grow the window, then keep scrolling past
388
+ * `MAX_WINDOW_SIZE` to see it slide (evicting the newest items); scroll back up to slide it back
389
+ * toward the head without a visible jump.
390
+ */
391
+ export const Default: Story = {
392
+ render: VirtualStackPaginationStory,
393
+ };
394
+
395
+ /**
396
+ * End-to-end pagination over a live ECHO feed: `usePagination` windows a feed seeded with 100
397
+ * items (each a number + a random word), rendered through `Mosaic.VirtualStack`'s `pagination`
398
+ * prop. Scroll to page toward older items (past `MAX_WINDOW_SIZE` to see the window slide). Use
399
+ * the toolbar to append more items (they appear live at the head), and to sort by
400
+ * natural/number/word in either direction.
401
+ */
402
+ // Hoisted so `config` and `services` below share the same call — calling `persistentClientServices`
403
+ // twice would spawn two independent dedicated workers/coordinators for the one story.
404
+ const FEED_BACKED_CLIENT_SERVICES = persistentClientServices();
405
+
406
+ export const FeedBacked: Story = {
407
+ render: FeedPaginationStory,
408
+ decorators: [
409
+ withClientProvider({
410
+ // Persist identity/storage across reloads, mirroring stories-assistant. DEDICATED_WORKER mode
411
+ // (not a bare createOpfsWorker) elects a single leader across tabs via navigator.locks so a
412
+ // second tab proxies through the first instead of racing it for the same OPFS file handle —
413
+ // see the failure-mode comment on OpfsWorker.run in @dxos/sql-sqlite. `onInitialized` reuses
414
+ // the persisted space/feed rather than creating fresh ones each load (hence no
415
+ // `createIdentity`/`createSpace` flags).
416
+ config: FEED_BACKED_CLIENT_SERVICES.config,
417
+ // `ClientProviderProps.services` wants a resolved value or a factory, not a bare promise.
418
+ services: () => FEED_BACKED_CLIENT_SERVICES.services,
419
+ types: [Feed.Feed, CounterItem, Counter],
420
+ onInitialized: initializeStory,
421
+ }),
422
+ ],
423
+ };
@@ -5,6 +5,5 @@
5
5
  export { styles as mosaicStyles } from './styles';
6
6
 
7
7
  export * from './extension';
8
- export * from './types';
9
8
 
10
9
  export * from './Mosaic';
@@ -5,4 +5,5 @@
5
5
  export * from './useContainerDebug';
6
6
  export * from './useDefaultColumnEventHandler';
7
7
  export * from './useEventHandlerAdapter';
8
+ export * from './useVirtualizerPagination';
8
9
  export * from './useVisibleItems';
@@ -3,8 +3,8 @@
3
3
  //
4
4
 
5
5
  import { type Obj } from '@dxos/echo';
6
+ import { type DndContainerHandler, type GetId } from '@dxos/react-ui-dnd';
6
7
 
7
- import { type GetId, type MosaicEventHandler } from '../components';
8
8
  import { useEventHandlerAdapter } from './useEventHandlerAdapter';
9
9
 
10
10
  /**
@@ -23,7 +23,7 @@ export const useDefaultColumnEventHandler = <TColumn extends Obj.Unknown = Obj.U
23
23
  id: string,
24
24
  model: DefaultColumnEventHandlerModel<TColumn>,
25
25
  items: TColumn[],
26
- ): MosaicEventHandler<TColumn> =>
26
+ ): DndContainerHandler<TColumn> =>
27
27
  useEventHandlerAdapter<TColumn, TColumn>({
28
28
  id,
29
29
  items,
@@ -6,12 +6,11 @@ import { useMemo } from 'react';
6
6
 
7
7
  import { type Obj } from '@dxos/echo';
8
8
  import { log } from '@dxos/log';
9
+ import { type DndContainerHandler, type GetId } from '@dxos/react-ui-dnd';
9
10
  import { arrayMove } from '@dxos/util';
10
11
 
11
- import { type GetId, type MosaicEventHandler } from '../components';
12
-
13
12
  export type UseEventHandlerProps<TItem = any, TObject extends Obj.Unknown = Obj.Unknown> = Pick<
14
- MosaicEventHandler<TItem>,
13
+ DndContainerHandler<TItem>,
15
14
  'id' | 'canDrop'
16
15
  > & {
17
16
  /**
@@ -53,8 +52,8 @@ export const useEventHandlerAdapter = <TItem = any, TObject extends Obj.Unknown
53
52
  make,
54
53
  onChange,
55
54
  ...props
56
- }: UseEventHandlerProps<TItem, TObject>): MosaicEventHandler<TItem> => {
57
- return useMemo<MosaicEventHandler<TItem>>(
55
+ }: UseEventHandlerProps<TItem, TObject>): DndContainerHandler<TItem> => {
56
+ return useMemo<DndContainerHandler<TItem>>(
58
57
  () => ({
59
58
  ...props,
60
59
  onTake: ({ source }, cb) => {
@@ -0,0 +1,105 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Virtualizer } from '@tanstack/react-virtual';
6
+ import { act, renderHook } from '@testing-library/react';
7
+ import { describe, expect, test, vi } from 'vitest';
8
+
9
+ import { type VirtualizerPaginationController, useVirtualizerPagination } from './useVirtualizerPagination';
10
+
11
+ type Item = { id: string };
12
+
13
+ const getId = (item: Item) => item.id;
14
+ const makeItems = (ids: number[]): Item[] => ids.map((id) => ({ id: String(id) }));
15
+
16
+ const ROW_HEIGHT = 60;
17
+ const VIEWPORT_HEIGHT = 340;
18
+
19
+ /**
20
+ * Minimal fake covering only the fields the hook actually reads off a real `Virtualizer` -- a
21
+ * genuine type-boundary mock; there's no typed alternative short of driving real DOM layout.
22
+ */
23
+ const makeVirtualizer = (itemCount: number, lastVisibleIndex: number): Virtualizer<any, any> =>
24
+ ({
25
+ getVirtualItems: () => [{ index: lastVisibleIndex }],
26
+ measurementsCache: Array.from({ length: itemCount }, (_, i) => ({
27
+ start: i * ROW_HEIGHT,
28
+ end: i * ROW_HEIGHT + (ROW_HEIGHT - 4),
29
+ })),
30
+ scrollOffset: lastVisibleIndex * ROW_HEIGHT,
31
+ scrollElement: { clientHeight: VIEWPORT_HEIGHT },
32
+ getTotalSize: () => itemCount * ROW_HEIGHT,
33
+ scrollToOffset: vi.fn(),
34
+ }) as unknown as Virtualizer<any, any>;
35
+
36
+ describe('useVirtualizerPagination', () => {
37
+ test('chains getNext across a page landing with no further onChange', async () => {
38
+ // Regression test: once the loaded window's tail is fully rendered and the scrollbar sits at
39
+ // its physical max, `@tanstack/react-virtual` stops calling `onChange` -- its `maybeNotify` only
40
+ // fires on `[isScrolling, startIndex, endIndex]` changes, and neither moves just because `items`
41
+ // did. A `getNext` chain (e.g. a mailbox window sliding once it hits its max size, appending a
42
+ // page while evicting an equal-sized one from the front) must keep going purely off the `items`
43
+ // prop changing -- with no further onChange -- or it stalls forever right where it landed.
44
+ let items = makeItems([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
45
+ const getNext = vi.fn(() => {
46
+ items = makeItems([10, 11, 12, 13, 14, 15, 16, 17, 18, 19]);
47
+ });
48
+ const pagination: VirtualizerPaginationController = { getNext, atHead: true };
49
+
50
+ const { result, rerender } = renderHook(
51
+ (props: { items: Item[] }) => useVirtualizerPagination({ items: props.items, getId, pagination }),
52
+ { initialProps: { items } },
53
+ );
54
+
55
+ // The one onChange tanstack fires as the user scrolls to the loaded window's tail: near the
56
+ // bottom edge, last row visible, and tall enough (10 rows) to exceed the viewport so the
57
+ // hook's own `isScrollable` check passes.
58
+ act(() => {
59
+ result.current.onChange(makeVirtualizer(10, 9));
60
+ });
61
+ // `requestNext`'s own `getNext()` call is deferred to a microtask.
62
+ await act(async () => {
63
+ await Promise.resolve();
64
+ });
65
+ expect(getNext).toHaveBeenCalledTimes(1);
66
+
67
+ // The data layer resolved and slid the window -- re-render with the new page, but WITHOUT any
68
+ // further onChange call (tanstack wouldn't fire one: the rendered range hasn't moved).
69
+ rerender({ items });
70
+ await act(async () => {
71
+ await Promise.resolve();
72
+ });
73
+
74
+ // The chain must continue on its own: the window is still the same size, so the same geometry
75
+ // is still "near the bottom" -- the layout effect's re-arm should have requested another page.
76
+ expect(getNext).toHaveBeenCalledTimes(2);
77
+ });
78
+
79
+ test('does not misclassify a mid-list item reordered to the head as an eviction', () => {
80
+ // Regression test: a conversation-grouped mailbox bumps an existing (already-loaded) thread to
81
+ // the head when a new reply lands during sync. A bare `findIndex` match on the new head's id
82
+ // used to be enough to classify this as an 'evicted' prefix -- reading the bumped item's old
83
+ // mid-list offset as "evicted" height and growing the leading spacer by a bogus amount that's
84
+ // never reversed, which is what produced the flashing/jumping during sync.
85
+ const items = makeItems([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
86
+ const pagination: VirtualizerPaginationController = {};
87
+
88
+ const { result, rerender } = renderHook(
89
+ (props: { items: Item[] }) => useVirtualizerPagination({ items: props.items, getId, pagination }),
90
+ { initialProps: { items } },
91
+ );
92
+
93
+ act(() => {
94
+ result.current.onChange(makeVirtualizer(10, 4));
95
+ });
96
+ expect(result.current.leadingSpace).toBe(0);
97
+
98
+ // Item '5' (previously mid-list, at index 5) is bumped to the head; everything else keeps its
99
+ // prior relative order -- exactly the shape a conversation re-sort produces.
100
+ const reordered = [items[5], ...items.slice(0, 5), ...items.slice(6)];
101
+ rerender({ items: reordered });
102
+
103
+ expect(result.current.leadingSpace).toBe(0);
104
+ });
105
+ });