/** * scroll-spy.js — Mise en évidence de la section courante * ============================================================================= * Marque le lien de navigation correspondant à la section actuellement lue. * * Choix technique : IntersectionObserver plutôt qu'un écouteur « scroll ». * Un écouteur de scroll se déclenche des dizaines de fois par seconde et * impose de recalculer getBoundingClientRect() à chaque appel, ce qui force * le navigateur à recalculer la mise en page (layout thrashing) et fait * chuter la fluidité. IntersectionObserver délègue ce travail au moteur de * rendu, hors du fil principal, et ne notifie qu'aux franchissements de seuil. * * Le lien actif reçoit aria-current="true" : ce n'est pas qu'un crochet de * style, c'est l'annonce de la position aux lecteurs d'écran (WCAG 2.4.8). */ /** * Initialise la surveillance des sections. * * @param {object} options * @param {NodeListOf} options.sections - Sections observées. * @param {NodeListOf} options.links - Liens de navigation. * @returns {void} */ export function initScrollSpy({ sections, links }) { if (!sections?.length || !links?.length) return; // Dégradation propre sur navigateur ancien : pas de surbrillance, // mais la navigation reste parfaitement utilisable. if (!("IntersectionObserver" in window)) return; /* --- Index des liens par identifiant de section ciblée --------------- */ const linksByTargetId = new Map(); links.forEach((link) => { const targetId = link.getAttribute("href")?.replace("#", ""); if (targetId) linksByTargetId.set(targetId, link); }); /** * Active un lien et désactive tous les autres. * @param {string} sectionId * @returns {void} */ const setActiveLink = (sectionId) => { links.forEach((link) => link.removeAttribute("aria-current")); linksByTargetId.get(sectionId)?.setAttribute("aria-current", "true"); }; /** Identifiants des sections actuellement dans la zone de détection. */ const visibleSectionIds = new Set(); /** Sections dans l'ordre du document, pour départager les ex æquo. */ const orderedSections = [...sections]; const observer = new IntersectionObserver( (entries) => { entries.forEach(({ target, isIntersecting }) => { if (isIntersecting) visibleSectionIds.add(target.id); else visibleSectionIds.delete(target.id); }); // Aucune section dans la bande : on conserve le dernier état actif // plutôt que de tout éteindre, ce qui produirait un clignotement. if (visibleSectionIds.size === 0) return; // Plusieurs sections visibles : on retient la première dans l'ordre // du document, c'est-à-dire la plus haute à l'écran. const current = orderedSections.find((section) => visibleSectionIds.has(section.id) ); if (current) setActiveLink(current.id); }, { /* * Bande de détection étroite, positionnée dans le tiers supérieur de * la fenêtre, juste sous l'en-tête collant. * * -20% en haut : ignore ce qui passe derrière l'en-tête * -70% en bas : ignore ce qui n'est encore qu'en bas d'écran * * Résultat : la section considérée « courante » est celle que * l'utilisateur lit réellement, pas celle qui pointe le nez. */ rootMargin: "-20% 0px -70% 0px", threshold: 0, } ); sections.forEach((section) => observer.observe(section)); }