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
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+9
View File
@@ -0,0 +1,9 @@
src/
public/
index.html
vite.config.ts
tsconfig*.json
eslint.config.js
*.md
.gitignore
node_modules/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jessy DAVID
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+126
View File
@@ -0,0 +1,126 @@
# accessibility-widget
Widget d'accessibilité React aligné **RGAA 4.1 / WCAG 2.1 AA**, conçu pour rendre les sites et services numériques accessibles à toutes et à tous.
## Installation
```bash
npm install accessibility-widget
```
## Usage
```tsx
import { AccessibilityWidget } from "accessibility-widget";
import "accessibility-widget/dist/accessibility-widget.css";
export default function App() {
return (
<>
<AccessibilityWidget position="bottom-right" lang="fr" />
</>
);
}
```
## Props
| Prop | Type | Défaut | Description |
| ------------ | -------------------------------------------------------------------- | --------------------- | ---------------------------------------------------- |
| `position` | `"bottom-right"` \| `"bottom-left"` \| `"top-right"` \| `"top-left"` | `"bottom-right"` | Position du bouton flottant |
| `lang` | `"fr"` \| `"en"` | `"fr"` | Langue de l'interface |
| `storageKey` | `string` | `"a11y-widget-prefs"` | Clé localStorage pour la persistance des préférences |
## Fonctionnalités
### RGAA 3 - Couleurs
- Contraste élevé
- Inversion des couleurs
### RGAA 10 - Présentation de l'information
- Taille du texte (×1 / ×1.2 / ×1.4 / ×1.6)
- Hauteur de ligne augmentée
- Espacement des lettres
- Espacement des mots
- Alignement du texte à gauche
- Police lisible (serif) ou dyslexique (OpenDyslexic)
### RGAA 13 - Consultation
- Réduction des animations
- Mise en pause des animations
### Navigation au clavier
- Indicateur de focus renforcé
- Mise en évidence des liens
### Aide à la lecture
- Guide de lecture (réticule horizontal)
- Masque de lecture
## Accessibilité du widget lui-même
Le widget est lui-même conforme RGAA :
- Bouton trigger avec `aria-label`, `aria-expanded`, `aria-haspopup="dialog"`
- Dialog avec `role="dialog"`, `aria-modal`, `aria-labelledby`
- Focus-trap dans le panneau (Tab / Shift+Tab)
- Fermeture par Échap avec retour du focus sur le trigger
- Switches avec `role="switch"`, `aria-checked`, `aria-labelledby`
- Annonces des changements d'état via `aria-live="polite"`
- Compatible NVDA + Firefox, JAWS + Firefox, VoiceOver + Safari, TalkBack + Chrome
## Environnements de test recommandés (RGAA)
| Environnement | OS |
| ------------------------------------- | ----------- |
| NVDA (dernière version) + Firefox | Windows |
| JAWS (version précédente) + Firefox | Windows |
| VoiceOver (dernière version) + Safari | macOS / iOS |
| TalkBack (dernière version) + Chrome | Android |
## Usage avancé
### Usage headless (hooks uniquement)
```tsx
import { useA11yStore, useA11yEffects } from "accessibility-widget";
function MonWidget() {
const { state, dispatch } = useA11yStore("mes-prefs");
useA11yEffects(state);
return (
<button onClick={() => dispatch({ type: "TOGGLE_HIGH_CONTRAST" })}>
Contraste élevé
</button>
);
}
```
### Personnalisation CSS
Toutes les couleurs et dimensions sont en custom properties CSS :
```css
:root {
--a11y-color-bg: #1a1a2e; /* couleur du bouton et header */
--a11y-color-accent: #005fcc; /* couleur d'accentuation */
--a11y-panel-width: 340px; /* largeur du panneau */
--a11y-radius: 12px; /* rayon des bordures */
}
```
## Référence
- [RGAA 4.1 - Critères et tests](https://accessibilite.numerique.gouv.fr/methode/criteres-et-tests/)
- [RGAA 4.1 - Environnements de test](https://accessibilite.numerique.gouv.fr/methode/environnement-de-test/)
- [WCAG 2.1](https://www.w3.org/TR/WCAG21/)
## Licence
MIT © [Jessy DAVID](https://jessy-david.dev)
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>accessibility-widget</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
{
"name": "accessibility-widget",
"version": "1.0.0",
"description": "Widget d'accessibilité",
"type": "module",
"main": "./dist/accessibility-widget.umd.cjs",
"module": "./dist/accessibility-widget.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/accessibility-widget.js",
"require": "./dist/accessibility-widget.umd.cjs",
"types": "./dist/index.d.ts"
},
"./dist/accessibility-widget.css": "./dist/accessibility-widget.css"
},
"files": [
"dist"
],
"keywords": [
"accessibility",
"accessibilite",
"a11y",
"rgaa",
"wcag",
"widget",
"react"
],
"author": "Jessy DAVID - https://jessy-david.dev",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/jessy-david-dev/accessibility-widget"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^4.0.0",
"vite-plugin-dts": "^4.5.4"
}
}
+2301
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+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
}, []);
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import dts from "vite-plugin-dts";
export default defineConfig({
plugins: [react(), dts()],
build: {
lib: {
entry: "src/index.ts",
name: "AccessibilityWidget",
fileName: "accessibility-widget",
formats: ["es", "umd"],
},
rollupOptions: {
external: ["react", "react-dom"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
},
});