Files
2026-08-05 22:40:03 +02:00

93 lines
3.5 KiB
JavaScript

/**
* 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<HTMLElement>} options.sections - Sections observées.
* @param {NodeListOf<HTMLAnchorElement>} 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));
}