Skip to content

Commit 426d705

Browse files
committed
Split virtualizer
1 parent 006f0ca commit 426d705

File tree

3 files changed

+191
-140
lines changed

3 files changed

+191
-140
lines changed

src/components/DeclarationsSidebar.tsx

Lines changed: 168 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,160 @@ const ROW_HEIGHT = 28;
2626
const STATIC_BEFORE = 19;
2727
const STATIC_AFTER = 60;
2828

29+
const VirtualizedList = ({
30+
rows,
31+
stickyIndexes,
32+
collapsed,
33+
toggleModule,
34+
activeIndex,
35+
activeModule,
36+
scope,
37+
sidebarOpen,
38+
onNavigate,
39+
wrapperRef,
40+
}: {
41+
rows: SidebarRow[];
42+
stickyIndexes: number[];
43+
collapsed: Set<string>;
44+
toggleModule: (module: string) => void;
45+
activeIndex: number;
46+
activeModule: string;
47+
scope: string;
48+
sidebarOpen?: boolean;
49+
onNavigate?: () => void;
50+
wrapperRef: React.RefObject<HTMLDivElement | null>;
51+
}) => {
52+
const parentRef = useRef<HTMLDivElement>(null);
53+
const activeStickyIndexRef = useRef(0);
54+
const navigatedFromSidebarRef = useRef(false);
55+
const isInitialMount = useRef(true);
56+
57+
const [initialOffset] = useState(() => {
58+
const target = activeIndex >= 0 ? activeIndex : 0;
59+
return Math.max(0, target - STATIC_BEFORE) * ROW_HEIGHT;
60+
});
61+
62+
const setParentRef = useCallback(
63+
(el: HTMLDivElement | null) => {
64+
parentRef.current = el;
65+
if (el && initialOffset > 0) {
66+
el.scrollTop = initialOffset;
67+
}
68+
},
69+
[initialOffset],
70+
);
71+
72+
const rangeExtractor = useCallback(
73+
(range: Range) => {
74+
let active = 0;
75+
for (let i = stickyIndexes.length - 1; i >= 0; i--) {
76+
if (range.startIndex >= stickyIndexes[i]) {
77+
active = stickyIndexes[i];
78+
break;
79+
}
80+
}
81+
activeStickyIndexRef.current = active;
82+
83+
const result = defaultRangeExtractor(range);
84+
if (result[0] > active) {
85+
result.unshift(active);
86+
}
87+
return result;
88+
},
89+
[stickyIndexes],
90+
);
91+
92+
const virtualizer = useVirtualizer({
93+
count: rows.length,
94+
getScrollElement: () => parentRef.current,
95+
estimateSize: () => ROW_HEIGHT,
96+
overscan: 20,
97+
rangeExtractor,
98+
initialOffset,
99+
});
100+
101+
// Scroll to active item on navigation (skip if the click came from the sidebar)
102+
useEffect(() => {
103+
if (isInitialMount.current) {
104+
isInitialMount.current = false;
105+
return;
106+
}
107+
if (!scope || !activeModule) return;
108+
if (navigatedFromSidebarRef.current) {
109+
navigatedFromSidebarRef.current = false;
110+
return;
111+
}
112+
if (activeIndex >= 0) {
113+
virtualizer.scrollToIndex(activeIndex, { align: "center" });
114+
}
115+
// Reset wrapper scroll in case the browser scrolled it
116+
if (wrapperRef.current) {
117+
wrapperRef.current.scrollTop = 0;
118+
}
119+
}, [activeModule, scope, activeIndex, virtualizer, wrapperRef]);
120+
121+
// When the mobile sidebar opens, the virtualizer's scroll element transitions
122+
// from display:none to display:flex. The element had zero dimensions while hidden,
123+
// so the virtualizer rendered no items and scrollTop was lost. Re-measure and
124+
// scroll to the active item.
125+
useEffect(() => {
126+
if (!sidebarOpen || !parentRef.current) return;
127+
const raf = requestAnimationFrame(() => {
128+
virtualizer.measure();
129+
if (activeIndex >= 0) {
130+
virtualizer.scrollToIndex(activeIndex, { align: "center" });
131+
}
132+
});
133+
return () => cancelAnimationFrame(raf);
134+
}, [sidebarOpen]);
135+
136+
const handleSidebarNavigate = useCallback(() => {
137+
navigatedFromSidebarRef.current = true;
138+
onNavigate?.();
139+
}, [onNavigate]);
140+
141+
return (
142+
<SidebarList ref={setParentRef}>
143+
<SidebarUl style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
144+
{virtualizer.getVirtualItems().map((virtualRow) => {
145+
const row = rows[virtualRow.index];
146+
const isHeader = row.type === "header";
147+
const isActiveSticky = activeStickyIndexRef.current === virtualRow.index;
148+
149+
return (
150+
<li
151+
key={virtualRow.key}
152+
style={{
153+
...(isActiveSticky
154+
? { position: "sticky", zIndex: 1 }
155+
: { position: "absolute", transform: `translateY(${virtualRow.start}px)` }),
156+
top: 0,
157+
left: 0,
158+
width: "100%",
159+
height: ROW_HEIGHT,
160+
}}
161+
>
162+
{isHeader ? (
163+
<SidebarGroupHeader
164+
data-collapsed={collapsed.has(row.module) || undefined}
165+
onClick={() => toggleModule(row.module)}
166+
>
167+
{row.module} ({row.count})
168+
</SidebarGroupHeader>
169+
) : (
170+
<DeclarationSidebarElement
171+
declaration={row.declaration}
172+
onClick={handleSidebarNavigate}
173+
/>
174+
)}
175+
</li>
176+
);
177+
})}
178+
</SidebarUl>
179+
</SidebarList>
180+
);
181+
};
182+
29183
export const DeclarationsSidebar = ({
30184
onNavigate,
31185
sidebarOpen,
@@ -38,11 +192,7 @@ export const DeclarationsSidebar = ({
38192
const { module: activeModule = "", scope = "" } = useParams();
39193
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
40194
const [hydrated, setHydrated] = useState(false);
41-
const parentRef = useRef<HTMLDivElement>(null);
42195
const wrapperRef = useRef<HTMLDivElement>(null);
43-
const activeStickyIndexRef = useRef(0);
44-
const navigatedFromSidebarRef = useRef(false);
45-
const isInitialMount = useRef(true);
46196

47197
const { rows, stickyIndexes } = useMemo(() => {
48198
const rows: SidebarRow[] = [];
@@ -68,26 +218,6 @@ export const DeclarationsSidebar = ({
68218
return { rows, stickyIndexes };
69219
}, [declarations, filter, collapsed]);
70220

71-
const rangeExtractor = useCallback(
72-
(range: Range) => {
73-
let active = 0;
74-
for (let i = stickyIndexes.length - 1; i >= 0; i--) {
75-
if (range.startIndex >= stickyIndexes[i]) {
76-
active = stickyIndexes[i];
77-
break;
78-
}
79-
}
80-
activeStickyIndexRef.current = active;
81-
82-
const result = defaultRangeExtractor(range);
83-
if (result[0] > active) {
84-
result.unshift(active);
85-
}
86-
return result;
87-
},
88-
[stickyIndexes],
89-
);
90-
91221
const activeIndex = useMemo(() => {
92222
if (!scope || !activeModule) return -1;
93223
return rows.findIndex(
@@ -96,40 +226,16 @@ export const DeclarationsSidebar = ({
96226
);
97227
}, [rows, scope, activeModule]);
98228

99-
const [initialOffset] = useState(() => {
100-
const target = activeIndex >= 0 ? activeIndex : 0;
101-
return Math.max(0, target - STATIC_BEFORE) * ROW_HEIGHT;
102-
});
103-
104-
const setParentRef = useCallback(
105-
(el: HTMLDivElement | null) => {
106-
parentRef.current = el;
107-
if (el && initialOffset > 0) {
108-
el.scrollTop = initialOffset;
109-
}
110-
},
111-
[initialOffset],
112-
);
113-
114-
const virtualizer = useVirtualizer({
115-
count: rows.length,
116-
getScrollElement: () => parentRef.current,
117-
estimateSize: () => ROW_HEIGHT,
118-
overscan: 20,
119-
rangeExtractor,
120-
initialOffset,
121-
});
122-
123229
useLayoutEffect(() => {
124230
setHydrated(true);
125231
}, []);
126232

127233
const staticRows = useMemo(() => {
128234
if (hydrated) return [];
129-
const start = initialOffset / ROW_HEIGHT;
235+
const start = Math.max(0, (activeIndex >= 0 ? activeIndex : 0) - STATIC_BEFORE);
130236
const end = Math.min(rows.length, start + STATIC_BEFORE + STATIC_AFTER + 1);
131237
return rows.slice(start, end);
132-
}, [hydrated, rows, initialOffset]);
238+
}, [hydrated, rows, activeIndex]);
133239

134240
const toggleModule = useCallback((module: string) => {
135241
setCollapsed((prev) => {
@@ -156,46 +262,6 @@ export const DeclarationsSidebar = ({
156262
});
157263
}, [activeModule, scope]);
158264

159-
// Scroll to active item on navigation (skip if the click came from the sidebar)
160-
useEffect(() => {
161-
if (isInitialMount.current) {
162-
isInitialMount.current = false;
163-
return;
164-
}
165-
if (!scope || !activeModule) return;
166-
if (navigatedFromSidebarRef.current) {
167-
navigatedFromSidebarRef.current = false;
168-
return;
169-
}
170-
if (activeIndex >= 0) {
171-
virtualizer.scrollToIndex(activeIndex, { align: "center" });
172-
}
173-
// Reset wrapper scroll in case the browser scrolled it
174-
if (wrapperRef.current) {
175-
wrapperRef.current.scrollTop = 0;
176-
}
177-
}, [activeModule, scope, activeIndex, virtualizer]);
178-
179-
// When the mobile sidebar opens, the virtualizer's scroll element transitions
180-
// from display:none to display:flex. The element had zero dimensions while hidden,
181-
// so the virtualizer rendered no items and scrollTop was lost. Re-measure and
182-
// scroll to the active item.
183-
useEffect(() => {
184-
if (!sidebarOpen || !parentRef.current) return;
185-
const raf = requestAnimationFrame(() => {
186-
virtualizer.measure();
187-
if (activeIndex >= 0) {
188-
virtualizer.scrollToIndex(activeIndex, { align: "center" });
189-
}
190-
});
191-
return () => cancelAnimationFrame(raf);
192-
}, [sidebarOpen]);
193-
194-
const handleSidebarNavigate = useCallback(() => {
195-
navigatedFromSidebarRef.current = true;
196-
onNavigate?.();
197-
}, [onNavigate]);
198-
199265
return (
200266
<SidebarWrapper ref={wrapperRef} aria-label="Classes and enums">
201267
<SidebarHeader>
@@ -219,44 +285,18 @@ export const DeclarationsSidebar = ({
219285
/>
220286
</SidebarHeader>
221287
{hydrated ? (
222-
<SidebarList ref={setParentRef}>
223-
<SidebarUl style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
224-
{virtualizer.getVirtualItems().map((virtualRow) => {
225-
const row = rows[virtualRow.index];
226-
const isHeader = row.type === "header";
227-
const isActiveSticky = activeStickyIndexRef.current === virtualRow.index;
228-
229-
return (
230-
<li
231-
key={virtualRow.key}
232-
style={{
233-
...(isActiveSticky
234-
? { position: "sticky", zIndex: 1 }
235-
: { position: "absolute", transform: `translateY(${virtualRow.start}px)` }),
236-
top: 0,
237-
left: 0,
238-
width: "100%",
239-
height: ROW_HEIGHT,
240-
}}
241-
>
242-
{isHeader ? (
243-
<SidebarGroupHeader
244-
data-collapsed={collapsed.has(row.module) || undefined}
245-
onClick={() => toggleModule(row.module)}
246-
>
247-
{row.module} ({row.count})
248-
</SidebarGroupHeader>
249-
) : (
250-
<DeclarationSidebarElement
251-
declaration={row.declaration}
252-
onClick={handleSidebarNavigate}
253-
/>
254-
)}
255-
</li>
256-
);
257-
})}
258-
</SidebarUl>
259-
</SidebarList>
288+
<VirtualizedList
289+
rows={rows}
290+
stickyIndexes={stickyIndexes}
291+
collapsed={collapsed}
292+
toggleModule={toggleModule}
293+
activeIndex={activeIndex}
294+
activeModule={activeModule}
295+
scope={scope}
296+
sidebarOpen={sidebarOpen}
297+
onNavigate={onNavigate}
298+
wrapperRef={wrapperRef}
299+
/>
260300
) : (
261301
<SidebarList>
262302
<SidebarUl>

src/components/kind-icon/KindIcon.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import React from "react";
2-
31
export type IconKind =
42
| "class"
53
| "enum"
@@ -18,16 +16,20 @@ export type IconKind =
1816
import ICONS_URL from "../../icons.svg?url";
1917
export { ICONS_URL };
2018

21-
export const KindIcon: React.FC<{
19+
export const KindIcon = ({
20+
className,
21+
kind,
22+
size,
23+
}: {
2224
className?: string;
2325
kind: IconKind;
2426
size: "small" | "medium" | "big" | number;
25-
}> = React.memo(({ className, kind, size }) => {
27+
}) => {
2628
const sizes =
2729
typeof size === "number" ? size : size === "small" ? 16 : size === "medium" ? 20 : 24;
2830
return (
2931
<svg className={className} width={sizes} height={sizes} aria-hidden="true">
3032
<use href={`${ICONS_URL}#ki-${kind}`} />
3133
</svg>
3234
);
33-
});
35+
};

0 commit comments

Comments
 (0)