Ou comment ne pas utiliser un widget approximatif ?
On trouve facilement des articles qui dénoncent les overlays d'accessibilité, et pour cause : les arguments ne manquent pas. L'Overlay Fact Sheet, signé par des centaines d'experts en accessibilité, documente méthodiquement leurs limites. Les procès s'accumulent, les témoignages d'utilisateurs en situation de handicap sont sans appel.
Mais entre « les overlays c'est nul » et « faites du bon code dès le départ », il manque souvent le comment. Parce que oui, concevoir accessible dès l'origine est l'idéal. Mais dans la vraie vie, on hérite de projets existants, on a des contraintes de temps et de budget, et parfois on cherche juste une solution concrète.
Cet article propose une alternative maison : un menu d'options d'affichage basé sur des standards web, sans dépendance externe. Un peu comme une tarte Tatin faite à la maison plutôt qu'une version industrielle bourrée d'additifs dont on ne connaît ni la composition ni les effets secondaires. Vous savez exactement ce qu'il y a dedans, vous contrôlez les ingrédients, et vous pouvez l'adapter à vos besoins.
Pas une solution miracle qui rendra votre site magiquement conforme, mais un outil supplémentaire pour améliorer l'expérience de vos utilisateurs.
Les widgets d'accessibilité sous le feu !
Aux États-Unis en 2024, 25 % des plaintes en matière d'accessibilité (1 023 cas) visent des sites utilisant des overlays ou widgets d'accessibilité.
Le widget AccessiBe a généré à lui seul 258 plaintes contre ses clients, sans compter l'amende FTC de 1 M$ infligée à la société éditrice.
Les tribunaux rejettent de plus en plus l'argument de « conformité par widget ». Installer un truc pareil sur son site, c'est un peu comme s'arrêter devant la gendarmerie en klaxonnant, avec 2 grammes, les pneus lisses et pas de permis.
Le prix ?
Payer pour un widget, c'est de la location (OPEX). Payer ses développeurs pour faire du code propre, c'est de l'investissement qualité (CAPEX) qui améliore aussi le SEO et la maintenabilité.
Pourquoi cette approche ?
Indépendance : aucune dépendance externe, code vanilla JS qui ne dépend d'aucun framework éphémère.
Personnalisation : totalement adaptable à n'importe quelle charte graphique sans contraintes imposées.
Conformité : respect des standards WCAG 2.2 niveau AA avec une vraie accessibilité, pas du marketing.
Maintenabilité : code structuré et documenté. Votre futur vous dira merci.
Performance : léger et optimisé. Les scripts ont subi des tests de temps d'exécution (performance.mark()) qui sont en-dessous de 2 millisecondes. Pour comparaison, certains widgets commerciaux pèsent 50 fois plus lourd et vont exploser les performances globales.
Pérenne : utilise des standards web natifs, pas un framework à la mode qui sera obsolète dans 6 mois.
Implémentation complète d'un menu d'options d'affichage accessible, basée sur des standards web, avec un peu de bon sens et de travail. C'est parti !
Architecture générale
C'est plutôt simple. Un bouton (CTA) et un menu. Côté design, c'est à vous de voir, tant que ça reste visible et facilement atteignable.
Puisqu'on a décidé d'avoir un gestionnaire d'affichage, on ne va pas utiliser du JavaScript pour insérer des éléments, on va les placer directement dans le document à l'endroit souhaité.
La couche JavaScript ne sert qu'à actionner les options.
Le bouton déclencheur
Le menu est activé par un bouton positionné en haut de page. Ce placement n'est pas anodin. C'est un peu comme les liens d'évitement, c'est mieux si ce n'est pas juste avant </body>.
Le sujet n'étant le design mais la fonctionnalité, à vous d'adapter à la charte voulue.
Points notables :
aria-haspopup="dialog": prévient poliment les lecteurs d'écran qu'une popup va s'ouvrir (et non, ce n'est pas une pub pour des pilules miracles) ;- le SVG
activeOptionsMarkeraffiche un petit badge quand des options sont actives (histoire de ne pas oublier qu'on a mis le site en rose fluo) ; - le label ARIA du marqueur est géré dynamiquement pour annoncer le nombre d'options actives.
Le dialog modal
Le menu utilise l'élément natif <dialog> HTML. Oui, ça existe depuis 2022 et non, ce n'est pas réservé aux hipsters du web.
Avantages de <dialog> :
- gestion native du focus ;
- support du backdrop sans bricolage CSS à la main ;
- fermeture automatique avec Échap (comme dans un jeu vidéo des années 90) ;
- sémantique correcte pour les lecteurs d'écran.
Gestion des options
Structure des données
Les options sont définies dans un objet global qui mappe des identifiants à des noms de classes CSS.
Ici, il y en a beaucoup. Il s'agit de montrer ce qu'il est possible de faire, et ce n'est pas exhaustif.
Le minimum étant la gestion d'une version contrastée de l'interface.
window.accessibilityDisplayOptions = {
1: 'line-spacing',
2: 'initial-font',
3: 'dyslexic',
4: 'dyslexic-comic',
5: 'contrast',
6: 'simca-persistent-buttons',
7: 'grayscale',
8: 'fixed-navigation',
9: 'letter-spacing',
10: 'disable-blank',
11: 'font-small',
12: 'font-large',
13: 'no-motion',
14: 'brightness'
};
Cette approche centralisée facilite la maintenance. Besoin d'une nouvelle option ? On l'ajoute à la liste, on l'ajoute dans le menu et point barre.
Convention de nommage
Chaque option suit une convention précise (parce qu'un peu de rigueur n'a jamais tué personne).
- Input ID :
{nom-option}-option(ex:contrast-option) ; - Value : correspond au nom de la classe CSS ;
- Classe CSS appliquée : ajoutée directement sur
:root(l'élémenthtml).
<input type="checkbox" id="contrast-option" value="contrast">
<label for="contrast-option">Renforcer le contraste</label>
La base HTML du menu
Au final, on obtient la structure de base du menu
<div id="menuDisplayOptionsContainer">
<button type="button" id="openMenuDisplayOptions" aria-haspopup="dialog">
<svg aria-hidden="true" width="32" height="32"><use href="#icon-vitruve"></use></svg>
<span>Options d'affichage</span>
<svg id="activeOptionsMarker" height="32" width="32" role="img"><use href="#icon-check"></use></svg>
</button>
<dialog id="menuDisplayOptions" data-nosnippet aria-details="menuDisplayOptionsTitle">
<p>
<span id="menuDisplayOptionsTitle">
Sélectionnez les options<span class="screen-readers">. Actuellement : </span> <span id="countOptions"></span>
</span>
<button id="closeMenuDisplayOptions" type="button">
<span class="screen-readers">Fermer le menu d'options (Touche Échappement)</span>
</button>
</p>
<div>
<!-- Désactivation des raccourcis https://www.w3.org/WAI/WCAG22/quickref/?versions=2.2#character-key-shortcuts -->
<input type="checkbox" id="toggleEnableShortcuts" class="ignore-status">
<label for="toggleEnableShortcuts" class="switch-marker"><!-- laisser vide, géré par fonction --></label>
</div>
<div>
<!-- Gestion du contraste -->
<input type="checkbox" id="contrast-option" value="contrast" data-autocommute aria-keyshortcuts="c">
<label for="contrast-option"><span class="screen-readers">Touche clavier c</span><kbd title="Touche clavier c" aria-hidden="true" class="symbol"> c </kbd> Renforcer le contraste</label>
</div>
<!--
… la suite des options
-->
</dialog>
</div>
Persistance des données
Les utilisateurs reviennent sur un site. localStorage est votre ami.
Le système utilise un objet JSON unique avec gestion de l'expiration :
{
"options": {
"contrast": true,
"grayscale": true
},
"lastUsed": 1737644523847
}
Logique d'expiration (ou comment faire le ménage automatiquement) :
- pas d'options actives → pas de
localStorage(on n'encombre pas pour rien) ; - au moins une option → création de l'objet avec timestamp ;
- chaque lecture → mise à jour automatique du timestamp (l'utilisateur revient, on le note) ;
- plus de 10 jours d'inactivité → purge complète (au revoir les réglages abandonnés) ;
- dernière option désactivée → suppression de l'objet.
Interaction avec les CSS
Variables CSS comme fondation
Le système repose sur des variables CSS. Si vous n'utilisez pas encore les variables CSS en 2026, on ne peut plus rien pour vous.
:root {
--primary-color: rgb(0,0,0);
--heading-color: rgb(17,17,17);
--link-color: rgb(255,129,0);
--box-shadow-color: rgb(204,204,204);
}
Modes d'affichage
Mode sombre automatique :
@media (prefers-color-scheme: dark) {
:root {
--primary-color: rgb(255,255,255);
--link-color: rgb(113,174,255);
}
}
Et voilà, votre site s'adapte automatiquement au mode sombre. Aucun bouton « dark mode » à tripoter, tout se fait tout seul comme un grand.
Partant du principe qu'un utilisateur ayant configuré son terminal en mode sombre sera ravi que l'interface s'applique aussi sur vos contenus.
Application des options
Le principe est ultra simple : chaque option pose une classe sur html correspondant à sa value. Ensuite, il suffit de capturer dans les CSS cette valeur et de modifier les variables.
Exemple pour le mode contrasté :
:root[class*="contrast"] {
--primary-color: rgb(0,0,0);
--link-color: blue;
}
Le sélecteur [class*="contrast"] est un petit malin : il cible à la fois contrast et prefers-contrast qui peut être capturé en JavaScript et appliqué automatiquement. Deux pour le prix d'un !
Vous pouvez essayer en direct… on en parle juste après.
Exemple avec la CSS actuelle
Les variables de base.
Taille brute : 7,16 kilo-octets (~2,27 kilo-octets gzip)
:root {
/* Requis : light-dark() et les couleurs système se résolvent selon ce schéma */
color-scheme: dark light;
font-size: max(100%, 13px);
--font-stretch: 100%;
--initial-font: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, sans-serif;
--size-body: 75rem;
&.full-viewport {
--size-body: min(100%, 160rem);
}
--size-body-largest: calc(75rem* 2);
@media (max-width: 47.9375rem) {
--font-size: 0.875rem;
}
@media (min-width: 48rem) and (max-width: 119.9375rem) {
--font-size: clamp(1.0625rem, calc(1.0313rem + 0.1dvw), 1.1875rem);
}
@media (min-width: 120rem) {
--font-size: 1.25rem;
--size-body: 90rem;
}
&:not(.contrast) {
--font-weight: 270;
@media (prefers-color-scheme: light) {
--font-weight: 320;
}
}
&.contrast {
--font-weight: 400;
}
@media (max-width: 47.9375rem) {
--gutter: 2.5rem;
}
@media (min-width: 48rem) {
--gutter: 3rem;
}
/* Couleurs système, suivent color-scheme */
/* --canvas et --canvasText sont définis plus bas */
--buttonFace: ButtonFace;
--buttonText: ButtonText;
--field: Field;
--fieldText: FieldText;
--highlight: Highlight;
--highlightText: HighlightText;
--selectedItem: SelectedItem;
--selectedItemText: SelectedItemText;
--mark: Mark;
--markText: MarkText;
--accentColor: AccentColor;
--accentColorText: AccentColorText;
/* Sans paire */
--buttonBorder: ButtonBorder;
--grayText: GrayText;
--linkText: LinkText;
--visitedText: VisitedText;
--activeText: ActiveText;
/* Fin couleurs système */
--cols: 12;
--radius: var(--small-gutter, 0.625rem);
--font-weight-headers: unset;
--lh: 1.618; /* φ phi */
--font-family: var(--base-font);
/* Palette fixe (identique clair et sombre) */
--darkblue: rgb(0,36,94);
--lightblue: rgb(184,207,241);
--lightgreen: rgb(212,249,201);
--orange: rgb(193,71,5);
--orangered: rgb(255,69,0);
--raspberry: rgb(205,0,55);
--red: rgb(213,43,30);
--yellow: rgb(255,182,18);
--maroon: rgb(154,90,12);
--antigua: rgb(130,196,206);
--lightgray: rgb(211,211,211);
--whitesmoke: rgb(252,252,252);
--teal: rgb(0,122,124);
/* Palette adaptative : light-dark(clair, sombre) */
--blue: light-dark(#2F6EBE, rgb(138,180,249));
--applegreen: light-dark(#5A820C, rgb(130,190,0));
--green: light-dark(#237A37, yellowgreen);
--limegreen: light-dark(#D2E100, rgb(255,253,0));
--carbon: light-dark(#6F7174, rgb(137,139,143));
--basic-color: light-dark(black, white);
--basic-color-reverse: light-dark(white, black);
--basic-color-alpha: light-dark( rgba(0, 0, 0, 0.8), rgba(255, 255, 255, 0.8));
--basic-color-reverse-alpha: light-dark( rgba(255, 255, 255, 0.8), rgba(0, 0, 0, 0.6));
--text-decoration-style: solid;
--text-decoration-color: currentColor;
--text-decoration-thickness: 0.0625em;
--small-radius: calc(var(--radius) / 2);
--font-family-monospace: Consolas, Courier, monospace;
--sticky-top: var(--header-outer-height);
--negative-gutter: calc(var(--gutter) / 2 * -1);
--half-gutter: calc(var(--gutter) / 2);
--small-gutter: calc(var(--gutter) / 4);
--tiny-gutter: calc(var(--gutter) / 8);
--double-gutter: calc(var(--gutter) * 2);
--negative-half-gutter: calc(var(--negative-gutter) / 2);
--negative-small-gutter: calc(var(--negative-gutter) / 4);
/* Surfaces et textes */
--canvas: light-dark(#f3f4f7, rgb(20,20,30)); /* sombre : rgb(16,16,23) en alternative */
--canvasText: light-dark(#56626F, rgb(197,213,231));
--background-color-secondary: light-dark(white, rgb(26,26,41)); /* sombre : rgb(26,26,33) en alternative */
--background-color-hero: light-dark( rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.6));
--background-color-menus: light-dark(#fff, rgb(37,37,37));
--background-error-color: rgb(246,197,237);
--background-forms-elements: light-dark(#fcfcfc, rgb(44,46,54));
--heading-color: light-dark(#111, white);
/* Ombres */
--box-shadow: light-dark(#DBDBDB, rgba(42, 34, 90, 0.81)); /* sombre : #34316Fa0 en alternative */
--box-shadow-light: light-dark(#DBDBDB, rgb(49,54,65));
--box-shadow-forms-elements: light-dark(var(--box-shadow), rgba(255, 255, 255, 0.3));
--under-box-shadow-color: light-dark( rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.9));
--under-box-shadow: 0 0.4375rem 0.3125rem -0.375rem var(--under-box-shadow-color);
/* Liens et états */
--link-color: light-dark(#0050b3, rgb(102,170,255)); /* alternatives : rgb(4,76,159) rgb(166,48,220) / rgb(205,189,248) */
--link-color-alternate: light-dark(#0032FF, rgb(102,133,255)); /* sombre : rgb(149,113,255) en alternative */
--button-radius: 0.4375rem;
--error-color: light-dark(var(--raspberry), rgb(255,169,174));
--message-dialog-error: var(--basic-color-reverse);
--highlight-sentence: light-dark(#6336de, var(--antigua));
/* Code */
--code-area-background: light-dark(rgba(99, 54, 222, 0.05), rgb(21,13,31));
--code-area-foreground: var(--canvasText);
--code-area-border: rgb(104,68,148);
&.admin {
--size-body: var(--size-body-largest);
}
@media (any-hover: none) {
&:not([class*="dys"]) {
--base-font: var(--initial-font);
--font-family-headers: var(--initial-font);
}
}
@media (any-hover: hover) {
&:not([class*="dys"]) {
--base-font: "Roboto Flex", var(--initial-font);
--font-family-headers: "Roboto Flex", var(--initial-font);
}
}
&.dyslexic {
--base-font: 'Accessible DfA', var(--initial-font);
--font-family-headers: 'Accessible DfA', var(--initial-font);
.no-dyslexic {
font-family: var(--initial-font);
}
}
&.dyslexic-comic {
--base-font: 'Comic Sans MS', var(--initial-font);
--font-family-headers: 'Comic Sans MS', var(--initial-font);
}
&.initial-font {
--initial-font: "";
--base-font: var(--initial-font);
--font-family-headers: var(--initial-font);
}
&[class*="dys"] .no-dyslexic {
font-family: var(--initial-font);
}
/* Focus */
--outline-offset: 0.1875rem;
--outline-width: 0.125rem;
--outline-style: solid;
--outline-color: light-dark(var(--orangered), springgreen);
&.use-keyboard {
--outline: var(--outline-width) var(--outline-style) var(--outline-color);
}
&:not(.use-keyboard) {
--outline: none;
}
/* Seules les valeurs non colorimétriques restent ici : light-dark() n'accepte que des couleurs */
@media (prefers-color-scheme: dark) {
--under-box-shadow: 0 0.4375rem 0.1875rem -0.3125rem var(--under-box-shadow-color);
}
@media (max-width: 47.9375rem) {
--size-icon-header: calc(var(--header-outer-height) * .35);
@media (max-height: 47.9375rem) {
--header-outer-height: 2.8125rem;
}
@media (min-height: 48rem) {
--header-outer-height: 3.125rem;
}
}
@media (min-width: 48rem) and (max-width: 89.9375rem) {
--header-outer-height: 3.4375rem;
--size-icon-header: calc(var(--header-outer-height) * .3);
}
@media (min-width: 90rem) {
--size-icon-header: calc(var(--header-outer-height) * .35);
@media (max-height: 47.9375rem) {
--header-outer-height: 4.375rem;
}
@media (min-height: 48rem) {
--header-outer-height: 5rem;
}
}
}
.initial-font {
font-family: var(--initial-font);
}
.font-family-code {
font-family: var(--font-family-monospace) !important;
}
Les variables mode contrasté.
Taille brute : 2,61 kilo-octets (~848,00 octets gzip)
:root {
background-color: var(--canvas);
margin: auto;
overflow-x: hidden;
max-width: 100vw;
width: 100vw;
scroll-padding-top: calc(var(--header-outer-height) * 2);
--filter-grayscale: grayscale(0);
--filter-brightness: brightness(1);
@media (prefers-reduced-motion: no-preference) {
&:not(.no-motion) {
scroll-behavior: smooth;
}
&.no-motion {
scroll-behavior: auto;
}
}
@media (prefers-reduced-motion: reduce) {
scroll-behavior: auto;
}
&.contrast {
--outline-width: 0.25rem;
font-weight: initial;
--text-decoration-style: solid;
--text-decoration-thickness: 0.125em;
--text-decoration-color: currentColor;
/* Couleurs communes aux deux schémas */
--box-shadow: GrayText;
--box-shadow-light: var(--box-shadow);
--canvasText: CanvasText;
--canvas: Canvas;
--background-color-secondary: Canvas;
--background-color-menus: Canvas;
--basic-color-alpha: var(--basic-color);
--basic-color-reverse-alpha: var(--basic-color-reverse);
/* Couleurs différentes selon le schéma : light-dark(clair, sombre) */
--link-color: light-dark(#0032ff, var(--linkText));
--link-color-alternate: light-dark(#004ede, yellow);
--background-color-hero: light-dark( rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.2));
--background-forms-elements: light-dark(white, rgba(0, 0, 0, 0.95));
--box-shadow-forms-elements: light-dark(CanvasText, var(--box-shadow));
--under-box-shadow-color: light-dark( rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 1));
/* Dimensions : light-dark() n'accepte que des couleurs */
@media (prefers-color-scheme: dark) {
--under-box-shadow: 0 0.4375rem 0.3125rem -0.3125rem var(--under-box-shadow-color);
}
}
/*
&.prefers-contrast {
--canvasText: rgb(16,16,16);
--heading-color: black;
}
*/
@media (prefers-reduced-transparency: reduce) {
--basic-color-alpha: var(--basic-color);
--basic-color-reverse-alpha: var(--basic-color-reverse);
}
&.grayscale {
--filter-grayscale: grayscale(1);
}
@media (prefers-color-scheme: light) {
&.brightness {
--filter-brightness: brightness(.75);
}
}
@media (prefers-color-scheme: dark) {
&.brightness {
--filter-brightness: brightness(.8);
}
}
&.grayscale,
&.brightness {
-webkit-filter: var(--filter-grayscale) var(--filter-brightness);
filter: var(--filter-grayscale) var(--filter-brightness);
&:not([data-ua-name="firefox"]) dialog {
-webkit-filter: var(--filter-grayscale) var(--filter-brightness);
filter: var(--filter-grayscale) var(--filter-brightness);
}
}
}
Télécharger kortic-contrast-mode.css
Fonctionnalités avancées
Détection des préférences système
Parce que vos utilisateurs ont peut-être déjà configuré leur système d'exploitation (et oui, certains le font), le script détecte les préférences au chargement (intégré à la fonction).
const mediaContrast = window.matchMedia("(prefers-contrast:more)");
if (mediaContrast.matches) {
document.documentElement.classList.add('prefers-contrast');
const contrastSwitch = document.querySelector('#contrast-option');
if (contrastSwitch) {
contrastSwitch.setAttribute('checked', '');
contrastSwitch.setAttribute('disabled', '');
}
}
Points clés :
- les options déjà configurées au niveau système sont cochées et désactivées (pas besoin de faire doublon) ;
- la page se recharge automatiquement lors du changement de préférences système ;
- évite les conflits entre préférences utilisateur et système.
Options mutuellement exclusives
Certaines options ne peuvent pas cohabiter (genre porter des chaussettes avec des sandales). Le système gère ça via l'attribut name :
if (setter.name) {
widget.querySelectorAll(`[name="${setter.name}"]:checked`).forEach(input => {
if (input !== setter) {
input.click(); // Dégage, l'autre !
}
});
}
Exemple concret : impossible d'avoir la police dyslexie ET Comic Sans MS en même temps. Il faut choisir son camp.
Pourquoi des cases à cocher et pas des input type="radio" ? Simplement parce que décocher un radio, c'est un peu compliqué.
Callbacks personnalisés
Chaque option peut déclencher une fonction JavaScript spécifique. Pratique pour des actions complexes :
<input type="checkbox" id="grayscale-option" data-callback="onGrayscaleToggle">
window.onGrayscaleToggle = function() {
console.log('Tout est gris maintenant. Comme un lundi matin.');
};
Compteur d'options actives
Le système maintient un compteur en temps réel. Parce qu'annoncer « 12 options activées » est plus parlant que « quelques trucs de cochés ».
Inspecter le DOM de l'élément sur cette page sera plus parlant.
Notez le .ignore-status : certaines options ne doivent pas être comptées (genre les réglages techniques).
Accessibilité au clavier
Trap focus
Pour garantir une navigation au clavier, un gestionnaire de « trap focus » confine le focus dans le dialog. Bon, normalement, c'est une chose que vous avez déjà, mais c'est cadeau.
Taille brute : 2,49 kilo-octets (~1,05 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2026/01/25/menu-d-accessibilite-gratuit-ou-presque.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
(() => {
/**
* Namespace global pour les utilitaires
* @namespace Utils
*/
window.Utils = window.Utils || {};
/**
* Sélecteur CSS pour tous les éléments pouvant recevoir le focus
* Inclut : liens, zones cliquables, résumés, contrôles de formulaire,
* éléments avec role="button", tabindex="0", et contenus éditables
* @type {string}
* @memberof Utils
*/
Utils.FOCUSABLE_SELECTORS = Utils.FOCUSABLE_SELECTORS ||
'a[href], area[href], summary, select, textarea, button, input:not([type="hidden"]):not([disabled])' +
'[role="button"], [tabindex="0"], [contenteditable]';
/**
* Confine le focus clavier à l'intérieur d'un élément (trap focus)
* Utile pour les modales, menus et dialogs conformes WCAG
*
* Comportement :
* - Tab depuis le dernier élément → retour au premier
* - Shift+Tab depuis le premier élément → retour au dernier
* - Si un seul élément focusable → le focus reste dessus
*
* Note WCAG : cette fonction confine le focus, mais l'élément parent DOIT
* fournir un moyen de fermeture au clavier (Échap, bouton, etc.) pour
* respecter le critère 2.1.2 niveau A (pas de piège au clavier)
*
* @param {HTMLElement} element - Élément conteneur dans lequel confiner le focus
* @returns {void}
* @global
*
* @example
* const dialog = document.querySelector('#myDialog');
* trapFocus(dialog);
*/
window.trapFocus = function (element) {
const focusableElements = Array.from(element.querySelectorAll(Utils.FOCUSABLE_SELECTORS));
// Sortie anticipée si aucun élément focusable
if (!focusableElements.length) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
element.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
// Cas particulier : un seul élément focusable
if (firstElement === lastElement) {
e.preventDefault();
firstElement.focus();
// Shift+Tab sur le premier élément → boucle vers le dernier
} else if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
// Tab sur le dernier élément → boucle vers le premier
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
});
};
})();
Télécharger kortic-trap-focus.js
Raccourcis clavier
Des raccourcis globaux permettent d'activer rapidement les options, conformément aux exigences WCAG 2.2 - Critère 2.1.4 (Raccourcis clavier utilisant des caractères) de niveau A
- Touche clavier o o : ouvrir le menu d'options ;
- Touche clavier c c : contraste renforcé ;
- Touche clavier g g : niveaux de gris ;
- Touche clavier d d : police dyslexique ;
- Touche clavier i i : interlignage ;
- Touche clavier r r : réduire les animations.
Pour ça, il faut un petit gestionnaire de raccourcis.
Taille brute : 5,66 kilo-octets (~2,10 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2026/01/25/menu-d-accessibilite-gratuit-ou-presque.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
(() => {
'use strict';
/**
* Système de gestion des raccourcis clavier accessibles (WCAG 2.2.1 niveau A)
* Implémente les exigences WCAG 2.2 - Character Key Shortcuts
* @see https://www.w3.org/WAI/WCAG22/quickref/?versions=2.2#character-key-shortcuts
*
* Fonctionnement :
* - Les raccourcis sont définis via l'attribut aria-keyshortcuts sur les éléments
* - L'utilisateur peut désactiver tous les raccourcis via une checkbox
* - Les raccourcis sont ignorés dans les champs de saisie
* - Seuls les raccourcis sans modificateur (Ctrl, Alt, etc.) sont gérés
*
* Usage :
* <button aria-keyshortcuts="h" data-autocommute>Accueil</button>
* <input type="checkbox" id="toggleEnableShortcuts">
*/
/**
* Clé utilisée pour stocker l'état des raccourcis clavier dans le localStorage
* @type {string}
* @const
*/
const SHORTCUTS_KEY = 'enableShortcuts';
/**
* Tags HTML à ignorer pour l'activation des raccourcis (zones de saisie)
* Empêche les conflits avec la saisie de texte
* @type {string[]}
* @const
*/
const ignoreTags = ['TEXTAREA', 'SELECT'];
/**
* Vérifie si un élément INPUT est éditable (champ de saisie de texte)
* Nécessaire car INPUT peut aussi être checkbox, radio, submit, etc.
* @param {HTMLElement} element - Élément à vérifier
* @returns {boolean} True si l'input accepte la saisie de texte
*/
function isEditableInput(element) {
return element.tagName === 'INPUT' && ['text', 'email', 'tel', 'search', 'url', 'password', 'number', 'date', 'datetime-local', 'month', 'time', 'week'].includes(element.type);
}
/**
* Vérifie si l'élément est visible à l'écran
* Évite de déclencher des raccourcis sur des éléments masqués
* @param {HTMLElement} el - Élément à vérifier
* @returns {boolean} True si l'élément est visuellement visible
*/
function isSelfVisible(el) {
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
}
/**
* Lit l'état des raccourcis clavier depuis le localStorage
* Par défaut, les raccourcis sont activés si aucune valeur n'existe
* @returns {boolean} True si les raccourcis sont activés
*/
function areShortcutsEnabled() {
const value = localStorage.getItem(SHORTCUTS_KEY);
return value === null || value === 'true';
}
/**
* Met à jour l'état des raccourcis clavier dans le localStorage
* @param {boolean} enabled - État souhaité (true = activé, false = désactivé)
*/
function setShortcutsEnabled(enabled) {
localStorage.setItem(SHORTCUTS_KEY, String(enabled));
}
/**
* Initialisation du contrôle utilisateur pour activer/désactiver les raccourcis
* Conforme WCAG 2.2.1 niveau A : permet à l'utilisateur de désactiver les raccourcis
*/
const toggleCheckbox = document.querySelector('#toggleEnableShortcuts');
const toggleLabel = document.querySelector('label[for="toggleEnableShortcuts"]');
/**
* Met à jour le texte du label selon l'état de la checkbox
* Fournit un retour visuel clair à l'utilisateur
*/
function updateToggleLabel() {
if (toggleLabel) {
toggleLabel.textContent = toggleCheckbox.checked
? i18n['aria-shortcuts-enabled'] || 'Shortcuts enabled'
: i18n['aria-enable-shortcuts'] || 'Enable shortcuts';
}
}
if (toggleCheckbox) {
// Initialisation de l'état de la checkbox au chargement (lecture du localStorage)
toggleCheckbox.checked = areShortcutsEnabled();
updateToggleLabel();
// Écoute des changements pour mettre à jour l'état et le label
toggleCheckbox.addEventListener('input', () => {
setShortcutsEnabled(toggleCheckbox.checked);
updateToggleLabel();
});
}
/**
* Gestionnaire principal des raccourcis clavier
*
* Comportement :
* 1. Vérifie si les raccourcis sont activés
* 2. Ignore si le focus est dans un champ de saisie
* 3. Ignore si une touche modificatrice est pressée
* 4. Recherche un élément visible avec aria-keyshortcuts correspondant
* 5. Active l'élément trouvé (clic et/ou focus)
*
* Attributs utilisés :
* - aria-keyshortcuts="x" : définit le raccourci (touche unique sans modificateur)
* - data-autocommute : si présent, déclenche un clic automatique
* - data-autocommute="false" : empêche le focus automatique
*/
document.addEventListener('keydown', e => {
// Sortie rapide si les raccourcis sont désactivés
if (!areShortcutsEnabled()) return;
const activeElement = document.activeElement;
const tag = activeElement.tagName;
// Ignorer si focus dans un champ de saisie
if (ignoreTags.includes(tag) || isEditableInput(activeElement)) return;
// Ignorer si touche modificatrice (Ctrl, Alt, Cmd, AltGr)
if (e.ctrlKey || e.altKey || e.metaKey || e.getModifierState('AltGraph')) return;
// Recherche des éléments avec le raccourci correspondant
const candidates = Array.from(document.querySelectorAll(`[aria-keyshortcuts="${e.key}"]`));
// Sélection du premier élément visible
const target = candidates.find(isSelfVisible);
if (target) {
// Clic automatique si l'attribut data-autocommute est présent
if (target.hasAttribute('data-autocommute')) {
target.click();
}
// Focus automatique sauf si explicitement désactivé
if (!ignoreTags.includes(target.tagName)) {
if (target.getAttribute('data-autocommute') !== String(false)) {
target.focus();
}
}
}
});
})();
Télécharger kortic-init-shortcuts.js
Conformité WCAG : un mécanisme permet de désactiver tous les raccourcis clavier via une touche dédiée, répondant ainsi au critère de succès qui exige qu'au moins une des conditions suivantes soit respectée. La checkbox est dans la structure du menu complet.
- les raccourcis peuvent être désactivés ;
- les raccourcis peuvent être remappés ;
- les raccourcis ne sont actifs que lorsque le composant a le focus.
Cette implémentation garantit que les utilisateurs de technologies d'assistance (lecteurs d'écran, reconnaissance vocale) ne se retrouvent pas avec des raccourcis qui se marchent sur les pieds. Parce qu'un conflit de raccourcis, c'est comme deux personnes qui parlent en même temps, personne ne comprend rien.
Structure CSS du menu
Le menu utilise des techniques modernes pour garantir une compatibilité multi-dispositifs.
Taille brute : 5,80 kilo-octets (~1,93 kilo-octets gzip)
#dialogAccessOptionsContainer {
position: relative;
--transition-time: 0.3s;
/* Coupe les transitions — couvre @starting-style automatiquement */
@media (prefers-reduced-motion: reduce) {
--transition-time: 0s;
}
@media (max-width: 63.9375rem) {
font-size: larger;
}
:root.no-motion & {
--transition-time: 0s;
}
}
#buttonDialogAccessOptions {
-webkit-font-smoothing: unset;
-moz-osx-font-smoothing: unset;
font-kerning: none;
overflow: initial;
--outline-offset: -3px;
--button-height: min(3rem, var(--header-outer-height) * .65);
& span {
display: inline-block;
font-size: 1.125em;
font-weight: 450;
color: CanvasText !important;
}
&:hover span {
text-decoration: underline !important;
text-underline-position: under;
}
& svg:first-child {
left: 0;
transform: translate(0, -50%);
}
@media (min-width: 30rem) {
padding-left: calc(var(--button-height, 2.5em) * .9);
&.displayOptionsActive {
padding-right: var(--button-height, 2.5em);
& #activeOptionsMarker {
top: 50%;
}
}
}
@media (max-width: 29.9375rem) {
padding-left: calc(var(--button-height, 2.5em) * .45);
padding-right: 0;
&.displayOptionsActive {
padding-right: calc(var(--button-height, 2.5em) / 1.2);
& #activeOptionsMarker {
top: 20%;
}
}
& span {
width: 0;
height: 0;
overflow: hidden;
}
}
&:not(.displayOptionsActive) #activeOptionsMarker {
visibility: hidden;
}
&.displayOptionsActive #activeOptionsMarker {
position: absolute;
right: 0.3125em;
left: auto;
width: calc(var(--button-height, 2.5em) * .5);
height: calc(var(--button-height, 2.5em) * .5);
display: block;
transform: translateY(-50%);
background-color: var(--green, green);
border-radius: 50%;
color: var(--basic-color-reverse, white) !important;
box-shadow: 0 0 0 0.0625em var(--basic-color, black);
}
}
#dialogAccessOptions {
background-color: var(--canvas, Canvas);
box-shadow: 0 0 0 1px var(--box-shadow, lightgrey);
line-height: var(--lh);
min-height: 100dvh;
max-height: 100dvh;
max-width: 100dvw;
overflow: auto;
position: fixed;
scroll-padding-top: 5em;
top: 0;
left: 0;
border: none;
/* Transition entrée/sortie */
transform: translate(0, 0);
transition: transform var(--transition-time) ease,
display var(--transition-time) allow-discrete,
overlay var(--transition-time) allow-discrete;
/* État initial à l'apparition */
@starting-style {
transform: translate(-100%, 0);
}
&:not([open]) {
transform: translate(-100%, 0);
}
&::backdrop {
backdrop-filter: blur(5px);
opacity: 1;
transition: opacity var(--transition-time) ease,
display var(--transition-time) allow-discrete,
overlay var(--transition-time) allow-discrete;
@starting-style {
opacity: 0;
}
@media (prefers-color-scheme: light) {
background-color: rgba(255, 255, 255, 0.4);
}
@media (prefers-color-scheme: dark) {
background-color: rgba(0, 0, 0, 0.5);
}
}
@media (max-width: 47.9375rem) {
right: 0;
}
@media (min-width: 48rem) {
width: calc(50dvw + var(--fixed-position, 0));
min-width: 30em;
max-width: 50em;
}
& #labelDialogAccessOptions {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 100%;
padding-inline-end: 2.5em;
position: relative;
& #countDialogAccessOptions[inert] {
display: none;
}
}
& #closeDialogAccessOptions {
color: inherit;
position: absolute;
right: 0;
background-color: transparent;
height: 3.75em;
width: 3.75em;
border-radius: 100%;
outline-offset: var(--negative-half-gutter, -1.25em);
&::before {
position: absolute;
inset: 0;
content: '';
-webkit-mask-image: var(--svg-close);
-webkit-mask-size: 40%;
-webkit-mask-position: center center;
-webkit-mask-repeat: no-repeat;
mask-image: var(--svg-close);
mask-size: 40%;
mask-position: center center;
mask-repeat: no-repeat;
background-color: currentColor;
}
}
& #resetOptionsButton {
position: absolute;
background-color: transparent;
padding: 0;
outline-offset: -0.1875em;
border-radius: 0.3125em;
color: var(--link-color, blue);
width: 2em;
height: 3.75em;
right: 0;
&::after {
content: "\20" / "";
position: absolute;
inset: 0;
mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">\
<path d="M.63,12.77h11.6L7.51,7.9a11.84,11.84,0,0,1,16.7415-.30574q.06979.0673.1385.13574a12,12,0,0,1,0,16.9,11.85,11.85,0,0,1-16.758.122q-.06144-.06056-.122-.122a11.35,11.35,0,0,1-3.44-8.45H.63A16.1,16.1,0,0,0,5.11,27.07,15.39018,15.39018,0,0,0,26.87,5.3,15.17,15.17,0,0,0,5.41688,5.15312Q5.34295,5.22605,5.27,5.3L.63.42Z"/>\
</svg>');
mask-position: center center;
mask-size: 1.5em;
background-color: var(--link-color, LinkText);
}
@media (any-hover: hover) {
&:hover::after {
background-color: var(--link-color-alternate, green);
}
}
&:focus::after {
background-color: var(--link-color-alternate, green);
}
/* si gestion par SVG dans le script
& > svg {
width: 1.5em;
height: 1.5em;
vertical-align: middle;
}
*/
}
:root:not([class*="dys"]) & {
font-family: var(--initial-font, 'arial'), sans-serif !important;
}
}
:root.root-overflow-stop {
height: 100dvh;
overflow: hidden;
& body {
overscroll-behavior: none;
}
}
Points techniques :
- utilisation de
dvh(dynamic viewport height) pour gérer les barres d'interface mobile qui apparaissent et disparaissent (merci Safari pour ce casse-tête) ; backdrop-filter: blur(5px)pour un effet de flou sympa.
Le script du menu
On y vient !
Taille brute : 15,10 kilo-octets (~4,81 kilo-octets gzip)
/**
* Source Kortic https://www.kortic.com/le-blog-kortic/2026/01/25/menu-d-accessibilite-gratuit-ou-presque.html
* licence Creative Commons CC BY-NC-SA 4.0
* https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr
* Attribution, partage dans les mêmes conditions, pas d'utilisation commerciale
*/
/**
* Configuration des options d'accessibilité disponibles.
* Mappage ID => classe CSS appliquée sur :root
*
* @type {Object<number, string>}
* @global
*/
window.accessibilityDisplayOptions = {
1: 'line-spacing',
2: 'initial-font',
3: 'dyslexic',
4: 'dyslexic-comic',
5: 'contrast',
6: 'simca-persistent-buttons',
7: 'grayscale',
8: 'fixed-navigation',
9: 'letter-spacing',
10: 'disable-blank',
11: 'font-small',
12: 'font-large',
13: 'no-motion',
14: 'brightness',
15: 'full-viewport'
};
/**
* Gestionnaire d'activation des options d'affichage accessibles.
*
* Fonctionnalités :
* - Active/désactive les options d'affichage via classes CSS sur :root
* - Persiste les choix utilisateur dans localStorage via un objet JSON unique
* - Gère l'expiration automatique des préférences (10 jours d'inactivité)
* - Met à jour le timestamp à chaque lecture des préférences
* - Supprime automatiquement le localStorage si aucune option n'est active
* - Gère l'ouverture/fermeture d'un dialog modal
* - Compte et affiche le nombre d'options actives
* - Support des callbacks personnalisés via data-callback
* - Gère les options mutuellement exclusives via l'attribut name
* - Détecte prefers-reduced-motion au chargement
*
* @requires window.accessibilityDisplayOptions - Configuration des options
* @requires window.i18n - (optionnel) Traductions internationalisées
* @requires window.trapFocus - (optionnel) Fonction de piégeage du focus dans le dialog
*
* Structure localStorage :
* Clé unique : 'a11yOptions'
* Valeur : {
* options: { [optionName]: true },
* lastUsed: timestamp
* }
*
* Logique d'expiration :
* - Pas d'options actives : pas de localStorage
* - Au moins une option active : création de l'objet JSON avec timestamp
* - À chaque lecture (si objet existe) : mise à jour du timestamp
* - Si timestamp > 10 jours : purge complète
* - Dernière option désactivée : suppression de l'objet localStorage
*
* Structure DOM attendue :
* - #buttonDialogAccessOptions : bouton d'ouverture du menu
* - #dialogAccessOptions : dialog contenant les options
* - #closeDialogAccessOptions : bouton de fermeture du dialog
* - #activeOptionsMarker : élément affichant le statut des options actives
* - #countDialogAccessOptions : élément affichant le nombre d'options actives
* - input[id="{option}-option"] : checkbox pour chaque option
*
* Attributs optionnels sur les inputs :
* - data-callback="functionName" : fonction globale à exécuter lors du toggle
* - name="groupName" : groupe d'options mutuellement exclusives
* - class="ignore-status" : exclure du comptage des options actives
* - disabled : option désactivée, ignorée du comptage
*
* @param {HTMLElement} root - Element racine (document.documentElement)
*
* @example
* // Structure HTML minimale
* <button id="buttonDialogAccessOptions">Options</button>
* <dialog id="dialogAccessOptions">
* <button id="closeDialogAccessOptions">Fermer</button>
* <span id="countDialogAccessOptions">0</span>
* <input type="checkbox" id="grayscale-option" data-callback="onGrayscaleToggle">
* </dialog>
*
* @example
* // Callback personnalisé
* window.onGrayscaleToggle = function() {
* console.log('Grayscale toggled');
* };
*/
((root) => {
// Recherche du bouton d'ouverture du menu
const trigger = document.getElementById('buttonDialogAccessOptions');
if (!trigger) return;
// Recherche du dialog d'options
const widget = document.getElementById('dialogAccessOptions');
if (!widget) {
trigger.remove();
return;
}
// Position du widget (true = à droite)
const widgetPositionRight = false;
// Clé unique pour le localStorage
const STORAGE_KEY = 'a11yOptions';
// Durée de validité des préférences (10 jours en millisecondes)
const EXPIRATION_DELAY = 10 * 24 * 60 * 60 * 1000;
/**
* Traductions par défaut pour les messages utilisateur.
* Peut être surchargé par window.i18n
*
* @type {Object<string, string>}
*/
const defaultTranslations = {
options_display_one: 'Une option activée',
options_display_multiple: '{number} options activées',
options_display_none: 'Aucune option activée',
reset_options: 'Réinitialiser',
reset_options_aria: 'Réinitialiser l\'affichage (la page sera rechargée)'
};
// Objet de traductions (global ou fallback)
const i18n = window.i18n || {};
/**
* Récupère une traduction internationalisée.
* Utilise window.i18n si disponible, sinon les traductions par défaut.
*
* @param {string} key - Clé de traduction
* @returns {string} Traduction correspondante
*/
function t(key) {
return i18n[key] || defaultTranslations[key];
}
/**
* Récupère les préférences depuis le localStorage.
* Vérifie l'expiration et met à jour le timestamp si valide.
* Purge automatiquement si expiré.
*
* @returns {Object} Objet contenant les options actives { optionName: true }
* @private
*/
function getPreferences() {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return {};
try {
const data = JSON.parse(stored);
const now = Date.now();
// Vérification de l'expiration
if (now - data.lastUsed > EXPIRATION_DELAY) {
localStorage.removeItem(STORAGE_KEY);
return {};
}
// Mise à jour du timestamp
data.lastUsed = now;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
return data.options || {};
} catch (e) {
// En cas d'erreur de parsing, on purge
localStorage.removeItem(STORAGE_KEY);
return {};
}
}
/**
* Sauvegarde les préférences dans le localStorage.
* Si aucune option n'est active, supprime l'entrée localStorage.
*
* @param {Object} options - Objet contenant les options actives { optionName: true }
* @private
*/
function savePreferences(options) {
// Si aucune option active, on supprime tout
if (Object.keys(options).length === 0) {
localStorage.removeItem(STORAGE_KEY);
return;
}
const data = {
options: options,
lastUsed: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
/**
* Vérifie et met à jour l'affichage du nombre d'options actives.
* Met à jour l'aria-label du marqueur et le compteur visuel.
* Exclut les inputs avec .ignore-status ou [disabled].
* Gère l'affichage du bouton de réinitialisation.
*
* @private
*/
const checkEnabledOptions = () => {
let enableCount = 0;
const toggleMarker = trigger.querySelector('#activeOptionsMarker');
const countDialogAccessOptions = widget.querySelector('#countDialogAccessOptions');
widget.querySelectorAll('input:not(.ignore-status):not([disabled])').forEach(input => {
if (input.checked) enableCount++;
});
const active = enableCount > 0;
// Bascule des classes selon l'état
trigger.classList.toggle('displayOptionsActive', active);
trigger.classList.toggle('cta-canvas', !active);
trigger.classList.toggle('cta-link', active);
toggleMarker?.setAttribute('aria-label', !active
? t('options_display_none')
: enableCount === 1
? t('options_display_one')
: t('options_display_multiple').replace('{number}', `${enableCount}`)
);
countDialogAccessOptions.textContent = `${enableCount}`;
enableCount === 0
? countDialogAccessOptions.setAttribute('inert', '')
: countDialogAccessOptions.removeAttribute('inert');
// Gestion du bouton de réinitialisation
let resetButton = widget.querySelector('#resetOptionsButton');
if (enableCount > 0) {
if (!resetButton) {
resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.id = 'resetOptionsButton';
resetButton.setAttribute('title', t('reset_options'));
resetButton.setAttribute('aria-label', t('reset_options_aria'));
resetButton.addEventListener('click', () => {
localStorage.removeItem(STORAGE_KEY);
document.location.reload();
});
// image insérée ou gestion par mask CSS
// resetButton.insertAdjacentHTML('beforeend', '<svg aria-hidden="true" fill="var(--canvasText)"><use href="#icon-reload"></use></svg>');
countDialogAccessOptions.parentNode.insertBefore(resetButton, countDialogAccessOptions.nextSibling);
}
} else {
resetButton?.remove();
}
};
/**
* Initialisation des gestionnaires d'événements pour chaque option.
*
* Pour chaque option :
* 1. Vérifie la présence de l'input dans le DOM
* 2. Restaure l'état depuis localStorage (objet JSON unique)
* 3. Applique la classe CSS correspondante sur :root
* 4. Écoute les changements et persiste dans localStorage
* 5. Gère les options mutuellement exclusives (même attribut name)
* 6. Exécute les callbacks personnalisés si définis
* 7. Supprime le localStorage si aucune option n'est active
*/
Object.values(window.accessibilityDisplayOptions).forEach(option => {
const setter = document.getElementById(option + '-option');
if (!setter) return;
option = String(option);
// Restauration depuis localStorage
const prefs = getPreferences();
if (prefs[option]) {
root.classList.add(option);
setter.checked = true;
// Exécution du callback au chargement si l'option est cochée
if (setter.hasAttribute('data-callback')) {
window[setter.getAttribute('data-callback')]?.();
}
}
// Gestionnaire de changement d'état
setter.addEventListener('input', () => {
const prefs = getPreferences();
if (setter.checked) {
root.classList.add(option);
prefs[option] = true;
// Désactivation des options mutuellement exclusives
if (setter.name) {
widget.querySelectorAll(`[name="${setter.name}"]:checked`).forEach(input => {
if (input !== setter) {
input.click();
}
});
}
} else {
root.classList.remove(option);
delete prefs[option];
}
// Sauvegarde (ou suppression si vide)
savePreferences(prefs);
// Exécution du callback personnalisé si défini
if (setter.dataset.callback) {
window[setter.dataset.callback]?.();
}
// Mise à jour du compteur
checkEnabledOptions();
});
});
// Vérification initiale du nombre d'options actives
checkEnabledOptions();
/**
* Ferme le dialog modal et restaure le focus sur le bouton déclencheur.
* Retire la classe root-overflow-stop pour réactiver le scroll.
*
* @private
*/
function closeModal() {
root.classList.remove('root-overflow-stop');
widget.close();
trigger.focus();
}
// Gestionnaires d'événements du dialog
widget.querySelector('#closeDialogAccessOptions').addEventListener('click', closeModal);
widget.addEventListener('keydown', function (event) {
event.key === 'Escape' && closeModal();
});
// Ouverture du dialog
trigger.addEventListener('click', () => {
widgetPositionRight ? widget.classList.add('pos-right') : false;
root.classList.add('root-overflow-stop');
widget.showModal();
window.trapFocus?.(widget);
});
/**
* Détection de prefers-reduced-motion au chargement du DOM.
*
* Actions :
* - Supprime les éléments .is-motion[hidden] si reduced-motion actif
* - Supprime les éléments .no-motion[hidden] si animations autorisées
* - Ajoute la classe 'no-motion' sur :root si reduced-motion actif
*/
document.addEventListener('DOMContentLoaded', () => {
(() => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const [toRemove, toShow] = prefersReducedMotion
? ['.is-motion[hidden]', '.no-motion[hidden]']
: ['.no-motion[hidden]', '.is-motion[hidden]'];
document.querySelectorAll(toRemove).forEach(el => el.remove());
document.querySelectorAll(toShow).forEach(el => el.removeAttribute('hidden'));
prefersReducedMotion && document.documentElement.classList.add('no-motion');
})();
});
})(document.documentElement);
/**
* Détection et gestion des préférences système d'accessibilité
*
* Gère automatiquement :
* - prefers-reduced-motion : désactivation des animations
* - prefers-contrast : mode contraste élevé
* - prefers-color-scheme : mode sombre/clair
*
* Comportement :
* 1. Au chargement, détecte les préférences système actives
* 2. Synchronise l'état des switches de configuration avec les préférences
* 3. Désactive les switches si la préférence système est active (priorité à l'OS)
* 4. Recharge la page si les préférences système changent (pour réappliquer le CSS)
*
*/
(() => {
'use strict';
/**
* Media query pour détecter la préférence "réduction des mouvements"
* Utilisé pour les utilisateurs sensibles aux animations (vestibulaire, epilepsie, migraines)
*/
// Si l'utilisateur a activé la réduction de mouvement au niveau système
if (window.matchMedia("(prefers-reduced-motion:reduce)").matches) {
const motionSwitch = document.querySelector('#no-motion-option');
if (motionSwitch) {
// Active et désactive le switch pour indiquer que c'est géré par l'OS
motionSwitch.setAttribute('checked', '');
motionSwitch.setAttribute('disabled', '');
}
}
/**
* Media query pour détecter la préférence "contraste élevé"
* Utilisé par les personnes malvoyantes ou ayant des difficultés de perception des couleurs
* @type {MediaQueryList}
*/
const mediaContrast = window.matchMedia("(prefers-contrast:more)");
// Si l'utilisateur a activé le contraste élevé au niveau système
if (mediaContrast.matches) {
// Ajout de la classe pour déclencher les styles CSS spécifiques
document.documentElement.classList.add('prefers-contrast', 'contrast');
const contrastSwitch = document.querySelector('#contrast-option');
if (contrastSwitch) {
// Active et désactive le switch (priorité à la préférence système)
contrastSwitch.setAttribute('checked', '');
contrastSwitch.setAttribute('disabled', '');
}
// Suppression des éléments qui ne doivent s'afficher que si contraste non activé
// Exemple : messages d'aide pour activer le contraste
document.querySelectorAll('.hide-prefers-contrast').forEach(el => el.remove());
}
/**
* Rechargement de la page lors du changement des préférences système
* Nécessaire, car certains styles CSS ne peuvent pas être modifiés dynamiquement
* (variables CSS définies dans @media, images différentes selon le thème, etc.)
*/
// , window.matchMedia("(prefers-color-scheme:dark)")
[mediaContrast].forEach(media =>
media.addEventListener('change', () => document.location.reload())
);
})();
Cas d'usages
Sur ce site, on a poussé le bouchon, histoire de démontrer que c'est faisable.
Ouvrez le menu en haut de page (ou tapoter Touche clavier o o ) et testez, c'est le plus simple.
Apparence / Couleurs
- Contraste renforcé : pour ceux qui veulent lire sans plisser les yeux ;
- Luminosité réduite : diminue l'intensité des blancs (si vous n'êtes pas en mode sombre) ;
- Niveaux de gris : supprime les couleurs (utile pour certains types de daltonisme ou pour faire croire qu'on est dans un film muet en noir et blanc, vu que ce site est totalement silencieux).
Typographie
- Taille de police : parce que tout le monde n'a pas des yeux de lynx. Si vous avez configuré votre navigateur pour avoir une taille de police plus grande par défaut, ce sera encore plus gros ! ;
- Interlignage : espacement vertical entre les lignes (respiration visuelle appréciable) ;
- Espacement des lettres : pour ne plus confondre le « rn » et le « m » ;
- Polices spécifiques : dyslexie (Accessible DfA) ou Comic Sans MS (oui, même elle a son utilité). Voir pas de police du tout en utilisant celle que vous avez configurée dans votre navigateur.
Navigation
- Menu fixe : la navigation principale ne disparait pas au scroll ;
- Désactivation nouvelle fenêtre : force l'ouverture dans le même onglet (fini les 47 onglets ouverts) ;
- Gestes assistés : boutons de contrôle toujours visibles (plus besoin de deviner où swiper).
Animations
- Réduction des animations : pour ceux qui ont le mal de mer devant un parallax ;
- détection automatique de
prefers-reduced-motion(votre système d'exploitation sait déjà ce que vous voulez).
Conclusion
Ce menu d’options d’affichage n’a rien d’un gadget : il applique réellement les principes des WCAG 2.2 sans promettre de miracles. Navigation clavier fiable, raccourcis maîtrisés, contrastes renforcés, réduction des animations, structure HTML propre, respect des préférences système… tout repose sur des standards, pas sur de la magie noire.
Ce n’est pas une solution de conformité automatique (ça n'existe définitivement pas), mais une brique solide, pérenne et transparente qui améliore l’expérience sans mentir aux utilisateurs. Bref : un outil utile, contrôlable.
That's all, Folks !