@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,683 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
|
6
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
|
+
import { expect } from 'storybook/test';
|
|
8
|
+
|
|
9
|
+
import { IconButton, Toolbar } from '@dxos/react-ui';
|
|
10
|
+
import { withLayout, withTheme } from '@dxos/react-ui/testing';
|
|
11
|
+
import { mx } from '@dxos/ui-theme';
|
|
12
|
+
|
|
13
|
+
import { Minimap, Outline, type OutlineMarker } from '../components';
|
|
14
|
+
import { ListModel } from '../model';
|
|
15
|
+
import { type EdgeDrift } from './placement';
|
|
16
|
+
import { Window, type WindowAxis, type WindowController, type WindowState } from './Window';
|
|
17
|
+
|
|
18
|
+
const EXTENT = (index: number) => 40 + (index % 8) * 30;
|
|
19
|
+
|
|
20
|
+
/** One harness row: identity plus the extent it renders at, so a prepend moves rows, not sizes. */
|
|
21
|
+
type Row = { id: string; extent: number };
|
|
22
|
+
|
|
23
|
+
/** The row `Grow` changes; near the top, so it is mounted whatever the extents turn out to be. */
|
|
24
|
+
const GROWN = 5;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The DOM shape, driven by content that cannot lie about its size.
|
|
28
|
+
*
|
|
29
|
+
* Between `placement.test.ts` (arithmetic, no DOM) and `baseline/*` (a real feed, real editors) sits
|
|
30
|
+
* this: plain boxes of declared extent, so anything that moves here is the shape and not the item.
|
|
31
|
+
* The layer exists because the two things fail differently and a single suite cannot say which.
|
|
32
|
+
*/
|
|
33
|
+
type StoryArgs = {
|
|
34
|
+
/** Rows the model is seeded with; the append/prepend buttons then drive the model directly. */
|
|
35
|
+
count?: number;
|
|
36
|
+
axis?: WindowAxis;
|
|
37
|
+
exact?: boolean;
|
|
38
|
+
/** Extent the row actually renders at — the story controls it, so the story knows the truth. */
|
|
39
|
+
extent?: (index: number) => number;
|
|
40
|
+
/** Outline every row and label its extent, so what is being measured is visible. */
|
|
41
|
+
debug?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Controls a story needs to drive the list, rendered in the toolbar when supplied.
|
|
44
|
+
*
|
|
45
|
+
* Buttons rather than a global the play function calls: a story is rendered more than once, so a
|
|
46
|
+
* `window.__grow` captured in an effect goes stale and the press lands on a previous mount's
|
|
47
|
+
* state — which presented as `Prepend` and `Grow` passing or failing depending on what else ran.
|
|
48
|
+
* A button is the same thing the reader has, and there is only ever one of it.
|
|
49
|
+
*/
|
|
50
|
+
/** Render the append button; 'few' appends 5 rows per press instead of 20 (the sticky story). */
|
|
51
|
+
append?: boolean | 'few';
|
|
52
|
+
/** Keep the last row against the bottom of the viewport as content arrives. */
|
|
53
|
+
sticky?: boolean;
|
|
54
|
+
prepend?: boolean;
|
|
55
|
+
grow?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Reserve a viewport's worth of space after the last row, so it can be read at the top.
|
|
58
|
+
*
|
|
59
|
+
* Computed **here**, from what the window publishes, and handed back as a number — which is the
|
|
60
|
+
* claim §7 makes: with the window placed rather than the rows, this is not a mode the list has.
|
|
61
|
+
*/
|
|
62
|
+
scrollPastEnd?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Extent the host *claims*, when that differs from what renders.
|
|
65
|
+
*
|
|
66
|
+
* Two callbacks, not one, because a story that reports the same number it renders cannot express a
|
|
67
|
+
* host being wrong — which is the only thing `Drift` is about.
|
|
68
|
+
*/
|
|
69
|
+
declared?: (index: number) => number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DefaultStory = ({
|
|
73
|
+
count = 500,
|
|
74
|
+
axis = 'block',
|
|
75
|
+
exact = true,
|
|
76
|
+
extent = EXTENT,
|
|
77
|
+
declared,
|
|
78
|
+
debug,
|
|
79
|
+
scrollPastEnd,
|
|
80
|
+
append,
|
|
81
|
+
prepend,
|
|
82
|
+
grow,
|
|
83
|
+
sticky,
|
|
84
|
+
}: StoryArgs) => {
|
|
85
|
+
// The model the window binds to: the buttons drive it directly, so a prepend is *told* and the
|
|
86
|
+
// stories exercise the told path rather than the replace-adapter's inference (SPEC F-7.1).
|
|
87
|
+
// Items carry their extent — keyed by identity, so a prepend moves rows without resizing them.
|
|
88
|
+
const model = useMemo(
|
|
89
|
+
() =>
|
|
90
|
+
new ListModel<Row>({
|
|
91
|
+
items: Array.from({ length: count }, (_, index) => ({ id: `row-${index}`, extent: extent(index) })),
|
|
92
|
+
getId: (item) => item.id,
|
|
93
|
+
}),
|
|
94
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
95
|
+
[],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// The harness re-renders on structural changes; the window subscribes on its own.
|
|
99
|
+
const [, setVersion] = useState(0);
|
|
100
|
+
useEffect(() => model.subscribe(() => setVersion((version) => version + 1)), [model]);
|
|
101
|
+
const total = model.count;
|
|
102
|
+
|
|
103
|
+
const [edges, setEdges] = useState<EdgeDrift[]>([]);
|
|
104
|
+
const [mismatches, setMismatches] = useState<string[]>([]);
|
|
105
|
+
const [state, setState] = useState<WindowState>();
|
|
106
|
+
const controller = useRef<WindowController>(null);
|
|
107
|
+
const onEdge = useCallback((drift: EdgeDrift) => setEdges((all) => [...all.slice(-4), drift]), []);
|
|
108
|
+
const onMismatch = useCallback(
|
|
109
|
+
(mismatch: { index: number; declared: number; actual: number }) =>
|
|
110
|
+
setMismatches((all) => [...all.slice(-4), `${mismatch.index}: ${mismatch.declared}→${mismatch.actual}`]),
|
|
111
|
+
[],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Everything here is *outside* the window, reading what it publishes: chrome is the host's, and a
|
|
115
|
+
// toolbar that reached inside would be the engine growing an opinion about navigation (§2).
|
|
116
|
+
// Smooth, because a step is a move the reader should be able to follow with their eyes — and it
|
|
117
|
+
// can be here: corrections move the window rather than the scroll (§7), so nothing cancels the
|
|
118
|
+
// animation halfway. The old engine had to make this instant for exactly that reason.
|
|
119
|
+
const step = (delta: number) => controller.current?.scrollToIndex((state?.index ?? 0) + delta, 'start', 'smooth');
|
|
120
|
+
|
|
121
|
+
// Measured from the container this owns, not from what the window publishes.
|
|
122
|
+
//
|
|
123
|
+
// Deriving it from `state.geometry.viewport` looks equivalent and is not: the reserve changes the
|
|
124
|
+
// sizer, the sizer changes the layout, the layout is what publishes the viewport — and the whole
|
|
125
|
+
// thing oscillates. React said `Maximum update depth exceeded`, which is the same "two things
|
|
126
|
+
// compensating for one change" this design exists to remove, wearing a new hat. A reserve is an
|
|
127
|
+
// input to the layout; anything read back out of the layout cannot be one.
|
|
128
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
129
|
+
const [available, setAvailable] = useState(0);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const element = bodyRef.current;
|
|
132
|
+
if (!element) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const read = () => setAvailable(axis === 'block' ? element.clientHeight : element.clientWidth);
|
|
137
|
+
read();
|
|
138
|
+
const observer = new ResizeObserver(read);
|
|
139
|
+
observer.observe(element);
|
|
140
|
+
return () => observer.disconnect();
|
|
141
|
+
}, [axis]);
|
|
142
|
+
|
|
143
|
+
const reserve = scrollPastEnd ? Math.max(0, available - (model.at(total - 1)?.extent ?? 100)) : 0;
|
|
144
|
+
|
|
145
|
+
// The product minimap, in **index** space: a tick per marker, and the mounted window as the
|
|
146
|
+
// visible range. Beside `Minimap`, which is the same list in **content** space — the pair is the
|
|
147
|
+
// point, since an index-space rail cannot show a layout that has gone wrong and a content-space
|
|
148
|
+
// one cannot show which message you are near.
|
|
149
|
+
const markers = useMemo<OutlineMarker[]>(
|
|
150
|
+
() =>
|
|
151
|
+
Array.from({ length: Math.ceil(total / 10) }, (_, step) => ({
|
|
152
|
+
id: `marker-${step * 10}`,
|
|
153
|
+
title: `#${step * 10}`,
|
|
154
|
+
range: { from: step * 10, to: step * 10 + 10 },
|
|
155
|
+
})),
|
|
156
|
+
[total],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className='flex flex-col h-full'>
|
|
161
|
+
<Toolbar.Root>
|
|
162
|
+
<IconButton
|
|
163
|
+
icon='ph--caret-up--regular'
|
|
164
|
+
iconOnly
|
|
165
|
+
label='Previous'
|
|
166
|
+
data-testid='window.prev'
|
|
167
|
+
onClick={() => step(-1)}
|
|
168
|
+
/>
|
|
169
|
+
<IconButton
|
|
170
|
+
icon='ph--caret-down--regular'
|
|
171
|
+
iconOnly
|
|
172
|
+
label='Next'
|
|
173
|
+
data-testid='window.next'
|
|
174
|
+
onClick={() => step(1)}
|
|
175
|
+
/>
|
|
176
|
+
<Toolbar.Separator />
|
|
177
|
+
<IconButton
|
|
178
|
+
icon='ph--arrow-line-up--regular'
|
|
179
|
+
iconOnly
|
|
180
|
+
label='Top'
|
|
181
|
+
data-testid='window.top'
|
|
182
|
+
onClick={() => controller.current?.scrollToIndex(0)}
|
|
183
|
+
/>
|
|
184
|
+
<IconButton
|
|
185
|
+
icon='ph--arrow-line-down--regular'
|
|
186
|
+
iconOnly
|
|
187
|
+
label='Bottom'
|
|
188
|
+
data-testid='window.bottom'
|
|
189
|
+
onClick={() => controller.current?.scrollToIndex(total - 1, 'end')}
|
|
190
|
+
/>
|
|
191
|
+
{(append || prepend || grow) && <Toolbar.Separator />}
|
|
192
|
+
{prepend && (
|
|
193
|
+
<IconButton
|
|
194
|
+
icon='ph--arrow-u-left-up--regular'
|
|
195
|
+
iconOnly
|
|
196
|
+
label='Prepend'
|
|
197
|
+
data-testid='window.prepend'
|
|
198
|
+
onClick={() =>
|
|
199
|
+
model.prepend(
|
|
200
|
+
Array.from({ length: 20 }, (_, index) => ({
|
|
201
|
+
id: `earlier-${model.count}-${index}`,
|
|
202
|
+
extent: EXTENT(index),
|
|
203
|
+
})),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
/>
|
|
207
|
+
)}
|
|
208
|
+
{append && (
|
|
209
|
+
<IconButton
|
|
210
|
+
icon='ph--arrow-u-right-down--regular'
|
|
211
|
+
iconOnly
|
|
212
|
+
label='Append'
|
|
213
|
+
data-testid='window.append'
|
|
214
|
+
onClick={() =>
|
|
215
|
+
model.append(
|
|
216
|
+
Array.from({ length: append === 'few' ? 5 : 20 }, (_, index) => ({
|
|
217
|
+
id: `later-${model.count}-${index}`,
|
|
218
|
+
extent: EXTENT(index),
|
|
219
|
+
})),
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
{grow && (
|
|
225
|
+
<IconButton
|
|
226
|
+
icon='ph--arrows-out-line-vertical--regular'
|
|
227
|
+
iconOnly
|
|
228
|
+
label='Grow'
|
|
229
|
+
data-testid='window.grow'
|
|
230
|
+
// A change in place: same identity, new extent — the model says which row, and the
|
|
231
|
+
// measurement pass finds the new size (§8).
|
|
232
|
+
onClick={() => {
|
|
233
|
+
const item = model.at(GROWN);
|
|
234
|
+
if (item) {
|
|
235
|
+
item.extent = 400;
|
|
236
|
+
model.update(item.id);
|
|
237
|
+
}
|
|
238
|
+
}}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
</Toolbar.Root>
|
|
242
|
+
|
|
243
|
+
<div ref={bodyRef} className='grow min-h-0 flex gap-2'>
|
|
244
|
+
<div className='h-full grid grid-rows-[1fr_3fr_1fr]'>
|
|
245
|
+
<div className='flex items-center row-start-2'>
|
|
246
|
+
<Outline
|
|
247
|
+
markers={markers}
|
|
248
|
+
visibleRange={state ? { from: state.visible.first, to: state.visible.last } : undefined}
|
|
249
|
+
onSelect={(marker) => controller.current?.scrollToIndex(marker.range.from)}
|
|
250
|
+
onNavigate={step}
|
|
251
|
+
/>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<Window
|
|
256
|
+
classNames='grow min-h-0'
|
|
257
|
+
model={model}
|
|
258
|
+
extents={{ of: declared ?? ((index) => model.at(index)?.extent ?? 100), exact }}
|
|
259
|
+
axis={axis}
|
|
260
|
+
reserve={reserve}
|
|
261
|
+
sticky={sticky}
|
|
262
|
+
controllerRef={controller}
|
|
263
|
+
onChange={setState}
|
|
264
|
+
onEdge={onEdge}
|
|
265
|
+
onMismatch={onMismatch}
|
|
266
|
+
>
|
|
267
|
+
{(index) => {
|
|
268
|
+
const size = model.at(index)?.extent ?? 100;
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
className={mx(
|
|
272
|
+
'flex items-center justify-center border border-separator text-xs tabular-nums',
|
|
273
|
+
index % 2 ? 'bg-input-surface' : 'bg-base-surface',
|
|
274
|
+
debug && 'outline outline-1 outline-dashed outline-primary-500/50',
|
|
275
|
+
)}
|
|
276
|
+
style={axis === 'block' ? { height: size } : { width: size }}
|
|
277
|
+
>
|
|
278
|
+
{debug ? `${index} · ${size}` : index}
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}}
|
|
282
|
+
</Window>
|
|
283
|
+
|
|
284
|
+
<Minimap
|
|
285
|
+
state={state}
|
|
286
|
+
onSelect={(fraction) => controller.current?.scrollToIndex(Math.round(fraction * (total - 1)))}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div className='px-2 py-1 flex gap-4 text-xs text-description tabular-nums' data-testid='placement.report'>
|
|
291
|
+
<span data-testid='window.index'>{state?.index ?? 0}</span>
|
|
292
|
+
<span data-testid='window.range'>
|
|
293
|
+
{state ? `${state.visible.first}–${state.visible.last}` : '—'} of {state?.count ?? 0}
|
|
294
|
+
</span>
|
|
295
|
+
<span className='grow' />
|
|
296
|
+
<span>{`edges ${edges.map(({ edge, delta }) => `${edge}${delta}`).join(' ') || '—'} · mismatch ${mismatches.join(' ') || '—'}`}</span>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const meta: Meta<StoryArgs> = {
|
|
303
|
+
title: 'ui/react-ui-feed/virtualizer',
|
|
304
|
+
render: DefaultStory,
|
|
305
|
+
decorators: [withLayout({ layout: 'column', classNames: 'w-[50rem]' }), withTheme()],
|
|
306
|
+
parameters: { layout: 'fullscreen' },
|
|
307
|
+
argTypes: {
|
|
308
|
+
debug: { control: 'boolean' },
|
|
309
|
+
scrollPastEnd: { control: 'boolean' },
|
|
310
|
+
},
|
|
311
|
+
// Listed in `args`, not only `argTypes`: the meta names no `component`, so storybook shows exactly
|
|
312
|
+
// the args that are set and a control declared only in `argTypes` never appears.
|
|
313
|
+
args: {
|
|
314
|
+
count: 500,
|
|
315
|
+
debug: false,
|
|
316
|
+
scrollPastEnd: false,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export default meta;
|
|
321
|
+
|
|
322
|
+
type Story = StoryObj<StoryArgs>;
|
|
323
|
+
|
|
324
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
325
|
+
|
|
326
|
+
const settle = async (frames = 20) => {
|
|
327
|
+
for (let frame = 0; frame < frames; frame++) {
|
|
328
|
+
await nextFrame();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
type Probe = {
|
|
333
|
+
scroller: HTMLElement;
|
|
334
|
+
window: HTMLElement;
|
|
335
|
+
sizer: HTMLElement;
|
|
336
|
+
offset: number;
|
|
337
|
+
rows: Map<number, number>;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** Everything a story asserts on, read in one frame so nothing is compared across a change. */
|
|
341
|
+
const probe = (canvasElement: HTMLElement, axis: WindowAxis = 'block'): Probe => {
|
|
342
|
+
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
|
|
343
|
+
const win = canvasElement.querySelector<HTMLElement>('[data-testid="window.window"]')!;
|
|
344
|
+
const sizer = canvasElement.querySelector<HTMLElement>('[data-testid="window.sizer"]')!;
|
|
345
|
+
const origin = scroller.getBoundingClientRect();
|
|
346
|
+
const rows = new Map(
|
|
347
|
+
[...win.children].map((child) => {
|
|
348
|
+
const row = child as HTMLElement;
|
|
349
|
+
const box = row.getBoundingClientRect();
|
|
350
|
+
return [Number(row.dataset.index), Math.round(axis === 'block' ? box.top - origin.top : box.left - origin.left)];
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const matched = /translate[XY]\(([-\d.]+)px\)/.exec(win.style.transform);
|
|
355
|
+
return { scroller, window: win, sizer, offset: Math.round(Number(matched?.[1] ?? 0)), rows };
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Rows present in both readings that are not where they were. */
|
|
359
|
+
const moved = (before: Probe, after: Probe): number[] =>
|
|
360
|
+
[...after.rows]
|
|
361
|
+
.filter(([index, at]) => before.rows.has(index) && Math.abs(before.rows.get(index)! - at) > 1)
|
|
362
|
+
.map(([index]) => index);
|
|
363
|
+
|
|
364
|
+
//
|
|
365
|
+
// The properties, one story each.
|
|
366
|
+
//
|
|
367
|
+
|
|
368
|
+
/** The harness at rest, for the eye and the controls panel. No play. */
|
|
369
|
+
export const Default: Story = {
|
|
370
|
+
args: { append: true, prepend: true, grow: true },
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The window is placed where the arithmetic says, and the edges agree with the model.
|
|
375
|
+
*
|
|
376
|
+
* `offset === 0` at the start and `offset + window === sizer` at the end are where estimate meets
|
|
377
|
+
* ground truth — with `exact` extents they must hold to the pixel, which is what makes this the
|
|
378
|
+
* control the other stories are read against.
|
|
379
|
+
*/
|
|
380
|
+
export const Static: Story = {
|
|
381
|
+
play: async ({ canvasElement }) => {
|
|
382
|
+
await settle();
|
|
383
|
+
const top = probe(canvasElement);
|
|
384
|
+
await expect(top.offset).toEqual(0);
|
|
385
|
+
|
|
386
|
+
top.scroller.scrollTop = top.scroller.scrollHeight;
|
|
387
|
+
await settle();
|
|
388
|
+
|
|
389
|
+
const bottom = probe(canvasElement);
|
|
390
|
+
const windowExtent = bottom.window.getBoundingClientRect().height;
|
|
391
|
+
await expect({
|
|
392
|
+
startsAtZero: top.offset === 0,
|
|
393
|
+
endsAtTheEnd: Math.abs(bottom.offset + Math.round(windowExtent) - bottom.sizer.offsetHeight) <= 1,
|
|
394
|
+
}).toEqual({ startsAtZero: true, endsAtTheEnd: true });
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/** Rows arriving at the end move nothing that is already on screen, and do not move the window. */
|
|
399
|
+
export const Append: Story = {
|
|
400
|
+
args: { count: 200, append: true },
|
|
401
|
+
play: async ({ canvasElement }) => {
|
|
402
|
+
await settle();
|
|
403
|
+
const before = probe(canvasElement);
|
|
404
|
+
|
|
405
|
+
(canvasElement.querySelector('[data-testid="window.append"]') as HTMLElement).click();
|
|
406
|
+
await settle();
|
|
407
|
+
|
|
408
|
+
const after = probe(canvasElement);
|
|
409
|
+
await expect({ moved: moved(before, after), offset: after.offset === before.offset }).toEqual({
|
|
410
|
+
moved: [],
|
|
411
|
+
offset: true,
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The hard case: rows inserted *before* the reader.
|
|
418
|
+
*
|
|
419
|
+
* Everything at or after the anchor has to stay exactly where it is, in the frame the insert lands —
|
|
420
|
+
* which is the whole reason placement is anchor-relative rather than summed from index 0.
|
|
421
|
+
*/
|
|
422
|
+
export const Prepend: Story = {
|
|
423
|
+
args: { count: 200, prepend: true },
|
|
424
|
+
play: async ({ canvasElement }) => {
|
|
425
|
+
await settle();
|
|
426
|
+
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
|
|
427
|
+
scroller.scrollTop = 3_000;
|
|
428
|
+
await settle();
|
|
429
|
+
const before = probe(canvasElement);
|
|
430
|
+
|
|
431
|
+
(canvasElement.querySelector('[data-testid="window.prepend"]') as HTMLElement).click();
|
|
432
|
+
await settle();
|
|
433
|
+
|
|
434
|
+
// Compared by the row's own identity, shifted by the insert: index 40 became index 60.
|
|
435
|
+
const after = probe(canvasElement);
|
|
436
|
+
const shifted = [...after.rows].filter(([index]) => before.rows.has(index - 20));
|
|
437
|
+
const jumped = shifted.filter(([index, at]) => Math.abs(before.rows.get(index - 20)! - at) > 1);
|
|
438
|
+
await expect({ compared: shifted.length > 0, jumped: jumped.map(([index]) => index) }).toEqual({
|
|
439
|
+
compared: true,
|
|
440
|
+
jumped: [],
|
|
441
|
+
});
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A row changing extent moves the rows after it and none before it — and costs us nothing.
|
|
447
|
+
*
|
|
448
|
+
* This is §6 in one assertion: the browser reflows the siblings. With rows placed by transform it
|
|
449
|
+
* was 177 re-placements written by us for one disclosure.
|
|
450
|
+
*/
|
|
451
|
+
export const Grow: Story = {
|
|
452
|
+
args: { count: 200, exact: false, grow: true },
|
|
453
|
+
play: async ({ canvasElement }) => {
|
|
454
|
+
await settle();
|
|
455
|
+
const before = probe(canvasElement);
|
|
456
|
+
// Grown at the top of the list rather than after a scroll: which rows are mounted depends on the
|
|
457
|
+
// extent function, and a story that scrolls to a fixed offset and then names a fixed row is
|
|
458
|
+
// asserting against wherever that row happened to be the day it was written. It named row 30 at
|
|
459
|
+
// an offset that mounts rows 6–29, and read "the grow did nothing".
|
|
460
|
+
if (!before.rows.has(GROWN)) {
|
|
461
|
+
throw new Error(`row ${GROWN} should be mounted at the top; mounted ${[...before.rows.keys()].join(',')}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
(canvasElement.querySelector('[data-testid="window.grow"]') as HTMLElement).click();
|
|
465
|
+
await settle();
|
|
466
|
+
|
|
467
|
+
const after = probe(canvasElement);
|
|
468
|
+
const displaced = moved(before, after);
|
|
469
|
+
await expect({
|
|
470
|
+
before: displaced.filter((index) => index < GROWN),
|
|
471
|
+
after: displaced.some((index) => index > GROWN),
|
|
472
|
+
}).toEqual({ before: [], after: true });
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/** A jump lands, and both edge invariants still hold once it has. */
|
|
477
|
+
export const Jump: Story = {
|
|
478
|
+
play: async ({ canvasElement }) => {
|
|
479
|
+
await settle();
|
|
480
|
+
const { scroller } = probe(canvasElement);
|
|
481
|
+
scroller.scrollTop = scroller.scrollHeight / 2;
|
|
482
|
+
await settle();
|
|
483
|
+
|
|
484
|
+
const middle = probe(canvasElement);
|
|
485
|
+
scroller.scrollTop = 0;
|
|
486
|
+
await settle();
|
|
487
|
+
|
|
488
|
+
const top = probe(canvasElement);
|
|
489
|
+
await expect({ arrived: middle.rows.size > 0, backAtZero: top.offset }).toEqual({ arrived: true, backAtZero: 0 });
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/** The same shape on the other axis, where the layout constrains the extent and nothing is measured. */
|
|
494
|
+
export const Horizontal: Story = {
|
|
495
|
+
args: { axis: 'inline', count: 200 },
|
|
496
|
+
play: async ({ canvasElement }) => {
|
|
497
|
+
await settle();
|
|
498
|
+
const start = probe(canvasElement, 'inline');
|
|
499
|
+
await expect(start.offset).toEqual(0);
|
|
500
|
+
|
|
501
|
+
start.scroller.scrollLeft = 2_000;
|
|
502
|
+
await settle();
|
|
503
|
+
|
|
504
|
+
const scrolled = probe(canvasElement, 'inline');
|
|
505
|
+
await expect({ moved: scrolled.rows.size > 0, offset: scrolled.offset > 0 }).toEqual({ moved: true, offset: true });
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* A declared extent that is wrong is **reported**, not silently absorbed.
|
|
511
|
+
*
|
|
512
|
+
* `exact` is a promise the host makes, and a promise nobody checks is how a 1px separator costs a day
|
|
513
|
+
* of bisection (§8). The row renders at a size the callback did not claim; the mismatch names it.
|
|
514
|
+
*/
|
|
515
|
+
export const Drift: Story = {
|
|
516
|
+
// Rows render at their real extent; the callback under-reports by 20px on every third row.
|
|
517
|
+
args: {
|
|
518
|
+
count: 100,
|
|
519
|
+
exact: true,
|
|
520
|
+
extent: EXTENT,
|
|
521
|
+
declared: (index: number) => (index % 3 === 0 ? EXTENT(index) - 20 : EXTENT(index)),
|
|
522
|
+
},
|
|
523
|
+
play: async ({ canvasElement }) => {
|
|
524
|
+
await settle();
|
|
525
|
+
|
|
526
|
+
const report = canvasElement.querySelector('[data-testid="placement.report"]')!.textContent ?? '';
|
|
527
|
+
await expect({ reported: /mismatch \d+:/.test(report) }).toEqual({ reported: true });
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The chrome, which is outside the window and reads what it publishes.
|
|
533
|
+
*
|
|
534
|
+
* Tested because untested chrome is how a story ended up pinned to half the screen height without
|
|
535
|
+
* anything failing: the assertions were all about the rows, and nothing asked whether what surrounds
|
|
536
|
+
* them was right (§12).
|
|
537
|
+
*/
|
|
538
|
+
export const Chrome: Story = {
|
|
539
|
+
args: { count: 200 },
|
|
540
|
+
play: async ({ canvasElement }) => {
|
|
541
|
+
await settle();
|
|
542
|
+
const read = (testId: string) => canvasElement.querySelector(`[data-testid="${testId}"]`)!.textContent!.trim();
|
|
543
|
+
const click = (testId: string) => (canvasElement.querySelector(`[data-testid="${testId}"]`) as HTMLElement).click();
|
|
544
|
+
const { scroller } = probe(canvasElement);
|
|
545
|
+
|
|
546
|
+
// The scroller fills what it is given, rather than a height somebody typed.
|
|
547
|
+
const container = scroller.parentElement!.getBoundingClientRect();
|
|
548
|
+
const filled = scroller.getBoundingClientRect().height >= container.height - 1;
|
|
549
|
+
|
|
550
|
+
const start = read('window.index');
|
|
551
|
+
click('window.next');
|
|
552
|
+
await settle();
|
|
553
|
+
const next = read('window.index');
|
|
554
|
+
|
|
555
|
+
click('window.bottom');
|
|
556
|
+
await settle();
|
|
557
|
+
const bottom = probe(canvasElement);
|
|
558
|
+
const atEnd = bottom.scroller.scrollTop > 0;
|
|
559
|
+
|
|
560
|
+
click('window.top');
|
|
561
|
+
await settle();
|
|
562
|
+
// Read before the keyboard step below, which moves it again.
|
|
563
|
+
const backToTop = read('window.index');
|
|
564
|
+
|
|
565
|
+
const tick = canvasElement.querySelector<HTMLElement>('[role="navigation"] button')!;
|
|
566
|
+
tick.focus();
|
|
567
|
+
const before = read('window.index');
|
|
568
|
+
tick.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
|
|
569
|
+
await settle();
|
|
570
|
+
const outlineStepped = read('window.index') !== before;
|
|
571
|
+
|
|
572
|
+
await expect({
|
|
573
|
+
filled,
|
|
574
|
+
moved: next !== start,
|
|
575
|
+
atEnd,
|
|
576
|
+
backToTop,
|
|
577
|
+
range: /^\d+–\d+ of 200$/.test(read('window.range')),
|
|
578
|
+
map: canvasElement.querySelectorAll('[data-testid="minimap.viewport"]').length,
|
|
579
|
+
// The two rails flank the same list and default to the same width; a prop that is accepted and
|
|
580
|
+
// ignored looks exactly like one that works until they are put side by side.
|
|
581
|
+
// Arrows from a focused tick step the document, not the rail: the rail thins its markers, so
|
|
582
|
+
// stepping ticks would jump ten items at a time.
|
|
583
|
+
outlineNavigates: outlineStepped,
|
|
584
|
+
railsMatch:
|
|
585
|
+
Math.round(canvasElement.querySelector('[data-testid="minimap"]')!.getBoundingClientRect().width) ===
|
|
586
|
+
Math.round(canvasElement.querySelector('[role="navigation"]')!.getBoundingClientRect().width),
|
|
587
|
+
}).toEqual({
|
|
588
|
+
filled: true,
|
|
589
|
+
moved: true,
|
|
590
|
+
atEnd: true,
|
|
591
|
+
backToTop: '0',
|
|
592
|
+
range: true,
|
|
593
|
+
map: 1,
|
|
594
|
+
outlineNavigates: true,
|
|
595
|
+
railsMatch: true,
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* With space reserved, the last row can be brought to the top of the viewport.
|
|
602
|
+
*
|
|
603
|
+
* On the old design this was a flag that special-cased the tail wherever it was consulted, and it
|
|
604
|
+
* could never be stabilised. Here the host computes a number and the list adds it to the sizer;
|
|
605
|
+
* nothing else in the engine knows it happened (§7).
|
|
606
|
+
*/
|
|
607
|
+
export const PastEnd: Story = {
|
|
608
|
+
args: { count: 200, scrollPastEnd: true },
|
|
609
|
+
play: async ({ canvasElement }) => {
|
|
610
|
+
await settle();
|
|
611
|
+
const { scroller } = probe(canvasElement);
|
|
612
|
+
|
|
613
|
+
// "Bottom" means the last message resting on the bottom of the screen, not the end of the
|
|
614
|
+
// scrollable range: reserved space is somewhere the reader may go, not somewhere to be sent.
|
|
615
|
+
// Parking the last row at the top of an empty screen is exactly what the old design did.
|
|
616
|
+
(canvasElement.querySelector('[data-testid="window.bottom"]') as HTMLElement).click();
|
|
617
|
+
await settle();
|
|
618
|
+
const atBottom = canvasElement.querySelector<HTMLElement>('[data-index="199"]');
|
|
619
|
+
const rests =
|
|
620
|
+
!!atBottom && Math.abs(atBottom.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
|
|
621
|
+
|
|
622
|
+
// And the reserve is what lets the reader take that same row to the top. Scrolled twice: the
|
|
623
|
+
// reserve is measured from the container, so it exists one render after the first layout.
|
|
624
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
625
|
+
await settle();
|
|
626
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
627
|
+
await settle();
|
|
628
|
+
const atEnd = canvasElement.querySelector<HTMLElement>('[data-index="199"]');
|
|
629
|
+
const reachesTheTop =
|
|
630
|
+
!!atEnd && Math.abs(atEnd.getBoundingClientRect().top - scroller.getBoundingClientRect().top) <= 2;
|
|
631
|
+
|
|
632
|
+
await expect({ rests, reachesTheTop }).toEqual({ rests: true, reachesTheTop: true });
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* A feed pinned to its tail stays there as content arrives.
|
|
638
|
+
*
|
|
639
|
+
* The one thing the old engine spent the most machinery on — a follow, a sticky flag, a re-base and
|
|
640
|
+
* a scroll listener that had to tell the reader apart from itself. Here it is a standing intent: at
|
|
641
|
+
* the end, appending keeps you at the end; away from it, appending does not move you.
|
|
642
|
+
*/
|
|
643
|
+
export const Sticky: Story = {
|
|
644
|
+
args: { count: 200, sticky: true, append: 'few' },
|
|
645
|
+
play: async ({ canvasElement }) => {
|
|
646
|
+
await settle();
|
|
647
|
+
const scroller = canvasElement.querySelector<HTMLElement>('[data-testid="window.scroller"]')!;
|
|
648
|
+
const append = () => (canvasElement.querySelector('[data-testid="window.append"]') as HTMLElement).click();
|
|
649
|
+
const atTail = (count: number) => {
|
|
650
|
+
const last = canvasElement.querySelector<HTMLElement>(`[data-index="${count - 1}"]`);
|
|
651
|
+
return !!last && Math.abs(last.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom) <= 2;
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
// Driven to the end first: the reader has to *be* at the tail for following it to mean anything.
|
|
655
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
656
|
+
scroller.scrollTop = scroller.scrollHeight;
|
|
657
|
+
await settle(20);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const arrived = atTail(200);
|
|
661
|
+
append();
|
|
662
|
+
// The follow *travels* now (glide, ~2 rows/s) — arrival is by deceleration, not teleport, so
|
|
663
|
+
// the reading is "lands and stays", polled to landing rather than sampled mid-flight.
|
|
664
|
+
let followed = false;
|
|
665
|
+
for (let frame = 0; frame < 300 && !followed; frame++) {
|
|
666
|
+
await nextFrame();
|
|
667
|
+
followed = atTail(205);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// And away from the tail it must not drag the reader: a feed that follows whatever the reader is
|
|
671
|
+
// doing is a feed that cannot be read while it is busy. The wheel first — a scroll with no
|
|
672
|
+
// gesture behind it is the machinery's, and the follow would undo it.
|
|
673
|
+
scroller.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -100 }));
|
|
674
|
+
scroller.scrollTop = 0;
|
|
675
|
+
await settle();
|
|
676
|
+
const away = Math.round(scroller.scrollTop);
|
|
677
|
+
append();
|
|
678
|
+
await settle();
|
|
679
|
+
const stayed = Math.abs(scroller.scrollTop - away) <= 2;
|
|
680
|
+
|
|
681
|
+
await expect({ arrived, followed, stayed }).toEqual({ arrived: true, followed: true, stayed: true });
|
|
682
|
+
},
|
|
683
|
+
};
|