@@ -26,6 +26,160 @@ const ROW_HEIGHT = 28;
2626const STATIC_BEFORE = 19 ;
2727const 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+
29183export 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 >
0 commit comments