1import { createEffect, createSignal, For, type JSX, on, onCleanup, onMount } from "solid-js";
2
3/** Rows rendered past each edge of the view, in px. */
4const OVERSCAN = 600;
5
6/**
7 * A scroller that renders only the rows near the view. Rows take any height: each is estimated until it renders,
8 * then measured, so soft-wrapped text works and resizing re-wraps. The row at the top of the view stays put while
9 * heights settle or rows arrive above it; with `follow`, the end stays in view instead. Focusable, so the keyboard
10 * scrolls it natively.
11 */
12export function VirtualList<T extends object>(props: {
13 items: readonly T[];
14 /** A row's height in px at the list's content width, used until the row has been measured at that width. */
15 estimate: (item: T, width: number) => number;
16 /** Keeps the end in view; the list reports scrolling to or away from the end through `onFollow`. */
17 follow: boolean;
18 onFollow: (atEnd: boolean) => void;
19 /** Called while the view is within a screen of the start. */
20 onStart?: () => void;
21 label: string;
22 class?: string;
23 children: (item: T, index: () => number) => JSX.Element;
24}) {
25 let scroller!: HTMLDivElement;
26 let spacer!: HTMLDivElement;
27 let rows!: HTMLDivElement;
28 let width = 0;
29 let heights = new Float64Array(0);
30 /** `tops[i]` is where row i starts; `tops[n]` is the total height. */
31 let tops = new Float64Array(1);
32 let anchor: { item: T; offset: number } | undefined;
33 /** Where `place` last scrolled to, so the scroll event it causes isn't taken for the user leaving the end. */
34 let placed = -1;
35 let frame = 0;
36 const measured = new WeakMap<T, { width: number; height: number }>();
37 const rowOf = new WeakMap<Element, { item: T; index: () => number }>();
38 /** Set only by `updateRange`, so rows never render from new items with a stale start. */
39 const [view, setView] = createSignal<{ start: number; items: readonly T[] }>({ start: 0, items: [] });
40
41 const sum = (from: number) => {
42 for (let i = from; i < heights.length; i++) tops[i + 1] = tops[i]! + heights[i]!;
43 };
44 const rebuild = () => {
45 const items = props.items;
46 heights = new Float64Array(items.length);
47 tops = new Float64Array(items.length + 1);
48 items.forEach((item, i) => {
49 const known = measured.get(item);
50 heights[i] = known?.width === width ? known.height : props.estimate(item, width);
51 });
52 sum(0);
53 };
54 /** The last row starting at or above `y`. */
55 const rowAt = (y: number) => {
56 let low = 0;
57 let high = heights.length - 1;
58 while (low < high) {
59 const mid = (low + high + 1) >> 1;
60 if (tops[mid]! <= y) low = mid;
61 else high = mid - 1;
62 }
63 return Math.max(0, low);
64 };
65 const updateRange = () => {
66 cancelAnimationFrame(frame);
67 const top = scroller.scrollTop;
68 const start = rowAt(top - OVERSCAN);
69 const end = Math.min(heights.length, rowAt(top + scroller.clientHeight + OVERSCAN) + 1);
70 rows.style.transform = `translateY(${tops[start]}px)`;
71 const { items } = view();
72 if (start === view().start && end - start === items.length && items[0] === props.items[start]) return;
73 setView({ start, items: props.items.slice(start, end) });
74 };
75 /** Scrolls to the end when following, else back to the anchor row, and lays out the rows for there. */
76 const place = () => {
77 spacer.style.height = `${tops[heights.length]}px`;
78 const index = anchor ? props.items.indexOf(anchor.item) : -1;
79 if (props.follow || index === -1) scroller.scrollTop = scroller.scrollHeight;
80 else scroller.scrollTop = tops[index]! + anchor!.offset;
81 placed = scroller.scrollTop;
82 // Rows rendered here get measured next frame; rendering them inside a resize callback would loop.
83 cancelAnimationFrame(frame);
84 frame = requestAnimationFrame(updateRange);
85 };
86
87 const observer = new ResizeObserver((entries) => {
88 let from = Infinity;
89 for (const entry of entries) {
90 if (entry.target === scroller) {
91 const next = entry.contentBoxSize[0]!.inlineSize;
92 if (next === width) continue;
93 width = next;
94 rebuild();
95 from = -1;
96 continue;
97 }
98 const row = rowOf.get(entry.target);
99 if (!row) continue;
100 const height = entry.borderBoxSize[0]!.blockSize;
101 measured.set(row.item, { width, height });
102 const index = row.index();
103 if (heights[index] === height) continue;
104 heights[index] = height;
105 from = Math.min(from, index);
106 }
107 if (from === Infinity) return;
108 if (from >= 0) sum(from);
109 place();
110 });
111
112 const onScroll = () => {
113 const top = scroller.scrollTop;
114 const index = rowAt(top);
115 anchor = props.items[index] && { item: props.items[index], offset: top - tops[index]! };
116 // Against the laid-out height, since rows not yet measured can overhang it.
117 const atEnd = tops[heights.length]! - top - scroller.clientHeight < 2;
118 updateRange();
119 const ours = top === placed;
120 placed = -1;
121 if (atEnd !== props.follow && !ours) props.onFollow(atEnd);
122 if (top < scroller.clientHeight) props.onStart?.();
123 };
124
125 onMount(() => {
126 observer.observe(scroller);
127 width = scroller.clientWidth;
128 });
129 createEffect(on(() => props.items, () => {
130 rebuild();
131 place();
132 updateRange();
133 }));
134 createEffect(on(() => props.follow, (follow) => follow && place(), { defer: true }));
135 onCleanup(() => {
136 observer.disconnect();
137 cancelAnimationFrame(frame);
138 });
139
140 return (
141 <div ref={scroller} class={props.class} style={{ "overflow-y": "auto", "overflow-anchor": "none" }} tabIndex={0}
142 aria-label={props.label} onScroll={onScroll}>
143 <div ref={spacer}>
144 <div ref={rows}>
145 <For each={view().items}>
146 {(item, i) => {
147 const index = () => view().start + i();
148 return (
149 <div ref={(el) => {
150 rowOf.set(el, { item, index });
151 observer.observe(el);
152 onCleanup(() => observer.unobserve(el));
153 }}>
154 {props.children(item, index)}
155 </div>
156 );
157 }}
158 </For>
159 </div>
160 </div>
161 </div>
162 );
163}