@intside/accessibility 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useState } from 'react';
4
+ import { AccessibilityModal } from './AccessibilityModal';
5
+ import { ensureA11yStyles, loadA11y, applyA11yClasses } from './engine';
6
+
7
+ export type FabPosition = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
8
+
9
+ export interface AccessibilityFabProps {
10
+ /** Coin d'ancrage du bouton flottant (par défaut « bottom-left »). */
11
+ position?: FabPosition;
12
+ /** Texte du bouton / libellé accessible (par défaut « Accessibilité »). */
13
+ label?: string;
14
+ /** Masque le libellé : bouton rond, icône seule. Activé par défaut. Passez `hideLabel={false}` pour la pilule avec texte. */
15
+ hideLabel?: boolean;
16
+ /** Décalage par rapport aux bords, en px (par défaut 22). */
17
+ offset?: number;
18
+ /** Classe CSS optionnelle : si fournie, remplace entièrement le style par défaut. */
19
+ className?: string;
20
+ /** Style inline additionnel (fusionné avec le style par défaut). */
21
+ style?: React.CSSProperties;
22
+ }
23
+
24
+ const POS: Record<FabPosition, (o: number) => React.CSSProperties> = {
25
+ 'bottom-left': (o) => ({ bottom: o, left: o }),
26
+ 'bottom-right': (o) => ({ bottom: o, right: o }),
27
+ 'top-left': (o) => ({ top: o, left: o }),
28
+ 'top-right': (o) => ({ top: o, right: o }),
29
+ };
30
+
31
+ /**
32
+ * Bouton flottant (FAB) qui ouvre le module d'accessibilité.
33
+ * Alternative au déclencheur inline `AccessibilityButton` (lien dans une top-bar).
34
+ * Par défaut : pastille ronde (icône seule) ancrée en bas à gauche, couleur
35
+ * `--a11y-accent`, avec un léger agrandissement au survol.
36
+ */
37
+ export function AccessibilityFab({
38
+ position = 'bottom-left',
39
+ label = 'Accessibilité',
40
+ hideLabel = true,
41
+ offset = 22,
42
+ className,
43
+ style,
44
+ }: AccessibilityFabProps) {
45
+ const [open, setOpen] = useState(false);
46
+ const [hover, setHover] = useState(false);
47
+
48
+ useEffect(() => {
49
+ ensureA11yStyles();
50
+ applyA11yClasses(loadA11y());
51
+ }, []);
52
+
53
+ const defaultStyle: React.CSSProperties = {
54
+ position: 'fixed',
55
+ zIndex: 1099,
56
+ ...POS[position](offset),
57
+ display: 'inline-flex',
58
+ alignItems: 'center',
59
+ justifyContent: 'center',
60
+ gap: 8,
61
+ height: 48,
62
+ padding: hideLabel ? 0 : '0 18px',
63
+ width: hideLabel ? 48 : undefined,
64
+ border: 'none',
65
+ borderRadius: 99,
66
+ background: 'var(--a11y-accent, #006828)',
67
+ color: '#fff',
68
+ fontSize: 14,
69
+ fontWeight: 700,
70
+ cursor: 'pointer',
71
+ boxShadow: '0 8px 24px -6px rgba(10,25,48,.45)',
72
+ transition: 'transform .15s ease, box-shadow .15s ease',
73
+ transform: hover ? 'scale(1.05)' : 'scale(1)',
74
+ };
75
+
76
+ return (
77
+ <>
78
+ <button
79
+ type="button"
80
+ onClick={() => setOpen(true)}
81
+ onMouseEnter={() => setHover(true)}
82
+ onMouseLeave={() => setHover(false)}
83
+ className={className}
84
+ aria-haspopup="dialog"
85
+ aria-label={hideLabel ? label : undefined}
86
+ style={className ? style : { ...defaultStyle, ...style }}
87
+ >
88
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={{ width: 32, height: 32 }}>
89
+ <circle cx="12" cy="5" r="1" />
90
+ <path d="m9 20 3-6 3 6" />
91
+ <path d="m6 8 6 2 6-2" />
92
+ <path d="M12 10v4" />
93
+ </svg>
94
+ {!hideLabel && <span>{label}</span>}
95
+ </button>
96
+ {open && <AccessibilityModal onClose={() => setOpen(false)} />}
97
+ </>
98
+ );
99
+ }
@@ -0,0 +1,328 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useRef, useState } from 'react';
4
+ import {
5
+ ACC_STANDARDS,
6
+ ACC_PROFILES,
7
+ ACC_SETTINGS,
8
+ A11Y_TOKENS,
9
+ loadA11y,
10
+ saveA11y,
11
+ applyA11yClasses,
12
+ ensureA11yStyles,
13
+ type A11yState,
14
+ type A11yProfile,
15
+ type A11yStandard,
16
+ type A11ySettingItem,
17
+ } from './engine';
18
+ import { A11YIcon } from './icons';
19
+
20
+ /* ---- Détection viewport étroit (< 600px) ---- */
21
+ function useNarrow(maxWidth = 600) {
22
+ const [narrow, setNarrow] = useState(false);
23
+ useEffect(() => {
24
+ const mql = window.matchMedia(`(max-width: ${maxWidth}px)`);
25
+ const update = () => setNarrow(mql.matches);
26
+ update();
27
+ mql.addEventListener('change', update);
28
+ return () => mql.removeEventListener('change', update);
29
+ }, [maxWidth]);
30
+ return narrow;
31
+ }
32
+
33
+ /* ---- Toggle ---- */
34
+ function Toggle({ value, onChange, id }: { value: boolean; onChange: (v: boolean) => void; id?: string }) {
35
+ return (
36
+ <button
37
+ role="switch"
38
+ aria-checked={value}
39
+ id={id}
40
+ onClick={() => onChange(!value)}
41
+ style={{
42
+ width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
43
+ background: value ? 'var(--accent)' : 'var(--line-strong)',
44
+ position: 'relative', flexShrink: 0, transition: '.2s',
45
+ }}
46
+ >
47
+ <span
48
+ style={{
49
+ position: 'absolute', top: 3, left: value ? 23 : 3,
50
+ width: 18, height: 18, borderRadius: '50%',
51
+ background: '#fff', transition: 'left .18s',
52
+ boxShadow: '0 1px 3px rgba(0,0,0,.25)',
53
+ }}
54
+ />
55
+ </button>
56
+ );
57
+ }
58
+
59
+ /* ---- Standard card ---- */
60
+ function StandardCard({ prof, onExpand, expanded, narrow }: { prof: A11yStandard; onExpand: (id: string | null) => void; expanded: string | null; narrow: boolean }) {
61
+ return (
62
+ <div style={{ border: '1px solid var(--line)', borderRadius: 'var(--r-md)', overflow: 'hidden', background: 'var(--surface)' }}>
63
+ <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '16px 18px' }}>
64
+ <div style={{ width: 42, height: 42, borderRadius: 11, background: 'color-mix(in srgb, var(--navy) 10%, transparent)', color: 'var(--navy)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
65
+ <A11YIcon name={prof.icon} size={20} />
66
+ </div>
67
+ <div style={{ flex: 1, minWidth: 0 }}>
68
+ <div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>{prof.label}</div>
69
+ <div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 4, fontSize: 12.5, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 99, padding: '3px 10px' }}>
70
+ <A11YIcon name="shieldChk" size={13} /> {prof.headline}
71
+ </div>
72
+ </div>
73
+ <button
74
+ onClick={() => onExpand(prof.id === expanded ? null : prof.id)}
75
+ style={{ background: 'var(--surface-2)', border: '1px solid var(--line)', borderRadius: 8, padding: '7px 12px', cursor: 'pointer', fontSize: 13, fontWeight: 600, color: 'var(--muted)', whiteSpace: 'nowrap', flexShrink: 0 }}
76
+ >
77
+ {narrow ? 'Voir' : prof.cta}
78
+ </button>
79
+ </div>
80
+ {expanded === prof.id && (
81
+ <div style={{ borderTop: '1px solid var(--line)', padding: '14px 18px', background: 'var(--surface-2)' }}>
82
+ <ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
83
+ {prof.details.map((d, i) => (
84
+ <li key={i} style={{ display: 'flex', gap: 10, fontSize: 14, color: 'var(--muted)', alignItems: 'flex-start' }}>
85
+ <span style={{ color: 'var(--accent)', marginTop: 2 }}><A11YIcon name="check" size={14} /></span>
86
+ {d}
87
+ </li>
88
+ ))}
89
+ </ul>
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ /* ---- Activatable profile card ---- */
97
+ function ProfileCard({ prof, active, onToggle }: { prof: A11yProfile; active: boolean; onToggle: (id: string) => void }) {
98
+ const [open, setOpen] = useState(false);
99
+ return (
100
+ <div style={{ border: '1px solid ' + (active ? 'var(--accent)' : 'var(--line)'), borderRadius: 'var(--r-md)', overflow: 'hidden', background: active ? 'var(--accent-soft)' : 'var(--surface)', transition: '.2s' }}>
101
+ <div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '14px 16px' }}>
102
+ <div style={{ width: 40, height: 40, borderRadius: 10, background: active ? 'var(--accent)' : 'var(--surface-2)', color: active ? '#fff' : 'var(--muted)', display: 'grid', placeItems: 'center', flexShrink: 0, transition: '.2s' }}>
103
+ <A11YIcon name={prof.icon} size={18} />
104
+ </div>
105
+ <div style={{ flex: 1, minWidth: 0 }}>
106
+ <div style={{ fontWeight: 700, fontSize: 14.5, color: 'var(--text)' }}>{prof.label}</div>
107
+ <div style={{ fontSize: 13, color: 'var(--muted)', marginTop: 2, lineHeight: 1.4 }}>{prof.desc}</div>
108
+ </div>
109
+ <Toggle value={active} onChange={() => onToggle(prof.id)} id={'prof_' + prof.id} />
110
+ </div>
111
+ {active && (
112
+ <div style={{ borderTop: '1px dashed var(--line)', padding: '10px 16px', background: 'rgba(0,104,40,.04)' }}>
113
+ <button onClick={() => setOpen(!open)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, fontWeight: 600, color: 'var(--accent)', padding: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
114
+ {open ? 'Masquer' : 'Ce profil améliore…'} {open ? '▲' : '▼'}
115
+ </button>
116
+ {open && (
117
+ <ul style={{ margin: '8px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 6 }}>
118
+ {prof.improves.map((d, i) => (
119
+ <li key={i} style={{ display: 'flex', gap: 9, fontSize: 13, color: 'var(--muted)', alignItems: 'flex-start' }}>
120
+ <span style={{ color: 'var(--accent)' }}><A11YIcon name="check" size={13} /></span>{d}
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ )}
125
+ </div>
126
+ )}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /* ---- Settings row ---- */
132
+ function SettingRow({ item, value, onChange }: { item: A11ySettingItem; value: string | boolean | undefined; onChange: (id: string, v: string | boolean) => void }) {
133
+ const id = 's_' + item.id;
134
+ return (
135
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, padding: '12px 0' }}>
136
+ <label htmlFor={id} style={{ fontSize: 14.5, fontWeight: 600, color: 'var(--text)', cursor: 'pointer' }}>{item.label}</label>
137
+ {item.type === 'toggle' && <Toggle value={!!value} onChange={(v) => onChange(item.id, v)} id={id} />}
138
+ {item.type === 'select' && (
139
+ <select
140
+ id={id}
141
+ value={(value as string) || (item.default as string)}
142
+ onChange={(e) => onChange(item.id, e.target.value)}
143
+ style={{ border: '1px solid var(--line)', borderRadius: 8, padding: '7px 12px', fontSize: 13.5, fontWeight: 600, color: 'var(--text)', background: 'var(--surface)', cursor: 'pointer', outline: 'none' }}
144
+ >
145
+ {item.options!.map((o) => <option key={o}>{o}</option>)}
146
+ </select>
147
+ )}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ export interface AccessibilityModalProps {
153
+ onClose: () => void;
154
+ }
155
+
156
+ /* ================================================================
157
+ MODAL PRINCIPAL
158
+ ================================================================ */
159
+ export function AccessibilityModal({ onClose }: AccessibilityModalProps) {
160
+ const [tab, setTab] = useState(0);
161
+ const [state, setState] = useState<A11yState>(() => loadA11y());
162
+ const [expandedStd, setExpandedStd] = useState<string | null>(null);
163
+ const [announce, setAnnounce] = useState('');
164
+ const narrow = useNarrow();
165
+ const firstRef = useRef<HTMLButtonElement>(null);
166
+
167
+ useEffect(() => {
168
+ ensureA11yStyles();
169
+ applyA11yClasses(loadA11y());
170
+ }, []);
171
+
172
+ useEffect(() => {
173
+ const t = setTimeout(() => firstRef.current?.focus(), 50);
174
+ return () => clearTimeout(t);
175
+ }, []);
176
+
177
+ useEffect(() => {
178
+ const fn = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
179
+ document.addEventListener('keydown', fn);
180
+ return () => document.removeEventListener('keydown', fn);
181
+ }, [onClose]);
182
+
183
+ function updateState(next: A11yState) {
184
+ setState(next);
185
+ saveA11y(next);
186
+ applyA11yClasses(next);
187
+ }
188
+
189
+ function toggleProfile(id: string) {
190
+ const prof = ACC_PROFILES.find((p) => p.id === id);
191
+ const wasActive = !!state.profiles[id];
192
+ updateState({ ...state, profiles: { ...state.profiles, [id]: !wasActive } });
193
+ setAnnounce((!wasActive ? 'Profil activé : ' : 'Profil désactivé : ') + (prof ? prof.label : id));
194
+ }
195
+
196
+ function updateSetting(key: string, val: string | boolean) {
197
+ updateState({ ...state, settings: { ...state.settings, [key]: val } });
198
+ setAnnounce('Réglage mis à jour : ' + key);
199
+ }
200
+
201
+ function reset() {
202
+ updateState({ profiles: {}, settings: {} });
203
+ setAnnounce('Préférences réinitialisées. Les standards de base restent actifs.');
204
+ }
205
+
206
+ const activeCount =
207
+ Object.values(state.profiles).filter(Boolean).length +
208
+ Object.values(state.settings).filter((v) => v && v !== 'Normale').length;
209
+
210
+ return (
211
+ <div
212
+ style={{
213
+ ...(A11Y_TOKENS as unknown as React.CSSProperties),
214
+ position: 'fixed', inset: 0, zIndex: 1100,
215
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
216
+ background: 'rgba(10,25,48,.6)', backdropFilter: 'blur(8px)',
217
+ }}
218
+ onClick={(e) => e.target === e.currentTarget && onClose()}
219
+ role="dialog"
220
+ aria-modal="true"
221
+ aria-label="Module d'accessibilité"
222
+ >
223
+ <div aria-live="polite" aria-atomic="true" style={{ position: 'absolute', left: -9999, top: 'auto', width: 1, height: 1, overflow: 'hidden' }}>
224
+ {announce}
225
+ </div>
226
+
227
+ <div style={{ background: 'var(--bg-2)', borderRadius: 'var(--r-xl)', boxShadow: '0 40px 100px -30px rgba(10,25,48,.55)', width: 'min(680px, 100%)', maxHeight: '90vh', display: 'flex', flexDirection: 'column', border: '1px solid var(--line)' }}>
228
+ {/* Header */}
229
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px 16px', borderBottom: '1px solid var(--line)', flexShrink: 0 }}>
230
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
231
+ <div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--accent-soft)', color: 'var(--accent)', display: 'grid', placeItems: 'center' }}>
232
+ <A11YIcon name="shieldChk" size={18} />
233
+ </div>
234
+ <div>
235
+ <h2 style={{ fontSize: 18, fontWeight: 800, letterSpacing: '-.01em', margin: 0, color: 'var(--text)' }}>Accessibilité numérique</h2>
236
+ {activeCount > 0 && (
237
+ <span style={{ fontSize: 12, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-soft)', borderRadius: 99, padding: '2px 8px', marginTop: 3, display: 'inline-block' }}>
238
+ {activeCount} préférence{activeCount > 1 ? 's' : ''} active{activeCount > 1 ? 's' : ''}
239
+ </span>
240
+ )}
241
+ </div>
242
+ </div>
243
+ <div style={{ display: 'flex', gap: 8 }}>
244
+ {activeCount > 0 && (
245
+ <button onClick={reset} title="Réinitialiser" aria-label="Réinitialiser" style={{ height: 36, width: narrow ? 36 : undefined, borderRadius: 9, border: '1px solid var(--line)', background: 'var(--surface)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: narrow ? 0 : 7, padding: narrow ? 0 : '0 12px', fontSize: 13, fontWeight: 600, color: 'var(--muted)' }}>
246
+ <A11YIcon name="reset" size={14} />{!narrow && ' Réinitialiser'}
247
+ </button>
248
+ )}
249
+ <button ref={firstRef} onClick={onClose} aria-label="Fermer" style={{ width: 36, height: 36, borderRadius: 9, border: '1px solid var(--line)', background: 'var(--surface)', cursor: 'pointer', display: 'grid', placeItems: 'center', color: 'var(--muted)' }}>
250
+ <A11YIcon name="x" size={16} />
251
+ </button>
252
+ </div>
253
+ </div>
254
+
255
+ {/* Tabs */}
256
+ <div style={{ display: 'flex', borderBottom: '1px solid var(--line)', flexShrink: 0 }}>
257
+ {["Profils d'assistance", 'Réglages personnalisés'].map((t, i) => (
258
+ <button
259
+ key={i}
260
+ role="tab"
261
+ aria-selected={tab === i}
262
+ onClick={() => setTab(i)}
263
+ style={{ flex: 1, border: 'none', background: 'none', cursor: 'pointer', padding: '12px 20px', fontSize: 14.5, fontWeight: 700, color: tab === i ? 'var(--accent)' : 'var(--muted)', borderBottom: tab === i ? '2.5px solid var(--accent)' : '2.5px solid transparent', transition: '.15s', marginBottom: -1 }}
264
+ >
265
+ {t}
266
+ </button>
267
+ ))}
268
+ </div>
269
+
270
+ {/* Content */}
271
+ <div style={{ overflowY: 'auto', flex: 1, padding: '20px 24px' }} role="tabpanel">
272
+ {tab === 0 && (
273
+ <div>
274
+ <p style={{ fontSize: 13.5, color: 'var(--muted)', margin: '0 0 18px', lineHeight: 1.6 }}>
275
+ Certains profils sont actifs par défaut dans le code — ils ne sont pas des options, mais des standards d'accessibilité intégrés nativement.
276
+ </p>
277
+ <div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: 'var(--muted-2)', marginBottom: 10 }}>STANDARDS DE BASE · TOUJOURS ACTIFS</div>
278
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 24 }}>
279
+ {ACC_STANDARDS.map((p) => (
280
+ <StandardCard key={p.id} prof={p} expanded={expandedStd} onExpand={setExpandedStd} narrow={narrow} />
281
+ ))}
282
+ </div>
283
+ <div style={{ fontSize: 12, fontWeight: 700, letterSpacing: '2px', textTransform: 'uppercase', color: 'var(--muted-2)', marginBottom: 10 }}>PRÉFÉRENCES VISUELLES & COGNITIVES</div>
284
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
285
+ {ACC_PROFILES.map((p) => (
286
+ <ProfileCard key={p.id} prof={p} active={!!state.profiles[p.id]} onToggle={toggleProfile} />
287
+ ))}
288
+ </div>
289
+ </div>
290
+ )}
291
+
292
+ {tab === 1 && (
293
+ <div>
294
+ <p style={{ fontSize: 13.5, color: 'var(--muted)', margin: '0 0 18px', lineHeight: 1.6 }}>
295
+ Ajustez chaque paramètre individuellement. Vos choix manuels ont priorité sur les profils.
296
+ </p>
297
+ {Object.entries(ACC_SETTINGS).map(([catKey, cat]) => (
298
+ <div key={catKey} style={{ marginBottom: 26 }}>
299
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
300
+ <A11YIcon name={cat.icon} size={16} />
301
+ <span style={{ fontSize: 13, fontWeight: 700, letterSpacing: '1.5px', textTransform: 'uppercase', color: 'var(--muted-2)' }}>{cat.label}</span>
302
+ </div>
303
+ <div style={{ background: 'var(--surface)', border: '1px solid var(--line)', borderRadius: 'var(--r-md)', padding: '0 16px' }}>
304
+ {cat.items.map((item, i) => (
305
+ <div key={item.id} style={{ borderBottom: i < cat.items.length - 1 ? '1px solid var(--line)' : 'none' }}>
306
+ <SettingRow item={item} value={state.settings[item.id]} onChange={updateSetting} />
307
+ </div>
308
+ ))}
309
+ </div>
310
+ </div>
311
+ ))}
312
+ </div>
313
+ )}
314
+ </div>
315
+
316
+ {/* Footer */}
317
+ <div style={{ borderTop: '1px solid var(--line)', padding: '14px 24px', flexShrink: 0, background: 'var(--surface-2)', borderRadius: '0 0 var(--r-xl) var(--r-xl)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
318
+ <span style={{ fontSize: 13, color: 'var(--muted-2)', display: 'flex', alignItems: 'center', gap: 8 }}>
319
+ <A11YIcon name="shieldChk" size={14} /> Conformité RGAA 4.1 · Partiellement conforme
320
+ </span>
321
+ <a href="mailto:accessibilite@example.org" style={{ fontSize: 13, fontWeight: 700, color: 'var(--accent)', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
322
+ <A11YIcon name="announce" size={14} /> Signaler un problème
323
+ </a>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ );
328
+ }