Files
courcellematthi/assets/js/modules/navigation.js
T
2026-08-05 22:40:03 +02:00

108 lines
3.8 KiB
JavaScript

/**
* navigation.js — Menu de navigation mobile
* =============================================================================
* Gère l'ouverture et la fermeture du panneau de navigation sous le point de
* rupture mobile.
*
* Principe d'amélioration progressive : sans JavaScript, le menu reste une
* liste de liens visible et fonctionnelle (le CSS ne la masque que si le
* bouton, lui-même conditionné à ce script, est présent et actif).
*
* Accessibilité couverte :
* - aria-expanded reflète l'état réel du panneau
* - aria-controls relie le bouton au panneau qu'il commande
* - la touche Échap ferme le menu et rend le focus au bouton
* - le focus est piégé dans le panneau tant qu'il est ouvert
* - le défilement de la page est bloqué pendant l'ouverture
*/
/** Point de rupture, aligné sur celui de layout.css. */
const MOBILE_QUERY = "(width <= 46rem)";
/** Sélecteur des éléments pouvant recevoir le focus clavier. */
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Initialise le menu mobile.
*
* @param {object} options
* @param {HTMLElement} options.toggle - Bouton d'ouverture/fermeture.
* @param {HTMLElement} options.nav - Conteneur <nav> à afficher/masquer.
* @returns {void}
*/
export function initNavigation({ toggle, nav }) {
// Garde défensive : si le balisage change, le script ne casse pas la page.
if (!toggle || !nav) return;
const mediaQuery = window.matchMedia(MOBILE_QUERY);
/**
* Applique l'état d'ouverture au DOM.
* @param {boolean} isOpen
* @returns {void}
*/
const setOpen = (isOpen) => {
nav.dataset.open = String(isOpen);
toggle.setAttribute("aria-expanded", String(isOpen));
// Empêche le défilement de l'arrière-plan pendant que le panneau
// recouvre l'écran — évite l'effet « scroll fantôme » sur mobile.
document.body.style.overflow = isOpen && mediaQuery.matches ? "hidden" : "";
};
const isOpen = () => toggle.getAttribute("aria-expanded") === "true";
const close = () => setOpen(false);
/* --- Bascule au clic sur le bouton ---------------------------------- */
toggle.addEventListener("click", () => setOpen(!isOpen()));
/* --- Fermeture après navigation vers une ancre ---------------------- */
nav.addEventListener("click", (event) => {
if (event.target.closest("a")) close();
});
/* --- Clavier : Échap ferme, Tab reste piégé dans le panneau --------- */
document.addEventListener("keydown", (event) => {
if (!isOpen()) return;
if (event.key === "Escape") {
close();
toggle.focus(); // Le focus ne doit jamais être perdu dans le vide
return;
}
if (event.key !== "Tab") return;
// Piège de focus : la tabulation boucle entre le bouton et le dernier
// lien du menu, au lieu de partir dans le contenu masqué derrière.
const focusables = [toggle, ...nav.querySelectorAll(FOCUSABLE)];
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
/* --- Fermeture au clic hors du panneau ------------------------------ */
document.addEventListener("click", (event) => {
if (!isOpen()) return;
if (nav.contains(event.target) || toggle.contains(event.target)) return;
close();
});
/* --- Retour en affichage large : on réinitialise --------------------- */
mediaQuery.addEventListener("change", (event) => {
if (!event.matches) close();
});
// État initial explicite plutôt qu'implicite dans le HTML.
close();
}