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>
);
}