93 lines
3.5 KiB
JavaScript
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));
|
|
}
|