Site personnel courcellematthi.eu
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user