[NIVEL 3] Issue #55 - TOC ScrollSpy con IntersectionObserver

Implementación completa de TOC sticky con scrollspy avanzado según Issue #55.

**Cambios en toc.css:**
- TOC sticky: position: sticky, top: 5.5rem, z-index: 10
- Border-left en links: 3px solid transparent (activo: #0d6efd)
- Scrollbar personalizado: width 6px, color #cbd5e0, hover #a0aec0
- Ajustados padding/margin para border-left

**Cambios en toc.js:**
- Reemplazado scroll handler por IntersectionObserver
- rootMargin: '-20% 0px -35% 0px' para detección óptima
- Tracking de headings visibles con Set
- Active link basado en primer heading visible
- Renombrado updateActiveLink → updateActiveLinkOnClick (evitar conflicto)
- Mantiene smooth scroll y reduce-motion support

**Características:**
 TOC sticky funcional con top: 5.5rem
 ScrollSpy con IntersectionObserver (rootMargin personalizado)
 Border-left 3px solid en active links
 Scrollbar width 6px, color #cbd5e0
 Smooth scroll con offset dinámico
 Performance optimizado (sin scroll events)
 Compatible todos los browsers modernos

Closes #55

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-04 20:09:45 -06:00
parent 470ef0c14f
commit d5fe816add
2 changed files with 64 additions and 54 deletions

View File

@@ -19,7 +19,9 @@
padding: 1.5rem;
margin: 2rem 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
position: relative;
position: sticky;
top: 5.5rem;
z-index: 10;
}
.apus-toc-header {
@@ -170,11 +172,13 @@
.apus-toc-link {
color: #212529;
text-decoration: none;
display: inline-block;
transition: color 0.2s ease, transform 0.2s ease;
display: block;
transition: color 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
line-height: 1.5;
position: relative;
padding: 0.25rem 0;
padding: 0.25rem 0 0.25rem 1rem;
border-left: 3px solid transparent;
margin-left: -1rem;
}
.apus-toc-link:hover {
@@ -188,22 +192,11 @@
border-radius: 2px;
}
/* Active link highlighting */
/* Active link highlighting - Issue #55 */
.apus-toc-link.active {
color: #0d6efd;
font-weight: 600;
}
.apus-toc-link.active::after {
content: '';
position: absolute;
left: -1rem;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 100%;
background-color: #0d6efd;
border-radius: 2px;
border-left-color: #0d6efd;
}
/* ========================================
@@ -348,16 +341,16 @@ h3[id] {
}
.apus-toc-list::-webkit-scrollbar-thumb {
background: #888;
background: #cbd5e0;
border-radius: 3px;
}
.apus-toc-list::-webkit-scrollbar-thumb:hover {
background: #555;
background: #a0aec0;
}
/* Firefox */
.apus-toc-list {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
scrollbar-color: #cbd5e0 #f1f1f1;
}

View File

@@ -94,7 +94,7 @@
}
// Update active state
updateActiveLink(this);
updateActiveLinkOnClick(this);
// Focus the target heading for accessibility
targetElement.setAttribute('tabindex', '-1');
@@ -104,7 +104,8 @@
}
/**
* Initialize active link highlighting based on scroll position
* Initialize active link highlighting with IntersectionObserver
* Issue #55 - ScrollSpy implementation
*/
function initActiveHighlight() {
const tocLinks = document.querySelectorAll('.apus-toc-link');
@@ -114,51 +115,67 @@
return;
}
let ticking = false;
// Keep track of which headings are currently visible
const visibleHeadings = new Set();
// Debounced scroll handler
function onScroll() {
if (!ticking) {
window.requestAnimationFrame(function() {
updateActiveOnScroll(headings, tocLinks);
ticking = false;
});
ticking = true;
}
}
// IntersectionObserver configuration with custom rootMargin
// -20% top, -35% bottom for optimal detection
const observerOptions = {
root: null, // viewport
rootMargin: '-20% 0px -35% 0px',
threshold: 0
};
window.addEventListener('scroll', onScroll, { passive: true });
// Callback when heading visibility changes
const observerCallback = function(entries) {
entries.forEach(function(entry) {
const headingId = entry.target.id;
// Initial update
updateActiveOnScroll(headings, tocLinks);
if (entry.isIntersecting) {
visibleHeadings.add(headingId);
} else {
visibleHeadings.delete(headingId);
}
});
// Update active link based on visible headings
updateActiveLink(visibleHeadings, tocLinks, headings);
};
// Create observer
const observer = new IntersectionObserver(observerCallback, observerOptions);
// Observe all headings
headings.forEach(function(heading) {
observer.observe(heading);
});
}
/**
* Update active link based on scroll position
* Update active link based on visible headings
*
* @param {Array} headings Array of heading elements
* @param {Set} visibleHeadings Set of currently visible heading IDs
* @param {NodeList} tocLinks TOC link elements
* @param {Array} headings Array of all heading elements
*/
function updateActiveOnScroll(headings, tocLinks) {
const scrollPosition = window.scrollY + 100; // Offset for better UX
function updateActiveLink(visibleHeadings, tocLinks, headings) {
// If no headings are visible, don't change anything
if (visibleHeadings.size === 0) {
return;
}
// Find the current heading
let currentHeading = null;
for (let i = headings.length - 1; i >= 0; i--) {
if (headings[i].offsetTop <= scrollPosition) {
currentHeading = headings[i];
// Find the first visible heading (topmost in document order)
let activeHeading = null;
for (let i = 0; i < headings.length; i++) {
if (visibleHeadings.has(headings[i].id)) {
activeHeading = headings[i];
break;
}
}
// If we're at the top, use the first heading
if (!currentHeading && scrollPosition < headings[0].offsetTop) {
currentHeading = headings[0];
}
// Update active class
// Update active class on TOC links
tocLinks.forEach(function(link) {
if (currentHeading && link.getAttribute('href') === '#' + currentHeading.id) {
if (activeHeading && link.getAttribute('href') === '#' + activeHeading.id) {
link.classList.add('active');
} else {
link.classList.remove('active');
@@ -171,7 +188,7 @@
*
* @param {Element} clickedLink The clicked TOC link
*/
function updateActiveLink(clickedLink) {
function updateActiveLinkOnClick(clickedLink) {
const tocLinks = document.querySelectorAll('.apus-toc-link');
tocLinks.forEach(function(link) {
link.classList.remove('active');
@@ -198,7 +215,7 @@
behavior: 'smooth',
block: 'start'
});
updateActiveLink(tocLink);
updateActiveLinkOnClick(tocLink);
}, 100);
}
}