Skip to content

Commit 4690c25

Browse files
authored
TCR-772 : fix(sidebar): improve active item highlighting with Intersection Observer
- Replace flawed isInViewport logic with modern Intersection Observer API - Fix highlighting not working correctly on initial page load - Account for 4rem fixed header offset with proper rootMargin - Add throttled scroll event handler for performance optimization - Properly clean up observer and event listeners on unmount - Select topmost visible heading for accurate sidebar highlighting
2 parents a6dd453 + b9357a8 commit 4690c25

File tree

1 file changed

+160
-46
lines changed

1 file changed

+160
-46
lines changed

docs/.vuepress/theme/sidebar/Sidebar.vue

Lines changed: 160 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -68,48 +68,126 @@ const toggleGroup = (index) => {
6868
openGroupIndex.value = index === openGroupIndex.value ? -1 : index
6969
}
7070
71-
const isInViewport = (element) => {
72-
const rect = element.getBoundingClientRect();
73-
74-
return (
75-
rect.top >= 0 &&
76-
rect.left >= 0 &&
77-
rect.bottom <= (window.innerHeight / 2 || document.documentElement.clientHeight / 2) &&
78-
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
79-
);
80-
};
81-
watch(() => route, refreshIndex)
71+
watch(() => route, () => {
72+
refreshIndex()
73+
// Reinitialize observer when route changes
74+
if (!props.isMobileWidth) {
75+
setTimeout(() => {
76+
setupIntersectionObserver()
77+
}, 100)
78+
}
79+
})
8280
83-
const checkIfScroll = () => {
84-
const pageAnchors = document.querySelectorAll('.header-anchor')
81+
// Track visible headings for Intersection Observer
82+
const visibleHeadings = ref(new Set())
83+
let intersectionObserver = null
84+
let scrollThrottleTimeout = null
85+
86+
// Throttle function for scroll events
87+
const throttle = (func, delay) => {
88+
return (...args) => {
89+
if (!scrollThrottleTimeout) {
90+
scrollThrottleTimeout = setTimeout(() => {
91+
func(...args)
92+
scrollThrottleTimeout = null
93+
}, delay)
94+
}
95+
}
96+
}
97+
98+
// Update sidebar active state based on the topmost visible heading
99+
const updateSidebarActiveState = () => {
85100
const sidebar = document.querySelector('.sidebar')
101+
if (!sidebar) return
102+
86103
const sidebarAnchors = sidebar.querySelectorAll('a')
87104
const sidebarAnchorsContainer = sidebar.querySelectorAll('.collapsible.sidebar-sub-header')
88-
const sidebarStringLinks = Array.from(sidebarAnchors).map(a => a.getAttribute('data-anchor'))
89-
90-
pageAnchors.forEach((a)=>{
91-
if(a.getAttribute('data-anchor')) return
92-
a.setAttribute('data-anchor', page.value.path+a.hash)
105+
106+
// Get all visible headings sorted by their position (topmost first)
107+
const sortedVisibleHeadings = Array.from(visibleHeadings.value).sort((a, b) => {
108+
const aRect = a.getBoundingClientRect()
109+
const bRect = b.getBoundingClientRect()
110+
return aRect.top - bRect.top
93111
})
94112
95-
pageAnchors.forEach(a => {
96-
if (isInViewport(a)) {
97-
const currentLink = sidebarStringLinks.find(link => link === a.getAttribute('data-anchor'))
98-
sidebarAnchorsContainer.forEach(container => {
99-
container.querySelectorAll('.sidebar-link-container').forEach(cl => {
100-
if (container.querySelector(`a[data-anchor="${currentLink}"]`)) cl.classList.remove("collapsed")
101-
else cl.classList.add("collapsed")
102-
})
113+
// Select the topmost visible heading
114+
const topmostHeading = sortedVisibleHeadings[0]
115+
116+
if (!topmostHeading) return
117+
118+
const currentAnchor = topmostHeading.getAttribute('data-anchor')
119+
120+
// Update active class on sidebar links
121+
sidebarAnchors.forEach(a => a.classList.remove('active'))
122+
const activeLink = sidebar.querySelector(`a[data-anchor="${currentAnchor}"]`)
123+
124+
if (activeLink) {
125+
activeLink.classList.add('active')
126+
127+
// Expand/collapse collapsible containers
128+
sidebarAnchorsContainer.forEach(container => {
129+
container.querySelectorAll('.sidebar-link-container').forEach(cl => {
130+
if (container.querySelector(`a[data-anchor="${currentAnchor}"]`)) {
131+
cl.classList.remove("collapsed")
132+
} else {
133+
cl.classList.add("collapsed")
134+
}
103135
})
136+
})
137+
}
138+
}
104139
105-
if (sidebar.querySelector(`a[data-anchor="${currentLink}"]`)) {
106-
sidebarAnchors.forEach(a => a.classList.remove("active"))
107-
sidebar.querySelector(`a[data-anchor="${currentLink}"]`).classList.add("active")
108-
}
140+
// Setup Intersection Observer for heading visibility tracking
141+
const setupIntersectionObserver = () => {
142+
const pageAnchors = document.querySelectorAll('.header-anchor')
143+
const sidebar = document.querySelector('.sidebar')
144+
145+
if (!pageAnchors.length || !sidebar) return
146+
147+
// Set data-anchor attribute on page anchors
148+
pageAnchors.forEach((anchor) => {
149+
if (!anchor.getAttribute('data-anchor')) {
150+
anchor.setAttribute('data-anchor', page.value.path + anchor.hash)
151+
}
152+
})
153+
154+
// Disconnect existing observer if any
155+
if (intersectionObserver) {
156+
intersectionObserver.disconnect()
157+
}
158+
159+
// Create new Intersection Observer
160+
// rootMargin: -80px accounts for 4rem (64px) fixed header + 16px buffer
161+
// -80% bottom margin triggers when heading enters top 20% of viewport
162+
intersectionObserver = new IntersectionObserver(
163+
(entries) => {
164+
entries.forEach((entry) => {
165+
const anchor = entry.target.getAttribute('data-anchor')
166+
if (entry.isIntersecting) {
167+
visibleHeadings.value.add(entry.target)
168+
} else {
169+
visibleHeadings.value.delete(entry.target)
170+
}
171+
})
172+
updateSidebarActiveState()
173+
},
174+
{
175+
rootMargin: '-80px 0px -80% 0px',
176+
threshold: [0, 0.25, 0.5, 0.75, 1]
109177
}
178+
)
179+
180+
// Observe all page anchors
181+
pageAnchors.forEach((anchor) => {
182+
intersectionObserver.observe(anchor)
110183
})
111184
}
112185
186+
// Throttled version of setupIntersectionObserver for scroll events
187+
const throttledUpdateSidebar = throttle(() => {
188+
updateSidebarActiveState()
189+
}, 100)
190+
113191
const resolveOpenGroupIndex = (route, items) => {
114192
for (let i = 0; i < items.length; i++) {
115193
const item = items[i]
@@ -124,36 +202,72 @@ const resolveOpenGroupIndex = (route, items) => {
124202
const handleHashChange = () => {
125203
// Get the current hash from the URL
126204
const currentHash = window.location.hash;
205+
206+
if (!currentHash) return;
207+
208+
const sidebar = document.querySelector('.sidebar');
209+
if (!sidebar) return;
127210
128211
// Find the corresponding anchor link in the sidebar
129-
const sidebarAnchors = document.querySelectorAll('.sidebar a');
130-
sidebarAnchors.forEach((a) => {
131-
if (a.getAttribute('data-anchor') === currentHash) {
132-
// Remove the "active" class from all sidebar links and add it only to the current one
133-
sidebarAnchors.forEach((link) => link.classList.remove('active'));
134-
a.classList.add('active');
135-
136-
// Expand the parent collapsible sidebar item, if any
137-
const parentCollapsible = a.closest('.collapsible');
138-
if (parentCollapsible) {
139-
parentCollapsible.classList.remove('collapsed');
212+
const targetAnchor = sidebar.querySelector(`a[data-anchor="${page.value.path}${currentHash}"]`);
213+
214+
if (targetAnchor) {
215+
const sidebarAnchors = sidebar.querySelectorAll('a');
216+
217+
// Remove the "active" class from all sidebar links and add it only to the current one
218+
sidebarAnchors.forEach((link) => link.classList.remove('active'));
219+
targetAnchor.classList.add('active');
220+
221+
// Expand the parent collapsible sidebar item, if any
222+
const parentCollapsible = targetAnchor.closest('.collapsible');
223+
if (parentCollapsible) {
224+
const linkContainer = parentCollapsible.querySelector('.sidebar-link-container');
225+
if (linkContainer) {
226+
linkContainer.classList.remove('collapsed');
140227
}
141228
}
142-
});
229+
}
230+
231+
// Re-setup observer after hash change to ensure proper tracking
232+
setTimeout(() => {
233+
setupIntersectionObserver();
234+
}, 50);
143235
};
144236
145237
onMounted(() => {
146238
refreshIndex();
147-
!props.isMobileWidth ? window.addEventListener('scroll', checkIfScroll) : null;
148-
!props.isMobileWidth ? window.addEventListener('resize', checkIfScroll) : null;
239+
240+
if (!props.isMobileWidth) {
241+
// Setup Intersection Observer after DOM is fully rendered
242+
setTimeout(() => {
243+
setupIntersectionObserver()
244+
}, 100)
245+
246+
// Add throttled scroll listener as backup
247+
window.addEventListener('scroll', throttledUpdateSidebar)
248+
window.addEventListener('resize', throttledUpdateSidebar)
249+
}
149250
150251
// Listen to the "hashchange" event to handle direct anchor link access
151252
window.addEventListener('hashchange', handleHashChange);
152253
});
153254
154255
onUnmounted(() => {
155-
window.removeEventListener('scroll', checkIfScroll);
156-
window.removeEventListener('resize', checkIfScroll);
256+
// Disconnect Intersection Observer
257+
if (intersectionObserver) {
258+
intersectionObserver.disconnect()
259+
intersectionObserver = null
260+
}
261+
262+
// Clear any pending throttle timeout
263+
if (scrollThrottleTimeout) {
264+
clearTimeout(scrollThrottleTimeout)
265+
scrollThrottleTimeout = null
266+
}
267+
268+
// Remove event listeners
269+
window.removeEventListener('scroll', throttledUpdateSidebar);
270+
window.removeEventListener('resize', throttledUpdateSidebar);
157271
window.removeEventListener('hashchange', handleHashChange);
158272
});
159273

0 commit comments

Comments
 (0)