feat: initial release - accessibility widget RGAA 4.1

This commit is contained in:
jessy-david-dev
2026-04-28 16:16:40 +02:00
commit bd5b7c8b83
22 changed files with 3914 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useRef } from "react";
import type { A11yState, A11yAction } from "../types";
import type { T } from "../i18n";
interface SectionProps {
title: string;
children: React.ReactNode;
}
function Section({ title, children }: SectionProps) {
return (
<section className="a11y-section">
<h3 className="a11y-section-title">{title}</h3>
{children}
</section>
);
}
interface ToggleProps {
label: string;
checked: boolean;
onChange: () => void;
}
function Toggle({ label, checked, onChange }: ToggleProps) {
const id = `a11y-sw-${label.replace(/\s+/g, "-").toLowerCase().slice(0, 30)}`;
return (
<div className="a11y-toggle">
<span id={id} className="a11y-toggle-label">
{label}
</span>
{/* role="switch" + aria-labelledby : annoncé correctement par NVDA, JAWS, VoiceOver */}
<button
type="button"
role="switch"
aria-checked={checked}
aria-labelledby={id}
className="a11y-switch"
onClick={onChange}
>
<span className="a11y-switch-thumb" />
{/* Texte masqué visuellement pour lecteurs d'écran anciens (JAWS < 2022) */}
<span className="a11y-sr-only">{checked ? "activé" : "désactivé"}</span>
</button>
</div>
);
}
interface FontSizeControlProps {
value: number;
t: T;
dispatch: React.Dispatch<A11yAction>;
}
const FONT_SIZES: { value: number; labelKey: keyof T }[] = [
{ value: 1, labelKey: "fontSizeNormal" },
{ value: 1.2, labelKey: "fontSizeLarge" },
{ value: 1.4, labelKey: "fontSizeXL" },
{ value: 1.6, labelKey: "fontSizeXXL" },
];
function FontSizeControl({ value, t, dispatch }: FontSizeControlProps) {
return (
<fieldset className="a11y-fieldset">
<legend className="a11y-legend">{t.fontSize}</legend>
<div className="a11y-radio-group" role="group">
{FONT_SIZES.map((fs) => (
<button
key={fs.value}
type="button"
className="a11y-size-btn"
aria-pressed={value === fs.value}
onClick={() =>
dispatch({ type: "SET_FONT_SIZE", payload: fs.value })
}
>
{t[fs.labelKey] as string}
</button>
))}
</div>
</fieldset>
);
}
interface FontFamilyControlProps {
value: A11yState["fontFamily"];
t: T;
dispatch: React.Dispatch<A11yAction>;
}
function FontFamilyControl({ value, t, dispatch }: FontFamilyControlProps) {
const families: { value: A11yState["fontFamily"]; label: string }[] = [
{ value: "default", label: t.fontDefault },
{ value: "readable", label: t.fontReadable },
{ value: "dyslexic", label: t.fontDyslexic },
];
return (
<fieldset className="a11y-fieldset">
<legend className="a11y-legend">{t.sectionFont}</legend>
<div className="a11y-radio-group" role="group">
{families.map((f) => (
<button
key={f.value}
type="button"
className="a11y-size-btn"
aria-pressed={value === f.value}
onClick={() =>
dispatch({ type: "SET_FONT_FAMILY", payload: f.value })
}
>
{f.label}
</button>
))}
</div>
</fieldset>
);
}
interface AccessibilityPanelProps {
state: A11yState;
dispatch: React.Dispatch<A11yAction>;
onClose: () => void;
t: T;
triggerId: string;
}
export function AccessibilityPanel({
state,
dispatch,
onClose,
t,
triggerId,
}: AccessibilityPanelProps) {
const panelRef = useRef<HTMLDivElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
// Fermeture via Escape + focus-trap basique
useEffect(() => {
closeRef.current?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
document.getElementById(triggerId)?.focus();
}
if (e.key === "Tab" && panelRef.current) {
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose, triggerId]);
return (
<div
ref={panelRef}
className="a11y-panel"
role="dialog"
aria-modal="true"
aria-labelledby="a11y-panel-title"
>
<div className="a11y-panel-header">
<h2 id="a11y-panel-title" className="a11y-panel-title">
{t.panelTitle}
</h2>
<div className="a11y-panel-actions">
<button
type="button"
className="a11y-btn-reset"
onClick={() => dispatch({ type: "RESET" })}
>
{t.reset}
</button>
<button
ref={closeRef}
type="button"
className="a11y-btn-close"
aria-label={t.closeMenu}
onClick={onClose}
>
</button>
</div>
</div>
<div className="a11y-panel-body">
{/* RGAA 3 Couleurs */}
<Section title={t.sectionColors}>
<Toggle
label={t.highContrast}
checked={state.highContrast}
onChange={() => dispatch({ type: "TOGGLE_HIGH_CONTRAST" })}
/>
<Toggle
label={t.invertColors}
checked={state.invertColors}
onChange={() => dispatch({ type: "TOGGLE_INVERT_COLORS" })}
/>
</Section>
{/* RGAA 10 Présentation du texte */}
<Section title={t.sectionText}>
<FontSizeControl value={state.fontSize} t={t} dispatch={dispatch} />
<Toggle
label={t.lineHeight}
checked={state.lineHeight}
onChange={() => dispatch({ type: "TOGGLE_LINE_HEIGHT" })}
/>
<Toggle
label={t.letterSpacing}
checked={state.letterSpacing}
onChange={() => dispatch({ type: "TOGGLE_LETTER_SPACING" })}
/>
<Toggle
label={t.wordSpacing}
checked={state.wordSpacing}
onChange={() => dispatch({ type: "TOGGLE_WORD_SPACING" })}
/>
<Toggle
label={t.textAlignLeft}
checked={state.textAlign === "left"}
onChange={() =>
dispatch({
type: "SET_TEXT_ALIGN",
payload: state.textAlign === "left" ? "default" : "left",
})
}
/>
</Section>
{/* Police de caractères */}
<Section title={t.sectionFont}>
<FontFamilyControl
value={state.fontFamily}
t={t}
dispatch={dispatch}
/>
</Section>
{/* RGAA 13 Consultation / Mouvement */}
<Section title={t.sectionMotion}>
<Toggle
label={t.reduceMotion}
checked={state.reduceMotion}
onChange={() => dispatch({ type: "TOGGLE_REDUCE_MOTION" })}
/>
<Toggle
label={t.pauseAnimations}
checked={state.pauseAnimations}
onChange={() => dispatch({ type: "TOGGLE_PAUSE_ANIMATIONS" })}
/>
</Section>
{/* Navigation clavier */}
<Section title={t.sectionNavigation}>
<Toggle
label={t.focusVisible}
checked={state.focusVisible}
onChange={() => dispatch({ type: "TOGGLE_FOCUS_VISIBLE" })}
/>
<Toggle
label={t.highlightLinks}
checked={state.highlightLinks}
onChange={() => dispatch({ type: "TOGGLE_HIGHLIGHT_LINKS" })}
/>
</Section>
{/* Aide à la lecture */}
<Section title={t.sectionReading}>
<Toggle
label={t.readingGuide}
checked={state.readingGuide}
onChange={() => dispatch({ type: "TOGGLE_READING_GUIDE" })}
/>
<Toggle
label={t.readingMask}
checked={state.readingMask}
onChange={() => dispatch({ type: "TOGGLE_READING_MASK" })}
/>
</Section>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useRef, useCallback } from "react";
import { useA11yStore } from "../store";
import { useA11yEffects } from "../useA11yEffects";
import { AccessibilityPanel } from "./AccessibilityPanel";
import { translations } from "../i18n";
import type { A11yWidgetProps, A11yAction } from "../types";
import "../styles.css";
const TRIGGER_ID = "a11y-widget-trigger";
export function AccessibilityWidget({
position = "bottom-right",
lang = "fr",
storageKey = "a11y-widget-prefs",
}: A11yWidgetProps) {
const [open, setOpen] = useState(false);
const { state, dispatch } = useA11yStore(storageKey);
const t = translations[lang];
const liveRef = useRef<HTMLSpanElement>(null);
useA11yEffects(state);
// Annonce chaque changement d'option aux lecteurs d'écran via aria-live
const dispatchWithAnnounce = useCallback(
(action: A11yAction) => {
dispatch(action);
if (!liveRef.current) return;
const labels: Partial<Record<A11yAction["type"], string>> = {
TOGGLE_HIGH_CONTRAST: t.highContrast,
TOGGLE_INVERT_COLORS: t.invertColors,
TOGGLE_LINE_HEIGHT: t.lineHeight,
TOGGLE_LETTER_SPACING: t.letterSpacing,
TOGGLE_WORD_SPACING: t.wordSpacing,
TOGGLE_REDUCE_MOTION: t.reduceMotion,
TOGGLE_PAUSE_ANIMATIONS: t.pauseAnimations,
TOGGLE_FOCUS_VISIBLE: t.focusVisible,
TOGGLE_HIGHLIGHT_LINKS: t.highlightLinks,
TOGGLE_READING_GUIDE: t.readingGuide,
TOGGLE_READING_MASK: t.readingMask,
RESET: lang === "fr" ? "Réinitialisé" : "Reset",
};
const label = labels[action.type];
if (label) {
// Vider puis reremplir force une nouvelle annonce même si le texte est identique
liveRef.current.textContent = "";
requestAnimationFrame(() => {
if (liveRef.current) liveRef.current.textContent = label;
});
}
},
[dispatch, t, lang],
);
const activeCount = [
state.highContrast,
state.invertColors,
state.fontSize !== 1,
state.lineHeight,
state.letterSpacing,
state.wordSpacing,
state.fontFamily !== "default",
state.reduceMotion,
state.pauseAnimations,
state.focusVisible,
state.textAlign !== "default",
state.highlightLinks,
state.readingGuide,
state.readingMask,
].filter(Boolean).length;
return (
<div className={`a11y-widget a11y-widget--${position}`}>
{/* Live region : annonce les changements d'état aux lecteurs d'écran */}
<span
ref={liveRef}
aria-live="polite"
aria-atomic="true"
className="a11y-sr-only"
/>
<button
id={TRIGGER_ID}
type="button"
className="a11y-trigger"
aria-label={open ? t.closeMenu : t.openMenu}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((v) => !v)}
>
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
width="28"
height="28"
fill="currentColor"
>
<path d="M12 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm8 5H4a1 1 0 0 0 0 2h3.3l-1.76 7.04A1 1 0 0 0 6.5 17h.06l2.44-.81V22a1 1 0 0 0 2 0v-4h2v4a1 1 0 0 0 2 0v-5.81l2.44.81h.06a1 1 0 0 0 .96-1.25L16.7 9H20a1 1 0 0 0 0-2z" />
</svg>
{activeCount > 0 && (
<span
className="a11y-badge"
aria-label={
lang === "fr"
? `${activeCount} option${activeCount > 1 ? "s" : ""} active${activeCount > 1 ? "s" : ""}`
: `${activeCount} active option${activeCount > 1 ? "s" : ""}`
}
>
{activeCount}
</span>
)}
</button>
{open && (
<AccessibilityPanel
state={state}
dispatch={dispatchWithAnnounce}
onClose={() => setOpen(false)}
t={t}
triggerId={TRIGGER_ID}
/>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
export const translations = {
fr: {
openMenu: "Ouvrir les options d'accessibilité",
closeMenu: "Fermer les options d'accessibilité",
panelTitle: "Accessibilité",
reset: "Réinitialiser",
close: "Fermer",
sectionColors: "Couleurs",
highContrast: "Contraste élevé",
invertColors: "Inverser les couleurs",
sectionText: "Texte",
fontSize: "Taille du texte",
fontSizeNormal: "Normal",
fontSizeLarge: "Grand",
fontSizeXL: "Très grand",
fontSizeXXL: "Très très grand",
lineHeight: "Hauteur de ligne",
letterSpacing: "Espacement des lettres",
wordSpacing: "Espacement des mots",
sectionFont: "Police",
fontDefault: "Par défaut",
fontReadable: "Lisible",
fontDyslexic: "Dyslexique",
sectionMotion: "Mouvement",
reduceMotion: "Réduire les animations",
pauseAnimations: "Mettre en pause les animations",
sectionNavigation: "Navigation",
focusVisible: "Indicateur de focus renforcé",
highlightLinks: "Mettre en évidence les liens",
sectionReading: "Aide à la lecture",
readingGuide: "Guide de lecture",
readingMask: "Masque de lecture",
textAlignLeft: "Aligner le texte à gauche",
},
en: {
openMenu: "Open accessibility options",
closeMenu: "Close accessibility options",
panelTitle: "Accessibility",
reset: "Reset",
close: "Close",
sectionColors: "Colors",
highContrast: "High contrast",
invertColors: "Invert colors",
sectionText: "Text",
fontSize: "Font size",
fontSizeNormal: "Normal",
fontSizeLarge: "Large",
fontSizeXL: "Extra large",
fontSizeXXL: "Extra extra large",
lineHeight: "Line height",
letterSpacing: "Letter spacing",
wordSpacing: "Word spacing",
sectionFont: "Font",
fontDefault: "Default",
fontReadable: "Readable",
fontDyslexic: "Dyslexic",
sectionMotion: "Motion",
reduceMotion: "Reduce animations",
pauseAnimations: "Pause animations",
sectionNavigation: "Navigation",
focusVisible: "Enhanced focus indicator",
highlightLinks: "Highlight links",
sectionReading: "Reading aids",
readingGuide: "Reading guide",
readingMask: "Reading mask",
textAlignLeft: "Align text to the left",
},
} as const;
export type Lang = keyof typeof translations;
export type T = (typeof translations)[Lang];
+8
View File
@@ -0,0 +1,8 @@
export { AccessibilityWidget } from "./components/AccessibilityWidget";
export { AccessibilityPanel } from "./components/AccessibilityPanel";
export { useA11yStore, DEFAULT_STATE } from "./store";
export { useA11yEffects } from "./useA11yEffects";
export { translations } from "./i18n";
export type { A11yState, A11yAction, A11yWidgetProps } from "./types";
export type { Lang, T } from "./i18n";
import "./styles.css";
+84
View File
@@ -0,0 +1,84 @@
import { useReducer, useEffect, useCallback } from "react";
import type { A11yState, A11yAction } from "./types";
export const DEFAULT_STATE: A11yState = {
highContrast: false,
invertColors: false,
fontSize: 1,
lineHeight: false,
letterSpacing: false,
wordSpacing: false,
fontFamily: "default",
reduceMotion: false,
pauseAnimations: false,
focusVisible: false,
textAlign: "default",
highlightLinks: false,
readingGuide: false,
readingMask: false,
};
function reducer(state: A11yState, action: A11yAction): A11yState {
switch (action.type) {
case "TOGGLE_HIGH_CONTRAST":
return { ...state, highContrast: !state.highContrast };
case "TOGGLE_INVERT_COLORS":
return { ...state, invertColors: !state.invertColors };
case "SET_FONT_SIZE":
return { ...state, fontSize: action.payload };
case "TOGGLE_LINE_HEIGHT":
return { ...state, lineHeight: !state.lineHeight };
case "TOGGLE_LETTER_SPACING":
return { ...state, letterSpacing: !state.letterSpacing };
case "TOGGLE_WORD_SPACING":
return { ...state, wordSpacing: !state.wordSpacing };
case "SET_FONT_FAMILY":
return { ...state, fontFamily: action.payload };
case "TOGGLE_REDUCE_MOTION":
return { ...state, reduceMotion: !state.reduceMotion };
case "TOGGLE_PAUSE_ANIMATIONS":
return { ...state, pauseAnimations: !state.pauseAnimations };
case "TOGGLE_FOCUS_VISIBLE":
return { ...state, focusVisible: !state.focusVisible };
case "SET_TEXT_ALIGN":
return { ...state, textAlign: action.payload };
case "TOGGLE_HIGHLIGHT_LINKS":
return { ...state, highlightLinks: !state.highlightLinks };
case "TOGGLE_READING_GUIDE":
return { ...state, readingGuide: !state.readingGuide };
case "TOGGLE_READING_MASK":
return { ...state, readingMask: !state.readingMask };
case "RESET":
return { ...DEFAULT_STATE };
default:
return state;
}
}
function loadState(storageKey: string): A11yState {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return DEFAULT_STATE;
return { ...DEFAULT_STATE, ...JSON.parse(raw) };
} catch {
return DEFAULT_STATE;
}
}
export function useA11yStore(storageKey: string) {
const [state, dispatch] = useReducer(reducer, undefined, () =>
loadState(storageKey),
);
useEffect(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch {
// storage indisponible, on continue sans persistance
}
}, [state, storageKey]);
const reset = useCallback(() => dispatch({ type: "RESET" }), []);
return { state, dispatch, reset };
}
+440
View File
@@ -0,0 +1,440 @@
/*
Accessibility Widget - Styles
Aligné RGAA 4.1 / WCAG 2.1 AA
*/
/* Variables */
:root {
--a11y-color-bg: #1a1a2e;
--a11y-color-surface: #ffffff;
--a11y-color-border: #d0d0d0;
--a11y-color-text: #1a1a1a;
--a11y-color-accent: #005fcc;
--a11y-color-accent-hover: #0047a0;
--a11y-color-danger: #c0392b;
--a11y-color-switch-off: #767676;
--a11y-color-switch-on: #005fcc;
--a11y-radius: 12px;
--a11y-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
--a11y-panel-width: 340px;
--a11y-z: 999999;
}
/* Widget container (position) */
.a11y-widget {
position: fixed;
z-index: var(--a11y-z);
}
.a11y-widget--bottom-right { right: 20px; bottom: 20px; }
.a11y-widget--bottom-left { left: 20px; bottom: 20px; }
.a11y-widget--top-right { right: 20px; top: 20px; }
.a11y-widget--top-left { left: 20px; top: 20px; }
/* Trigger button */
.a11y-trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
border: 3px solid transparent;
background: var(--a11y-color-bg);
color: #fff;
cursor: pointer;
box-shadow: var(--a11y-shadow);
transition: background 0.2s, transform 0.15s;
}
.a11y-trigger:hover {
background: var(--a11y-color-accent);
transform: scale(1.06);
}
.a11y-trigger:focus-visible {
outline: 3px solid var(--a11y-color-accent);
outline-offset: 4px;
}
/* Badge compteur d'options actives */
.a11y-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
padding: 0 4px;
border-radius: 10px;
background: #e63946;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
pointer-events: none;
}
/* Panel (dialog) */
.a11y-panel {
position: absolute;
bottom: 68px;
right: 0;
width: var(--a11y-panel-width);
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--a11y-color-surface);
color: var(--a11y-color-text);
border: 1px solid var(--a11y-color-border);
border-radius: var(--a11y-radius);
box-shadow: var(--a11y-shadow);
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
}
.a11y-widget--bottom-left .a11y-panel,
.a11y-widget--top-left .a11y-panel { right: auto; left: 0; }
.a11y-widget--top-right .a11y-panel,
.a11y-widget--top-left .a11y-panel { bottom: auto; top: 68px; }
/* Header */
.a11y-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--a11y-color-bg);
color: #fff;
flex-shrink: 0;
}
.a11y-panel-title {
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.02em;
}
.a11y-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.a11y-btn-reset {
padding: 4px 10px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 6px;
background: transparent;
color: #fff;
font-size: 12px;
cursor: pointer;
transition: background 0.15s;
}
.a11y-btn-reset:hover { background: rgba(255,255,255,0.15); }
.a11y-btn-reset:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.a11y-btn-close {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 50%;
background: rgba(255,255,255,0.15);
color: #fff;
font-size: 16px;
cursor: pointer;
transition: background 0.15s;
}
.a11y-btn-close:hover { background: rgba(255,255,255,0.3); }
.a11y-btn-close:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* Body */
.a11y-panel-body {
overflow-y: auto;
padding: 8px 0;
flex: 1;
scroll-behavior: smooth;
}
/* Sections */
.a11y-section {
padding: 10px 16px;
border-bottom: 1px solid var(--a11y-color-border);
}
.a11y-section:last-child { border-bottom: none; }
.a11y-section-title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #666;
}
/* Toggle switch (role="switch") */
.a11y-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
cursor: pointer;
gap: 8px;
}
.a11y-toggle-label {
flex: 1;
font-size: 13px;
color: var(--a11y-color-text);
user-select: none;
}
.a11y-switch {
position: relative;
flex-shrink: 0;
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--a11y-color-switch-off);
cursor: pointer;
transition: background 0.2s;
padding: 0;
}
.a11y-switch[aria-checked="true"] {
background: var(--a11y-color-switch-on);
}
.a11y-switch:focus-visible {
outline: 3px solid var(--a11y-color-accent);
outline-offset: 3px;
}
.a11y-switch-thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
pointer-events: none;
}
.a11y-switch[aria-checked="true"] .a11y-switch-thumb {
transform: translateX(20px);
}
/* Groupes de boutons (taille de police, police) */
.a11y-fieldset {
border: none;
padding: 0;
margin: 6px 0 0;
}
.a11y-legend {
font-size: 12px;
color: #555;
margin-bottom: 6px;
padding: 0;
}
.a11y-radio-group {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.a11y-size-btn {
padding: 5px 10px;
border: 1.5px solid var(--a11y-color-border);
border-radius: 6px;
background: transparent;
color: var(--a11y-color-text);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.a11y-size-btn[aria-pressed="true"] {
border-color: var(--a11y-color-accent);
background: var(--a11y-color-accent);
color: #fff;
}
.a11y-size-btn:hover:not([aria-pressed="true"]) {
border-color: var(--a11y-color-accent);
color: var(--a11y-color-accent);
}
.a11y-size-btn:focus-visible {
outline: 3px solid var(--a11y-color-accent);
outline-offset: 2px;
}
/* Utilitaire : masqué visuellement mais accessible aux lecteurs d'écran */
.a11y-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Effets appliqués sur <html> */
/* RGAA 3 - Contraste élevé */
.a11y-high-contrast {
filter: contrast(1.5) !important;
}
/* Inversion des couleurs */
.a11y-invert {
filter: invert(1) hue-rotate(180deg) !important;
}
/* RGAA 10 - Taille de police via custom property */
.a11y-widget { font-size: initial; } /* ne pas scaler le widget lui-même */
html[style*="--a11y-font-scale"] body,
html[style*="--a11y-font-scale"] p,
html[style*="--a11y-font-scale"] li,
html[style*="--a11y-font-scale"] td,
html[style*="--a11y-font-scale"] h1,
html[style*="--a11y-font-scale"] h2,
html[style*="--a11y-font-scale"] h3,
html[style*="--a11y-font-scale"] h4,
html[style*="--a11y-font-scale"] h5,
html[style*="--a11y-font-scale"] h6 {
font-size: calc(1em * var(--a11y-font-scale, 1)) !important;
}
/* RGAA 10 - Interlignage */
.a11y-line-height body,
.a11y-line-height p,
.a11y-line-height li {
line-height: 1.8 !important;
}
/* RGAA 10 - Espacement lettres */
.a11y-letter-spacing body,
.a11y-letter-spacing p,
.a11y-letter-spacing li {
letter-spacing: 0.12em !important;
}
/* RGAA 10 - Espacement mots */
.a11y-word-spacing body,
.a11y-word-spacing p,
.a11y-word-spacing li {
word-spacing: 0.16em !important;
}
/* Police lisible */
[data-a11y-font="readable"] body,
[data-a11y-font="readable"] p,
[data-a11y-font="readable"] li {
font-family: Georgia, "Times New Roman", serif !important;
}
/* Police dyslexique - utilise OpenDyslexic si disponible, sinon Arial */
[data-a11y-font="dyslexic"] body,
[data-a11y-font="dyslexic"] p,
[data-a11y-font="dyslexic"] li {
font-family: "OpenDyslexic", Arial, sans-serif !important;
}
/* RGAA 10 - Alignement du texte */
[data-a11y-align="left"] body,
[data-a11y-align="left"] p,
[data-a11y-align="left"] li {
text-align: left !important;
}
/* RGAA 13 - Réduire les animations */
.a11y-reduce-motion *,
.a11y-reduce-motion *::before,
.a11y-reduce-motion *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
/* Mettre en pause les animations */
.a11y-pause-animations *,
.a11y-pause-animations *::before,
.a11y-pause-animations *::after {
animation-play-state: paused !important;
}
/* Focus visible renforcé */
.a11y-focus-visible *:focus {
outline: 3px solid #e63946 !important;
outline-offset: 4px !important;
box-shadow: 0 0 0 6px rgba(230, 57, 70, 0.25) !important;
}
/* Liens mis en évidence */
.a11y-highlight-links a {
text-decoration: underline !important;
text-decoration-thickness: 2px !important;
text-underline-offset: 3px !important;
background: #fffde7 !important;
color: #003399 !important;
padding: 0 2px !important;
}
/* Guide de lecture */
#a11y-reading-guide {
position: fixed;
left: 0;
width: 100%;
height: 2px;
background: rgba(0, 95, 204, 0.6);
pointer-events: none;
z-index: var(--a11y-z);
transform: translateY(-50%);
}
/* Masque de lecture */
#a11y-reading-mask-top,
#a11y-reading-mask-bottom {
position: fixed;
left: 0;
width: 100%;
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
z-index: calc(var(--a11y-z) - 1);
}
#a11y-reading-mask-top {
top: 0;
height: 0;
}
#a11y-reading-mask-bottom {
top: 80px;
bottom: 0;
height: auto;
}
+48
View File
@@ -0,0 +1,48 @@
export interface A11yState {
// RGAA 3 - Couleurs
highContrast: boolean;
invertColors: boolean;
// RGAA 10 - Présentation de l'information
fontSize: number; // multiplicateur : 1 | 1.2 | 1.4 | 1.6
lineHeight: boolean;
letterSpacing: boolean;
wordSpacing: boolean;
fontFamily: "default" | "readable" | "dyslexic";
// RGAA 13 - Consultation
reduceMotion: boolean;
pauseAnimations: boolean;
// Navigation au clavier / focus
focusVisible: boolean;
// Lisibilité
textAlign: "default" | "left";
highlightLinks: boolean;
readingGuide: boolean;
readingMask: boolean;
}
export type A11yAction =
| { type: "TOGGLE_HIGH_CONTRAST" }
| { type: "TOGGLE_INVERT_COLORS" }
| { type: "SET_FONT_SIZE"; payload: number }
| { type: "TOGGLE_LINE_HEIGHT" }
| { type: "TOGGLE_LETTER_SPACING" }
| { type: "TOGGLE_WORD_SPACING" }
| { type: "SET_FONT_FAMILY"; payload: A11yState["fontFamily"] }
| { type: "TOGGLE_REDUCE_MOTION" }
| { type: "TOGGLE_PAUSE_ANIMATIONS" }
| { type: "TOGGLE_FOCUS_VISIBLE" }
| { type: "SET_TEXT_ALIGN"; payload: A11yState["textAlign"] }
| { type: "TOGGLE_HIGHLIGHT_LINKS" }
| { type: "TOGGLE_READING_GUIDE" }
| { type: "TOGGLE_READING_MASK" }
| { type: "RESET" };
export interface A11yWidgetProps {
position?: "bottom-right" | "bottom-left" | "top-right" | "top-left";
lang?: "fr" | "en";
storageKey?: string;
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useRef } from "react";
import type { A11yState } from "./types";
const ROOT = document.documentElement;
function cls(add: boolean, ...names: string[]) {
names.forEach((n) => ROOT.classList.toggle(n, add));
}
function setCustomProp(prop: string, value: string | null) {
if (value === null) {
ROOT.style.removeProperty(prop);
} else {
ROOT.style.setProperty(prop, value);
}
}
export function useA11yEffects(state: A11yState) {
// RGAA 3 - Couleurs
useEffect(
() => cls(state.highContrast, "a11y-high-contrast"),
[state.highContrast],
);
useEffect(() => cls(state.invertColors, "a11y-invert"), [state.invertColors]);
// RGAA 10 - Présentation : taille de police
useEffect(() => {
setCustomProp(
"--a11y-font-scale",
state.fontSize === 1 ? null : String(state.fontSize),
);
}, [state.fontSize]);
// RGAA 10 - Présentation : hauteur de ligne
useEffect(
() => cls(state.lineHeight, "a11y-line-height"),
[state.lineHeight],
);
// RGAA 10 - Présentation : espacement des lettres
useEffect(
() => cls(state.letterSpacing, "a11y-letter-spacing"),
[state.letterSpacing],
);
// RGAA 10 - Présentation : espacement des mots
useEffect(
() => cls(state.wordSpacing, "a11y-word-spacing"),
[state.wordSpacing],
);
// RGAA 10 - Police de caractères
useEffect(() => {
ROOT.setAttribute("data-a11y-font", state.fontFamily);
}, [state.fontFamily]);
// RGAA 13 - Consultation : mouvement / animation
useEffect(
() => cls(state.reduceMotion, "a11y-reduce-motion"),
[state.reduceMotion],
);
useEffect(
() => cls(state.pauseAnimations, "a11y-pause-animations"),
[state.pauseAnimations],
);
// Navigation clavier - focus visible renforcé
useEffect(
() => cls(state.focusVisible, "a11y-focus-visible"),
[state.focusVisible],
);
// RGAA 10 - Alignement du texte
useEffect(() => {
ROOT.setAttribute("data-a11y-align", state.textAlign);
}, [state.textAlign]);
// Mise en évidence des liens
useEffect(
() => cls(state.highlightLinks, "a11y-highlight-links"),
[state.highlightLinks],
);
// Guide de lecture (réticule horizontal)
useEffect(() => {
if (!state.readingGuide) {
document.getElementById("a11y-reading-guide")?.remove();
return;
}
let guide = document.getElementById("a11y-reading-guide");
if (!guide) {
guide = document.createElement("div");
guide.id = "a11y-reading-guide";
guide.setAttribute("aria-hidden", "true");
document.body.appendChild(guide);
}
const onMove = (e: MouseEvent) => {
if (guide) guide.style.top = `${e.clientY}px`;
};
document.addEventListener("mousemove", onMove);
return () => {
document.removeEventListener("mousemove", onMove);
guide?.remove();
};
}, [state.readingGuide]);
// Masque de lecture (assombrit tout sauf la zone courante)
useEffect(() => {
if (!state.readingMask) {
document.getElementById("a11y-reading-mask-top")?.remove();
document.getElementById("a11y-reading-mask-bottom")?.remove();
return;
}
function makeMask(id: string) {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("div");
el.id = id;
el.setAttribute("aria-hidden", "true");
document.body.appendChild(el);
}
return el;
}
const top = makeMask("a11y-reading-mask-top");
const bottom = makeMask("a11y-reading-mask-bottom");
const STRIP = 80;
const onMove = (e: MouseEvent) => {
const y = e.clientY;
top.style.height = `${Math.max(0, y - STRIP)}px`;
bottom.style.top = `${y + STRIP}px`;
};
document.addEventListener("mousemove", onMove);
return () => {
document.removeEventListener("mousemove", onMove);
top.remove();
bottom.remove();
};
}, [state.readingMask]);
// Synchro prefers-reduced-motion OS avec l'état initial (lecture seule)
const syncedMotion = useRef(false);
useEffect(() => {
if (syncedMotion.current) return;
syncedMotion.current = true;
// pas d'écriture automatique : laisser l'utilisateur choisir
}, []);
}