@dxos/react-ui-feed 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/dist/lib/chunk-src.mjs +2228 -0
- package/dist/lib/chunk-src.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +1923 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts +14 -0
- package/dist/types/src/components/Block/HtmlBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts +24 -0
- package/dist/types/src/components/Block/MarkdownBlock.d.ts.map +1 -0
- package/dist/types/src/components/Block/block.stories.d.ts +21 -0
- package/dist/types/src/components/Block/block.stories.d.ts.map +1 -0
- package/dist/types/src/components/Block/construction.d.ts +36 -0
- package/dist/types/src/components/Block/construction.d.ts.map +1 -0
- package/dist/types/src/components/Block/extensions.d.ts +23 -0
- package/dist/types/src/components/Block/extensions.d.ts.map +1 -0
- package/dist/types/src/components/Block/highlight.d.ts +14 -0
- package/dist/types/src/components/Block/highlight.d.ts.map +1 -0
- package/dist/types/src/components/Block/index.d.ts +6 -0
- package/dist/types/src/components/Block/index.d.ts.map +1 -0
- package/dist/types/src/components/Block/selection-group.d.ts +21 -0
- package/dist/types/src/components/Block/selection-group.d.ts.map +1 -0
- package/dist/types/src/components/Block/widget-state.d.ts +33 -0
- package/dist/types/src/components/Block/widget-state.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts +129 -0
- package/dist/types/src/components/MessageList/MessageList.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts +47 -0
- package/dist/types/src/components/MessageList/MessageList.stories.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/index.d.ts +3 -0
- package/dist/types/src/components/MessageList/index.d.ts.map +1 -0
- package/dist/types/src/components/MessageList/position-log.d.ts +73 -0
- package/dist/types/src/components/MessageList/position-log.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts +28 -0
- package/dist/types/src/components/Minimap/Minimap.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts +21 -0
- package/dist/types/src/components/Minimap/Minimap.stories.d.ts.map +1 -0
- package/dist/types/src/components/Minimap/index.d.ts +2 -0
- package/dist/types/src/components/Minimap/index.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.d.ts +46 -0
- package/dist/types/src/components/Outline/Outline.d.ts.map +1 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts +37 -0
- package/dist/types/src/components/Outline/Outline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Outline/index.d.ts +2 -0
- package/dist/types/src/components/Outline/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/debug/Debug.d.ts +17 -0
- package/dist/types/src/debug/Debug.d.ts.map +1 -0
- package/dist/types/src/debug/Stats.d.ts +30 -0
- package/dist/types/src/debug/Stats.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.d.ts +32 -0
- package/dist/types/src/debug/debug-model.d.ts.map +1 -0
- package/dist/types/src/debug/debug-model.test.d.ts +2 -0
- package/dist/types/src/debug/debug-model.test.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.d.ts +48 -0
- package/dist/types/src/debug/frame-meter.d.ts.map +1 -0
- package/dist/types/src/debug/frame-meter.test.d.ts +2 -0
- package/dist/types/src/debug/frame-meter.test.d.ts.map +1 -0
- package/dist/types/src/debug/index.d.ts +6 -0
- package/dist/types/src/debug/index.d.ts.map +1 -0
- package/dist/types/src/debug/use-debug.d.ts +19 -0
- package/dist/types/src/debug/use-debug.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.d.ts +89 -0
- package/dist/types/src/hooks/follow.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.stories.d.ts +25 -0
- package/dist/types/src/hooks/follow.stories.d.ts.map +1 -0
- package/dist/types/src/hooks/follow.test.d.ts +2 -0
- package/dist/types/src/hooks/follow.test.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +6 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useDecorations.d.ts +31 -0
- package/dist/types/src/hooks/useDecorations.d.ts.map +1 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts +44 -0
- package/dist/types/src/hooks/useFeedNavigation.d.ts.map +1 -0
- package/dist/types/src/hooks/useFollow.d.ts +46 -0
- package/dist/types/src/hooks/useFollow.d.ts.map +1 -0
- package/dist/types/src/hooks/useItemSelection.d.ts +34 -0
- package/dist/types/src/hooks/useItemSelection.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.d.ts +67 -0
- package/dist/types/src/model/feed-model.d.ts.map +1 -0
- package/dist/types/src/model/feed-model.test.d.ts +2 -0
- package/dist/types/src/model/feed-model.test.d.ts.map +1 -0
- package/dist/types/src/model/feed.d.ts +66 -0
- package/dist/types/src/model/feed.d.ts.map +1 -0
- package/dist/types/src/model/feed.test.d.ts +2 -0
- package/dist/types/src/model/feed.test.d.ts.map +1 -0
- package/dist/types/src/model/index.d.ts +5 -0
- package/dist/types/src/model/index.d.ts.map +1 -0
- package/dist/types/src/model/list-model.d.ts +61 -0
- package/dist/types/src/model/list-model.d.ts.map +1 -0
- package/dist/types/src/model/list-model.test.d.ts +2 -0
- package/dist/types/src/model/list-model.test.d.ts.map +1 -0
- package/dist/types/src/model/useListModel.d.ts +22 -0
- package/dist/types/src/model/useListModel.d.ts.map +1 -0
- package/dist/types/src/stories/assistant.stories.d.ts +38 -0
- package/dist/types/src/stories/assistant.stories.d.ts.map +1 -0
- package/dist/types/src/stories/baseline.stories.d.ts +32 -0
- package/dist/types/src/stories/baseline.stories.d.ts.map +1 -0
- package/dist/types/src/stories/bridge.stories.d.ts +72 -0
- package/dist/types/src/stories/bridge.stories.d.ts.map +1 -0
- package/dist/types/src/stories/construction.stories.d.ts +17 -0
- package/dist/types/src/stories/construction.stories.d.ts.map +1 -0
- package/dist/types/src/stories/fill.stories.d.ts +51 -0
- package/dist/types/src/stories/fill.stories.d.ts.map +1 -0
- package/dist/types/src/stories/horizontal.stories.d.ts +17 -0
- package/dist/types/src/stories/horizontal.stories.d.ts.map +1 -0
- package/dist/types/src/stories/mount.stories.d.ts +19 -0
- package/dist/types/src/stories/mount.stories.d.ts.map +1 -0
- package/dist/types/src/stories/navigation.stories.d.ts +33 -0
- package/dist/types/src/stories/navigation.stories.d.ts.map +1 -0
- package/dist/types/src/stories/streaming.stories.d.ts +17 -0
- package/dist/types/src/stories/streaming.stories.d.ts.map +1 -0
- package/dist/types/src/stories/tail.stories.d.ts +47 -0
- package/dist/types/src/stories/tail.stories.d.ts.map +1 -0
- package/dist/types/src/stories/widget.stories.d.ts +16 -0
- package/dist/types/src/stories/widget.stories.d.ts.map +1 -0
- package/dist/types/src/testing/FeedStory.d.ts +30 -0
- package/dist/types/src/testing/FeedStory.d.ts.map +1 -0
- package/dist/types/src/testing/MessageWindow.d.ts +48 -0
- package/dist/types/src/testing/MessageWindow.d.ts.map +1 -0
- package/dist/types/src/testing/controls.d.ts +46 -0
- package/dist/types/src/testing/controls.d.ts.map +1 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts +18 -0
- package/dist/types/src/testing/debug/FeedStats.d.ts.map +1 -0
- package/dist/types/src/testing/debug/index.d.ts +4 -0
- package/dist/types/src/testing/debug/index.d.ts.map +1 -0
- package/dist/types/src/testing/debug/sweep.d.ts +18 -0
- package/dist/types/src/testing/debug/sweep.d.ts.map +1 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts +33 -0
- package/dist/types/src/testing/debug/use-feed-debug.d.ts.map +1 -0
- package/dist/types/src/testing/generator.d.ts +16 -0
- package/dist/types/src/testing/generator.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +10 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/scenarios.d.ts +58 -0
- package/dist/types/src/testing/scenarios.d.ts.map +1 -0
- package/dist/types/src/testing/stream.d.ts +22 -0
- package/dist/types/src/testing/stream.d.ts.map +1 -0
- package/dist/types/src/testing/turn.d.ts +21 -0
- package/dist/types/src/testing/turn.d.ts.map +1 -0
- package/dist/types/src/testing/widgets.d.ts +3 -0
- package/dist/types/src/testing/widgets.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.d.ts +137 -0
- package/dist/types/src/virtualizer/Window.d.ts.map +1 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts +112 -0
- package/dist/types/src/virtualizer/Window.stories.d.ts.map +1 -0
- package/dist/types/src/virtualizer/index.d.ts +3 -0
- package/dist/types/src/virtualizer/index.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.d.ts +188 -0
- package/dist/types/src/virtualizer/placement.d.ts.map +1 -0
- package/dist/types/src/virtualizer/placement.test.d.ts +2 -0
- package/dist/types/src/virtualizer/placement.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +72 -0
- package/src/components/Block/HtmlBlock.tsx +26 -0
- package/src/components/Block/MarkdownBlock.tsx +157 -0
- package/src/components/Block/block.stories.tsx +140 -0
- package/src/components/Block/construction.ts +77 -0
- package/src/components/Block/extensions.ts +95 -0
- package/src/components/Block/highlight.ts +53 -0
- package/src/components/Block/index.ts +10 -0
- package/src/components/Block/selection-group.ts +53 -0
- package/src/components/Block/widget-state.tsx +75 -0
- package/src/components/MessageList/MessageList.stories.tsx +111 -0
- package/src/components/MessageList/MessageList.tsx +647 -0
- package/src/components/MessageList/index.ts +6 -0
- package/src/components/MessageList/position-log.ts +167 -0
- package/src/components/Minimap/Minimap.stories.tsx +129 -0
- package/src/components/Minimap/Minimap.tsx +84 -0
- package/src/components/Minimap/index.ts +5 -0
- package/src/components/Outline/Outline.stories.tsx +196 -0
- package/src/components/Outline/Outline.tsx +383 -0
- package/src/components/Outline/index.ts +5 -0
- package/src/components/index.ts +8 -0
- package/src/debug/Debug.tsx +90 -0
- package/src/debug/Stats.tsx +57 -0
- package/src/debug/debug-model.test.ts +36 -0
- package/src/debug/debug-model.ts +66 -0
- package/src/debug/frame-meter.test.ts +49 -0
- package/src/debug/frame-meter.ts +188 -0
- package/src/debug/index.ts +9 -0
- package/src/debug/use-debug.tsx +41 -0
- package/src/hooks/follow.stories.tsx +200 -0
- package/src/hooks/follow.test.ts +105 -0
- package/src/hooks/follow.ts +241 -0
- package/src/hooks/index.ts +9 -0
- package/src/hooks/useDecorations.tsx +54 -0
- package/src/hooks/useFeedNavigation.ts +90 -0
- package/src/hooks/useFollow.ts +258 -0
- package/src/hooks/useItemSelection.tsx +53 -0
- package/src/index.ts +7 -0
- package/src/model/feed-model.test.ts +84 -0
- package/src/model/feed-model.ts +214 -0
- package/src/model/feed.test.ts +65 -0
- package/src/model/feed.ts +157 -0
- package/src/model/index.ts +8 -0
- package/src/model/list-model.test.ts +71 -0
- package/src/model/list-model.ts +152 -0
- package/src/model/useListModel.ts +53 -0
- package/src/stories/assistant.stories.tsx +425 -0
- package/src/stories/baseline.stories.tsx +78 -0
- package/src/stories/bridge.stories.tsx +376 -0
- package/src/stories/construction.stories.tsx +147 -0
- package/src/stories/fill.stories.tsx +271 -0
- package/src/stories/horizontal.stories.tsx +129 -0
- package/src/stories/mount.stories.tsx +233 -0
- package/src/stories/navigation.stories.tsx +145 -0
- package/src/stories/streaming.stories.tsx +132 -0
- package/src/stories/tail.stories.tsx +149 -0
- package/src/stories/widget.stories.tsx +107 -0
- package/src/testing/FeedStory.tsx +452 -0
- package/src/testing/MessageWindow.tsx +139 -0
- package/src/testing/controls.tsx +96 -0
- package/src/testing/debug/FeedStats.tsx +182 -0
- package/src/testing/debug/index.ts +16 -0
- package/src/testing/debug/sweep.ts +85 -0
- package/src/testing/debug/use-feed-debug.ts +91 -0
- package/src/testing/generator.ts +90 -0
- package/src/testing/index.ts +13 -0
- package/src/testing/scenarios.tsx +552 -0
- package/src/testing/stream.ts +70 -0
- package/src/testing/turn.ts +91 -0
- package/src/testing/widgets.tsx +183 -0
- package/src/virtualizer/Window.stories.tsx +683 -0
- package/src/virtualizer/Window.tsx +435 -0
- package/src/virtualizer/index.ts +6 -0
- package/src/virtualizer/placement.test.ts +312 -0
- package/src/virtualizer/placement.ts +403 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import React, {
|
|
6
|
+
type ReactNode,
|
|
7
|
+
type Ref,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useLayoutEffect,
|
|
12
|
+
useMemo,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
16
|
+
|
|
17
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
18
|
+
import { mx } from '@dxos/ui-theme';
|
|
19
|
+
|
|
20
|
+
import { useFollow } from '../hooks/useFollow';
|
|
21
|
+
import { type EdgeDrift, type Extents, type Layout, Placement } from './placement';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the window needs from a model, structurally — `ListModel`/`FeedModel` satisfy it, and the
|
|
25
|
+
* virtualizer stays importable without the model layer. The contract is SPEC F-7.1: a change says
|
|
26
|
+
* what it was (`prepended`), so the anchor is told rather than left to infer it from identities.
|
|
27
|
+
*/
|
|
28
|
+
export type WindowModel = {
|
|
29
|
+
readonly count: number;
|
|
30
|
+
getId: (index: number) => string;
|
|
31
|
+
subscribe: (
|
|
32
|
+
listener: (change: { prepended?: number; appended?: number; updated?: readonly string[] }) => void,
|
|
33
|
+
) => () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The DOM shape, and nothing else.
|
|
38
|
+
*
|
|
39
|
+
* Three elements: a **sizer** that holds no rows and exists only to give the thumb something to
|
|
40
|
+
* measure, a **window** holding the mounted rows in normal flow, and the scroller around them. The
|
|
41
|
+
* window is absolutely positioned and moved by a transform, so a correction changes one number and
|
|
42
|
+
* never touches `scrollTop` — which is what stops corrections and the reader sharing a channel (§7).
|
|
43
|
+
*
|
|
44
|
+
* Rows are unpositioned. A row that changes extent reflows the ones after it, in the browser, in the
|
|
45
|
+
* same frame; the alternative was re-placing every row below it ourselves on every frame of the
|
|
46
|
+
* change — measured at 177 re-placements for one disclosure opening (§6).
|
|
47
|
+
*
|
|
48
|
+
* Knows nothing about messages, markdown or CodeMirror. What it renders is whatever `children`
|
|
49
|
+
* returns, which is what lets `placement/*` drive it with content that cannot lie about its size.
|
|
50
|
+
*/
|
|
51
|
+
export type WindowAxis = 'block' | 'inline';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What a toolbar or a readout needs, and nothing more.
|
|
55
|
+
*
|
|
56
|
+
* Published rather than rendered: chrome belongs to the host (§2), so the window says where it is
|
|
57
|
+
* and the host decides what to draw about it.
|
|
58
|
+
*/
|
|
59
|
+
export type WindowState = {
|
|
60
|
+
/** The row at the top of the viewport — what "where am I" means to a reader. */
|
|
61
|
+
index: number;
|
|
62
|
+
visible: { first: number; last: number };
|
|
63
|
+
/** The mounted range, which includes the overscan the reader cannot see. */
|
|
64
|
+
mounted: { first: number; last: number };
|
|
65
|
+
count: number;
|
|
66
|
+
/**
|
|
67
|
+
* The same thing in pixels, which is what a map of the list has to draw.
|
|
68
|
+
*
|
|
69
|
+
* `window` is the mounted parent's span in content space — where the rows that exist actually are —
|
|
70
|
+
* and `total` is what the thumb is scaled against. A map drawn from indices instead would be a map
|
|
71
|
+
* of the model rather than of the layout, and it is the layout that goes wrong.
|
|
72
|
+
*/
|
|
73
|
+
geometry: { scroll: number; viewport: number; total: number; window: { start: number; extent: number } };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type WindowController = {
|
|
77
|
+
/**
|
|
78
|
+
* `behavior: 'smooth'` glides, and only over content that is mounted — a glide across rows that do
|
|
79
|
+
* not exist is a journey over a blank screen, so a far target is taken instantly (§7).
|
|
80
|
+
*/
|
|
81
|
+
scrollToIndex: (index: number, align?: 'start' | 'end', behavior?: ScrollBehavior) => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type WindowProps = ThemedClassName<{
|
|
85
|
+
model: WindowModel;
|
|
86
|
+
extents: Extents;
|
|
87
|
+
/** Which way the list runs. The principles hold either way; only this mapping differs (§9). */
|
|
88
|
+
axis?: WindowAxis;
|
|
89
|
+
overscan?: number;
|
|
90
|
+
/** Empty extent after the last row, so it can be scrolled to the top of the viewport (§7). */
|
|
91
|
+
reserve?: number;
|
|
92
|
+
/**
|
|
93
|
+
* Keep the last row against the end of the viewport as content arrives.
|
|
94
|
+
*
|
|
95
|
+
* Consumed by the trivial `Window` binding, which composes `useFollow`; `useWindow` itself knows
|
|
96
|
+
* nothing about following — the intent, the withdrawal and the glide are the aspect's (SPEC §Aspects).
|
|
97
|
+
*/
|
|
98
|
+
sticky?: boolean;
|
|
99
|
+
/** What an edge revealed about the estimates, if it revealed anything. */
|
|
100
|
+
onEdge?: (drift: EdgeDrift) => void;
|
|
101
|
+
/** A row whose declared extent was not the extent it rendered at. `exact` means do not correct — not do not check (§8). */
|
|
102
|
+
onMismatch?: (mismatch: { index: number; id: string; declared: number; actual: number }) => void;
|
|
103
|
+
onChange?: (state: WindowState) => void;
|
|
104
|
+
controllerRef?: Ref<WindowController>;
|
|
105
|
+
children: (index: number, id: string) => ReactNode;
|
|
106
|
+
}>;
|
|
107
|
+
|
|
108
|
+
/** Extent of the mounted rows, which is the parent's own size in content space. */
|
|
109
|
+
const windowExtentOf = (placement: Placement, first: number, last: number): number => {
|
|
110
|
+
let extent = 0;
|
|
111
|
+
for (let index = first; index <= last; index++) {
|
|
112
|
+
extent += placement.extentOf(index);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return extent;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Everything the shape needs, with no opinion about who owns the scroll element.
|
|
120
|
+
*
|
|
121
|
+
* Split out because the DOM below is only the *simplest* binding of it. A host that already has a
|
|
122
|
+
* scroll container — `MessageList` scrolls inside `ScrollArea`, which owns the overlay thumbs and
|
|
123
|
+
* the padding tokens — cannot nest a second one, and would otherwise have to reimplement the
|
|
124
|
+
* placement to keep its own chrome. It hands its element over instead.
|
|
125
|
+
*/
|
|
126
|
+
export type UseWindowOptions = Omit<WindowProps, 'classNames' | 'children'> & {
|
|
127
|
+
/** The element being scrolled, when the host owns it. `Window` passes its own. */
|
|
128
|
+
scrollerRef: React.RefObject<HTMLElement | null>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type UseWindowResult = {
|
|
132
|
+
placement: Placement;
|
|
133
|
+
layout: Layout;
|
|
134
|
+
/** Ref for the element holding the mounted rows, which is what gets measured. */
|
|
135
|
+
windowRef: React.RefObject<HTMLDivElement | null>;
|
|
136
|
+
/** Extent of the whole document along the axis, reserve included: what the thumb is scaled to. */
|
|
137
|
+
sizerExtent: number;
|
|
138
|
+
/** Absolute position of the first mounted row, which the parent is translated by. */
|
|
139
|
+
offset: number;
|
|
140
|
+
first: number;
|
|
141
|
+
last: number;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const useWindow = ({
|
|
145
|
+
scrollerRef,
|
|
146
|
+
model,
|
|
147
|
+
extents,
|
|
148
|
+
axis = 'block',
|
|
149
|
+
overscan,
|
|
150
|
+
reserve = 0,
|
|
151
|
+
onEdge,
|
|
152
|
+
onMismatch,
|
|
153
|
+
onChange,
|
|
154
|
+
controllerRef,
|
|
155
|
+
}: UseWindowOptions): UseWindowResult => {
|
|
156
|
+
const windowRef = useRef<HTMLDivElement>(null);
|
|
157
|
+
const [, render] = useState(0);
|
|
158
|
+
const invalidate = useCallback(() => render((value) => value + 1), []);
|
|
159
|
+
|
|
160
|
+
const count = model.count;
|
|
161
|
+
const getId = model.getId;
|
|
162
|
+
|
|
163
|
+
// Held across renders, and told about the model rather than rebuilt from it: the anchor is state,
|
|
164
|
+
// and rebuilding would discard the one position that is known exactly.
|
|
165
|
+
const placement = useMemo(
|
|
166
|
+
() => new Placement({ count, getId, extents, viewport: 0, overscan }),
|
|
167
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
168
|
+
[],
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// A mismatch is news the first time and noise afterwards. Without this the report is emitted on
|
|
172
|
+
// every commit, and a host that renders the report re-renders on it — which is an infinite loop,
|
|
173
|
+
// found by `placement/Drift` on its first run.
|
|
174
|
+
const reported = useRef(new Set<string>());
|
|
175
|
+
|
|
176
|
+
// Told before anything consults the model: `getId` is a closure over the host's list, so the one
|
|
177
|
+
// captured at construction names rows that no longer exist there.
|
|
178
|
+
placement.setGetId(getId);
|
|
179
|
+
|
|
180
|
+
// Which end the model grew at, told by the model (SPEC F-7.1). The engine used to reconstruct
|
|
181
|
+
// this by scanning for the previous first id — the model's own knowledge, recovered from its
|
|
182
|
+
// absence; the scan survives only in `ListModel.replace`, the adapter for hosts that hand over a
|
|
183
|
+
// whole new array and genuinely do not know what changed. The model publishes synchronously, so
|
|
184
|
+
// a prepend reaches the anchor in the same tick it happens; the render-time guard below covers
|
|
185
|
+
// the one gap — a *new* model instance arriving mid-render, before the effect re-subscribes.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
const unsubscribe = model.subscribe((change) => {
|
|
188
|
+
placement.setCount(model.count, { prepended: change.prepended ?? 0 });
|
|
189
|
+
invalidate();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
placement.setCount(model.count, {});
|
|
193
|
+
invalidate();
|
|
194
|
+
return unsubscribe;
|
|
195
|
+
}, [model, placement, invalidate]);
|
|
196
|
+
|
|
197
|
+
if (placement.count !== model.count) {
|
|
198
|
+
placement.setCount(model.count, {});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Navigation, not correction. The rule that corrections never touch `scrollTop` (§7) is about the
|
|
202
|
+
// list moving itself; a reader asking to be somewhere is the one case where moving the scroll is
|
|
203
|
+
// the whole point.
|
|
204
|
+
useImperativeHandle(
|
|
205
|
+
controllerRef,
|
|
206
|
+
() => ({
|
|
207
|
+
scrollToIndex: (index, align, behavior = 'auto') => {
|
|
208
|
+
const scroller = scrollerRef.current;
|
|
209
|
+
const { first, last } = placement.layout();
|
|
210
|
+
// A glide only over rows that are already there. Beyond them the offset is a sum of
|
|
211
|
+
// estimates, so the travel would cross content that does not exist and land somewhere the
|
|
212
|
+
// measurement then corrects — a step the reader watched go to the wrong place.
|
|
213
|
+
if (behavior === 'smooth' && scroller && index >= first && index <= last) {
|
|
214
|
+
const target = placement.offsetOf(index, align);
|
|
215
|
+
// Reported, not applied: the element animates and its scroll events drive the placement,
|
|
216
|
+
// one frame at a time, so the mounted window stays under the reader for the whole journey.
|
|
217
|
+
scroller.scrollTo(axis === 'block' ? { top: target, behavior } : { left: target, behavior });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
placement.jumpTo(index, align);
|
|
222
|
+
if (scroller) {
|
|
223
|
+
if (axis === 'block') {
|
|
224
|
+
scroller.scrollTop = placement.scroll;
|
|
225
|
+
} else {
|
|
226
|
+
scroller.scrollLeft = placement.scroll;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
invalidate();
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
[placement, axis, invalidate],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Told on every render, not captured once: `extents` is a function the host may replace, and a
|
|
237
|
+
// placement holding the first one it was given keeps a layout that was true at mount.
|
|
238
|
+
placement.setExtents(extents);
|
|
239
|
+
placement.setReserve(reserve);
|
|
240
|
+
|
|
241
|
+
const { first, last, visible, offset, sizerExtent } = placement.layout();
|
|
242
|
+
|
|
243
|
+
// Declared before the follow, so the follow's first run has a viewport to compute an end against:
|
|
244
|
+
// effects run in order, and an end measured against a viewport of zero is the whole document.
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
const scroller = scrollerRef.current;
|
|
247
|
+
if (!scroller) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const read = () => (axis === 'block' ? scroller.clientHeight : scroller.clientWidth);
|
|
252
|
+
placement.setViewport(read());
|
|
253
|
+
const observer = new ResizeObserver(() => {
|
|
254
|
+
placement.setViewport(read());
|
|
255
|
+
invalidate();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
observer.observe(scroller);
|
|
259
|
+
const onScroll = () => {
|
|
260
|
+
placement.scrollTo(axis === 'block' ? scroller.scrollTop : scroller.scrollLeft);
|
|
261
|
+
invalidate();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
265
|
+
invalidate();
|
|
266
|
+
return () => {
|
|
267
|
+
observer.disconnect();
|
|
268
|
+
scroller.removeEventListener('scroll', onScroll);
|
|
269
|
+
};
|
|
270
|
+
}, [placement, axis, invalidate]);
|
|
271
|
+
|
|
272
|
+
// Measured after every commit, so a row that grows later — a font, a portaled widget, an image —
|
|
273
|
+
// is not a special case but the same case arriving again (§8).
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
const parent = windowRef.current;
|
|
276
|
+
if (!parent) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let changed = false;
|
|
281
|
+
for (const element of parent.children) {
|
|
282
|
+
const row = element as HTMLElement;
|
|
283
|
+
const index = Number(row.dataset.index);
|
|
284
|
+
const id = row.dataset.objectId!;
|
|
285
|
+
const actual = Math.round(axis === 'block' ? row.offsetHeight : row.offsetWidth);
|
|
286
|
+
const declared = placement.extentOf(index);
|
|
287
|
+
if (extents.exact) {
|
|
288
|
+
const key = `${id}:${declared}:${actual}`;
|
|
289
|
+
if (actual !== declared && !reported.current.has(key)) {
|
|
290
|
+
reported.current.add(key);
|
|
291
|
+
onMismatch?.({ index, id, declared, actual });
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (actual && actual !== declared) {
|
|
297
|
+
placement.measure(id, actual);
|
|
298
|
+
changed = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The start edge's drift is repaid, not merely reported: row 0 at a negative position is a top
|
|
303
|
+
// of the document no scroll can reach. Content and offset move by the same delta in the same
|
|
304
|
+
// commit, so the mounted rows hold their place on screen while the coordinate system comes
|
|
305
|
+
// true. Forward shifts never read as the reader (the follow only weighs backwards gestures).
|
|
306
|
+
const shift = placement.rebaseStart();
|
|
307
|
+
if (shift !== 0) {
|
|
308
|
+
const scroller = scrollerRef.current;
|
|
309
|
+
if (scroller) {
|
|
310
|
+
if (axis === 'block') {
|
|
311
|
+
scroller.scrollTop = placement.scroll;
|
|
312
|
+
} else {
|
|
313
|
+
scroller.scrollLeft = placement.scroll;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
changed = true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Deduped for the same reason as a mismatch: an edge is news the first time it is reached, and
|
|
321
|
+
// a host that renders the report would otherwise re-render on it for ever.
|
|
322
|
+
const drift = placement.drift();
|
|
323
|
+
const key = drift && `${drift.edge}:${drift.delta}`;
|
|
324
|
+
if (drift && key && !reported.current.has(key)) {
|
|
325
|
+
reported.current.add(key);
|
|
326
|
+
onEdge?.(drift);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (changed) {
|
|
330
|
+
invalidate();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const announced = useRef<string>('');
|
|
335
|
+
const state = `${visible.first}:${visible.last}:${first}:${last}:${count}`;
|
|
336
|
+
if (onChange && announced.current !== state) {
|
|
337
|
+
announced.current = state;
|
|
338
|
+
// In render rather than an effect, so a readout never lags the frame it describes by one.
|
|
339
|
+
const scroller = scrollerRef.current;
|
|
340
|
+
const viewport = (axis === 'block' ? scroller?.clientHeight : scroller?.clientWidth) ?? 0;
|
|
341
|
+
const extent = windowExtentOf(placement, first, last);
|
|
342
|
+
queueMicrotask(() =>
|
|
343
|
+
onChange({
|
|
344
|
+
index: visible.first,
|
|
345
|
+
visible,
|
|
346
|
+
mounted: { first, last },
|
|
347
|
+
count,
|
|
348
|
+
geometry: { scroll: placement.scroll, viewport, total: sizerExtent, window: { start: offset, extent } },
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
placement,
|
|
355
|
+
layout: { first, last, visible, offset, sizerExtent },
|
|
356
|
+
windowRef,
|
|
357
|
+
sizerExtent,
|
|
358
|
+
offset,
|
|
359
|
+
first,
|
|
360
|
+
last,
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The DOM shape, and nothing else.
|
|
366
|
+
*
|
|
367
|
+
* Three elements: a **sizer** that holds no rows and exists only to give the thumb something to
|
|
368
|
+
* measure, a **window** holding the mounted rows in normal flow, and the scroller around them.
|
|
369
|
+
*/
|
|
370
|
+
export const Window = ({ classNames, children, controllerRef, ...options }: WindowProps) => {
|
|
371
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
372
|
+
const inner = useRef<WindowController>(null);
|
|
373
|
+
const { placement, windowRef, offset, sizerExtent, first, last } = useWindow({
|
|
374
|
+
...options,
|
|
375
|
+
scrollerRef,
|
|
376
|
+
controllerRef: inner,
|
|
377
|
+
});
|
|
378
|
+
const axis = options.axis ?? 'block';
|
|
379
|
+
const main = axis === 'block' ? 'height' : 'width';
|
|
380
|
+
const { getId } = options.model;
|
|
381
|
+
|
|
382
|
+
// The binding composes the aspect; the hook knows nothing about following. Graduates with the
|
|
383
|
+
// virtualizer — nothing in it is about messages.
|
|
384
|
+
const follow = useFollow({
|
|
385
|
+
scrollerRef,
|
|
386
|
+
placement,
|
|
387
|
+
extent: sizerExtent,
|
|
388
|
+
count: options.model.count,
|
|
389
|
+
axis,
|
|
390
|
+
reserve: options.reserve,
|
|
391
|
+
enabled: options.sticky,
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// The exposed controller answers the follow before it moves (see FollowHandle.onNavigate).
|
|
395
|
+
useImperativeHandle(
|
|
396
|
+
controllerRef,
|
|
397
|
+
() => ({
|
|
398
|
+
scrollToIndex: (index, align, behavior) => {
|
|
399
|
+
follow.onNavigate(index);
|
|
400
|
+
inner.current?.scrollToIndex(index, align, behavior);
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
[follow],
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const rows = [];
|
|
407
|
+
for (let index = first; index <= last; index++) {
|
|
408
|
+
rows.push(
|
|
409
|
+
<div key={getId(index)} data-index={index} data-object-id={getId(index)}>
|
|
410
|
+
{children(index, getId(index))}
|
|
411
|
+
</div>,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div
|
|
417
|
+
ref={scrollerRef}
|
|
418
|
+
className={mx('relative', axis === 'block' ? 'overflow-y-auto' : 'overflow-x-auto', classNames)}
|
|
419
|
+
// Off deliberately: the browser adjusting the scroll as well would be a second party anchoring
|
|
420
|
+
// the same thing, and the defect this design exists to remove is exactly that (§6).
|
|
421
|
+
style={{ overflowAnchor: 'none' }}
|
|
422
|
+
data-testid='window.scroller'
|
|
423
|
+
>
|
|
424
|
+
<div style={{ [main]: sizerExtent }} data-testid='window.sizer' />
|
|
425
|
+
<div
|
|
426
|
+
ref={windowRef}
|
|
427
|
+
className={mx('absolute top-0 left-0 flex', axis === 'block' ? 'flex-col w-full' : 'flex-row h-full')}
|
|
428
|
+
style={{ transform: axis === 'block' ? `translateY(${offset}px)` : `translateX(${offset}px)` }}
|
|
429
|
+
data-testid='window.window'
|
|
430
|
+
>
|
|
431
|
+
{rows}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
);
|
|
435
|
+
};
|