overview / js /utils /stickyNavigation.js
Yacine Jernite
sticky_nav_prettier
a08c68d
raw
history blame
5.2 kB
// stickyNavigation.js - Sticky navigation behavior and hamburger menu
// Handles: sticky positioning on scroll + collapse/expand toggle + active section tracking
/**
* Initializes sticky navigation behavior for a page
* @param {string} navElementId - ID of the navigation element (without #)
*/
export function initializeStickyNavigation(navElementId) {
const navElement = document.getElementById(navElementId);
if (!navElement) {
console.warn(`Navigation element #${navElementId} not found`);
return;
}
const navSection = navElement.closest('section.page-navigation');
if (!navSection) {
console.warn('Navigation section wrapper not found');
return;
}
// Calculate sticky threshold: when nav reaches bottom of header
const headerHeight = parseInt(getComputedStyle(document.documentElement)
.getPropertyValue('--header-height')) || 100;
const stickyThreshold = navSection.offsetTop - headerHeight;
// Scroll handler with throttling
let scrollTimeout;
const handleScroll = () => {
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
navSection.classList.toggle('sticky-nav', scrollPosition >= stickyThreshold);
};
window.addEventListener('scroll', () => {
if (scrollTimeout) cancelAnimationFrame(scrollTimeout);
scrollTimeout = requestAnimationFrame(handleScroll);
});
handleScroll(); // Initial check
// Initialize active section tracking
initializeActiveSectionTracking();
}
/**
* Track visible sections and update active navigation item
* Uses Intersection Observer for efficient scroll tracking
*/
function initializeActiveSectionTracking() {
// Get all navigation links
const navLinks = document.querySelectorAll('.page-navigation a[href^="/#"], .page-navigation a[href*="#"]');
if (navLinks.length === 0) return;
// Extract section IDs from navigation links
const sectionIds = Array.from(navLinks)
.map(link => {
const href = link.getAttribute('href');
const hashIndex = href.indexOf('#');
return hashIndex >= 0 ? href.substring(hashIndex + 1) : null;
})
.filter(id => id);
if (sectionIds.length === 0) return;
// Get all section elements
const sections = sectionIds
.map(id => document.getElementById(id))
.filter(section => section);
if (sections.length === 0) return;
// Track which section is most visible
const observerOptions = {
root: null,
rootMargin: '-20% 0px -60% 0px', // Trigger when section is 20% from top
threshold: [0, 0.1, 0.2, 0.5, 0.8, 1]
};
let activeSection = null;
const observer = new IntersectionObserver((entries) => {
// Find the most visible section
let mostVisible = null;
let maxRatio = 0;
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > maxRatio) {
maxRatio = entry.intersectionRatio;
mostVisible = entry.target;
}
});
// Update active section if changed
if (mostVisible && mostVisible !== activeSection) {
activeSection = mostVisible;
updateActiveNavItem(mostVisible.id);
}
}, observerOptions);
// Observe all sections
sections.forEach(section => observer.observe(section));
}
/**
* Update active navigation item based on current section
* @param {string} sectionId - ID of the currently visible section
*/
function updateActiveNavItem(sectionId) {
const navLinks = document.querySelectorAll('.page-navigation a');
navLinks.forEach(link => {
const href = link.getAttribute('href');
const linkSectionId = href.substring(href.indexOf('#') + 1);
if (linkSectionId === sectionId) {
// Add active classes
link.classList.add('text-blue-600', 'font-semibold');
link.classList.add('underline', 'decoration-2', 'underline-offset-2');
link.classList.remove('text-gray-700');
} else {
// Remove active classes
link.classList.remove('text-blue-600', 'font-semibold');
link.classList.remove('underline', 'decoration-2', 'underline-offset-2');
link.classList.add('text-gray-700');
}
});
}
/**
* Toggle navigation visibility (hamburger menu)
* Exposed globally for onclick handler
*/
window.toggleNavigation = function() {
const navItems = document.getElementById('nav-items');
const navToggle = document.getElementById('nav-toggle');
const navContainer = document.getElementById('nav-container');
if (!navItems || !navToggle) return;
const isCollapsed = navItems.classList.contains('nav-collapsed');
// Toggle classes and ARIA attribute
navItems.classList.toggle('nav-collapsed', !isCollapsed);
navToggle.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false');
navContainer?.classList.toggle('nav-container-collapsed', !isCollapsed);
};