Site personnel courcellematthi.eu

This commit is contained in:
lrdlg
2026-08-05 22:40:03 +02:00
commit df12d21165
39 changed files with 4503 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* main.js — Point d'entrée de l'application
* =============================================================================
* Orchestre l'initialisation des modules. Chaque module est indépendant :
* si l'un échoue, les autres continuent de fonctionner.
*
* PRINCIPE DIRECTEUR — amélioration progressive
* ---------------------------------------------
* Le site est intégralement lisible, navigable et imprimable sans
* JavaScript. Tout ce que ce fichier ajoute relève du confort :
*
* - navigation.js : replie le menu sur petit écran
* - scroll-spy.js : surligne la section en cours de lecture
* - back-to-top.js : raccourci de remontée
* - copy-email.js : copie de l'adresse en un clic
*
* Aucun contenu n'est injecté par script : le HTML servi est complet.
* C'est ce qui garantit l'indexation par les moteurs de recherche et la
* lecture par les agents conversationnels, dont beaucoup n'exécutent pas
* JavaScript.
*
* CHARGEMENT
* ----------
* Ce fichier est appelé avec type="module", donc :
* - différé par défaut (équivalent à defer), il ne bloque pas le rendu
* - exécuté en mode strict
* - portée isolée, aucune variable globale
*/
import { initNavigation } from "./modules/navigation.js";
import { initScrollSpy } from "./modules/scroll-spy.js";
import { initBackToTop } from "./modules/back-to-top.js";
import { initCopyEmail } from "./modules/copy-email.js";
/**
* Démarre les modules de la page.
* @returns {void}
*/
function init() {
/* --- Signale que JS est actif : permet au CSS de n'appliquer certains
comportements (menu replié) que lorsqu'ils sont pilotables ------- */
document.documentElement.dataset.js = "true";
initNavigation({
toggle: document.querySelector("[data-nav-toggle]"),
nav: document.querySelector("[data-nav]"),
});
initScrollSpy({
sections: document.querySelectorAll("[data-section]"),
links: document.querySelectorAll("[data-nav-link]"),
});
initBackToTop({
button: document.querySelector("[data-to-top]"),
sentinel: document.querySelector("[data-scroll-sentinel]"),
});
initCopyEmail({
button: document.querySelector("[data-copy]"),
status: document.querySelector("[data-status]"),
});
}
/* -----------------------------------------------------------------------
Les modules sont différés, le DOM est donc normalement prêt. On teste
malgré tout readyState : cela rend le script robuste s'il est un jour
réutilisé dans un contexte de chargement différent.
----------------------------------------------------------------------- */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
+58
View File
@@ -0,0 +1,58 @@
/**
* back-to-top.js — Bouton de retour en haut de page
* =============================================================================
* Le bouton n'apparaît qu'une fois l'utilisateur descendu au-delà d'une
* hauteur d'écran : afficher un raccourci de retour alors qu'on est déjà
* en haut n'a aucune utilité et encombre l'interface.
*
* Détection par IntersectionObserver sur une sentinelle placée en haut du
* document, plutôt que par écouteur de scroll — même raison que pour le
* scroll-spy : pas de recalcul de mise en page à chaque pixel parcouru.
*
* Le bouton est un vrai <button> injecté par ce script : il n'a aucun sens
* sans JavaScript, il ne doit donc pas exister dans le HTML servi.
*/
/**
* Initialise le bouton de retour en haut.
*
* @param {object} options
* @param {HTMLElement} options.button - Bouton à piloter.
* @param {HTMLElement} options.sentinel - Élément repère en haut de page.
* @returns {void}
*/
export function initBackToTop({ button, sentinel }) {
if (!button || !sentinel) return;
/* --- Action : remonter en haut -------------------------------------- */
button.addEventListener("click", () => {
// Respecte la préférence système de réduction des animations :
// un défilement animé sur plusieurs milliers de pixels peut
// provoquer un malaise chez les personnes photosensibles.
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
window.scrollTo({
top: 0,
behavior: prefersReducedMotion ? "auto" : "smooth",
});
// Le focus doit suivre le déplacement visuel, sinon un utilisateur
// clavier se retrouve à naviguer depuis le bas de page.
document.getElementById("haut-de-page")?.focus({ preventScroll: true });
});
/* --- Visibilité conditionnelle -------------------------------------- */
if (!("IntersectionObserver" in window)) return;
const observer = new IntersectionObserver(
([entry]) => {
// La sentinelle est visible => on est en haut => bouton masqué.
button.dataset.visible = String(!entry.isIntersecting);
},
{ threshold: 0 }
);
observer.observe(sentinel);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* copy-email.js — Copie de l'adresse e-mail dans le presse-papiers
* =============================================================================
* Détail d'ergonomie qui compte sur un site de contact : tout le monde
* n'a pas de client mail configuré, et un lien mailto: qui ouvre un
* logiciel inattendu est une impasse. Le bouton de copie offre la voie
* alternative sans supprimer le lien mailto.
*
* Le retour à l'utilisateur passe par deux canaux :
* - visuel : le libellé du bouton change temporairement
* - vocal : une région aria-live annonce la copie aux lecteurs d'écran
*
* L'API Clipboard exige un contexte sécurisé (HTTPS ou localhost). En cas
* d'échec, on ne laisse pas l'utilisateur sans réponse : on lui indique
* de copier manuellement.
*/
/** Durée d'affichage du message de confirmation, en millisecondes. */
const FEEDBACK_DURATION = 2200;
/**
* Initialise le bouton de copie.
*
* @param {object} options
* @param {HTMLElement} options.button - Bouton portant data-copy="<valeur>".
* @param {HTMLElement} options.status - Région aria-live pour l'annonce.
* @returns {void}
*/
export function initCopyEmail({ button, status }) {
if (!button) return;
const value = button.dataset.copy;
if (!value) return;
const label = button.querySelector("[data-copy-label]");
const originalLabel = label?.textContent ?? "";
let resetTimer;
/**
* Affiche un retour temporaire, visuel et vocal.
* @param {string} message
* @returns {void}
*/
const announce = (message) => {
if (label) label.textContent = message;
if (status) status.textContent = message;
clearTimeout(resetTimer);
resetTimer = setTimeout(() => {
if (label) label.textContent = originalLabel;
if (status) status.textContent = "";
}, FEEDBACK_DURATION);
};
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(value);
announce("Adresse copiée");
} catch {
// Contexte non sécurisé, permission refusée ou API absente.
announce("Copie impossible");
}
});
}
+107
View File
@@ -0,0 +1,107 @@
/**
* 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();
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 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));
}