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

View File

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