@bauer-group/accessibility-widget 1.0.3

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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/accessibility-widget-core.min.js +6 -0
  4. package/dist/accessibility-widget-core.min.js.map +7 -0
  5. package/dist/accessibility-widget-loader.min.js +14 -0
  6. package/dist/accessibility-widget-loader.min.js.map +7 -0
  7. package/dist/accessibility-widget.min.css +7 -0
  8. package/dist/accessibility-widget.min.css.map +7 -0
  9. package/dist/config.d.ts +33 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/core.d.ts +31 -0
  12. package/dist/core.d.ts.map +1 -0
  13. package/dist/features/apply.d.ts +3 -0
  14. package/dist/features/apply.d.ts.map +1 -0
  15. package/dist/features/profile.d.ts +3 -0
  16. package/dist/features/profile.d.ts.map +1 -0
  17. package/dist/features/reading-guide.d.ts +2 -0
  18. package/dist/features/reading-guide.d.ts.map +1 -0
  19. package/dist/features/reading-mask.d.ts +2 -0
  20. package/dist/features/reading-mask.d.ts.map +1 -0
  21. package/dist/features/structure-nav.d.ts +2 -0
  22. package/dist/features/structure-nav.d.ts.map +1 -0
  23. package/dist/features/tts.d.ts +7 -0
  24. package/dist/features/tts.d.ts.map +1 -0
  25. package/dist/focus-trap.d.ts +6 -0
  26. package/dist/focus-trap.d.ts.map +1 -0
  27. package/dist/i18n/ar.d.ts +3 -0
  28. package/dist/i18n/ar.d.ts.map +1 -0
  29. package/dist/i18n/bn.d.ts +3 -0
  30. package/dist/i18n/bn.d.ts.map +1 -0
  31. package/dist/i18n/cs.d.ts +3 -0
  32. package/dist/i18n/cs.d.ts.map +1 -0
  33. package/dist/i18n/de.d.ts +3 -0
  34. package/dist/i18n/de.d.ts.map +1 -0
  35. package/dist/i18n/el.d.ts +3 -0
  36. package/dist/i18n/el.d.ts.map +1 -0
  37. package/dist/i18n/en.d.ts +3 -0
  38. package/dist/i18n/en.d.ts.map +1 -0
  39. package/dist/i18n/es.d.ts +3 -0
  40. package/dist/i18n/es.d.ts.map +1 -0
  41. package/dist/i18n/fa.d.ts +3 -0
  42. package/dist/i18n/fa.d.ts.map +1 -0
  43. package/dist/i18n/fr.d.ts +3 -0
  44. package/dist/i18n/fr.d.ts.map +1 -0
  45. package/dist/i18n/he.d.ts +3 -0
  46. package/dist/i18n/he.d.ts.map +1 -0
  47. package/dist/i18n/hi.d.ts +3 -0
  48. package/dist/i18n/hi.d.ts.map +1 -0
  49. package/dist/i18n/hu.d.ts +3 -0
  50. package/dist/i18n/hu.d.ts.map +1 -0
  51. package/dist/i18n/id.d.ts +3 -0
  52. package/dist/i18n/id.d.ts.map +1 -0
  53. package/dist/i18n/index.d.ts +6 -0
  54. package/dist/i18n/index.d.ts.map +1 -0
  55. package/dist/i18n/it.d.ts +3 -0
  56. package/dist/i18n/it.d.ts.map +1 -0
  57. package/dist/i18n/ja.d.ts +3 -0
  58. package/dist/i18n/ja.d.ts.map +1 -0
  59. package/dist/i18n/ko.d.ts +3 -0
  60. package/dist/i18n/ko.d.ts.map +1 -0
  61. package/dist/i18n/nl.d.ts +3 -0
  62. package/dist/i18n/nl.d.ts.map +1 -0
  63. package/dist/i18n/pl.d.ts +3 -0
  64. package/dist/i18n/pl.d.ts.map +1 -0
  65. package/dist/i18n/pt.d.ts +3 -0
  66. package/dist/i18n/pt.d.ts.map +1 -0
  67. package/dist/i18n/ro.d.ts +3 -0
  68. package/dist/i18n/ro.d.ts.map +1 -0
  69. package/dist/i18n/ru.d.ts +3 -0
  70. package/dist/i18n/ru.d.ts.map +1 -0
  71. package/dist/i18n/sv.d.ts +3 -0
  72. package/dist/i18n/sv.d.ts.map +1 -0
  73. package/dist/i18n/th.d.ts +3 -0
  74. package/dist/i18n/th.d.ts.map +1 -0
  75. package/dist/i18n/tr.d.ts +3 -0
  76. package/dist/i18n/tr.d.ts.map +1 -0
  77. package/dist/i18n/types.d.ts +44 -0
  78. package/dist/i18n/types.d.ts.map +1 -0
  79. package/dist/i18n/uk.d.ts +3 -0
  80. package/dist/i18n/uk.d.ts.map +1 -0
  81. package/dist/i18n/ur.d.ts +3 -0
  82. package/dist/i18n/ur.d.ts.map +1 -0
  83. package/dist/i18n/vi.d.ts +3 -0
  84. package/dist/i18n/vi.d.ts.map +1 -0
  85. package/dist/i18n/zh.d.ts +3 -0
  86. package/dist/i18n/zh.d.ts.map +1 -0
  87. package/dist/index.d.ts +10 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/integrity.json +9 -0
  90. package/dist/integrity.txt +12 -0
  91. package/dist/loader.d.ts +2 -0
  92. package/dist/loader.d.ts.map +1 -0
  93. package/dist/panel/drag.d.ts +34 -0
  94. package/dist/panel/drag.d.ts.map +1 -0
  95. package/dist/panel/panel.d.ts +23 -0
  96. package/dist/panel/panel.d.ts.map +1 -0
  97. package/dist/state.d.ts +18 -0
  98. package/dist/state.d.ts.map +1 -0
  99. package/dist/styles/critical.d.ts +16 -0
  100. package/dist/styles/critical.d.ts.map +1 -0
  101. package/dist/types/index.d.ts +3 -0
  102. package/dist/types/index.d.ts.map +1 -0
  103. package/dist/types/locale.d.ts +11 -0
  104. package/dist/types/locale.d.ts.map +1 -0
  105. package/dist/types/widget.d.ts +207 -0
  106. package/dist/types/widget.d.ts.map +1 -0
  107. package/dist/util/debug.d.ts +8 -0
  108. package/dist/util/debug.d.ts.map +1 -0
  109. package/dist/util/dom.d.ts +19 -0
  110. package/dist/util/dom.d.ts.map +1 -0
  111. package/dist/util/events.d.ts +38 -0
  112. package/dist/util/events.d.ts.map +1 -0
  113. package/dist/util/feature-icons.d.ts +33 -0
  114. package/dist/util/feature-icons.d.ts.map +1 -0
  115. package/dist/util/language-names.d.ts +12 -0
  116. package/dist/util/language-names.d.ts.map +1 -0
  117. package/dist/util/svg.d.ts +38 -0
  118. package/dist/util/svg.d.ts.map +1 -0
  119. package/package.json +67 -0
  120. package/src/config.ts +213 -0
  121. package/src/core.ts +173 -0
  122. package/src/features/apply.ts +37 -0
  123. package/src/features/profile.ts +18 -0
  124. package/src/features/reading-guide.ts +25 -0
  125. package/src/features/reading-mask.ts +25 -0
  126. package/src/features/structure-nav.ts +43 -0
  127. package/src/features/tts.ts +73 -0
  128. package/src/focus-trap.ts +35 -0
  129. package/src/globals.d.ts +63 -0
  130. package/src/i18n/ar.ts +48 -0
  131. package/src/i18n/bn.ts +48 -0
  132. package/src/i18n/cs.ts +48 -0
  133. package/src/i18n/de.ts +65 -0
  134. package/src/i18n/el.ts +48 -0
  135. package/src/i18n/en.ts +65 -0
  136. package/src/i18n/es.ts +48 -0
  137. package/src/i18n/fa.ts +48 -0
  138. package/src/i18n/fr.ts +48 -0
  139. package/src/i18n/he.ts +48 -0
  140. package/src/i18n/hi.ts +48 -0
  141. package/src/i18n/hu.ts +48 -0
  142. package/src/i18n/id.ts +48 -0
  143. package/src/i18n/index.ts +70 -0
  144. package/src/i18n/it.ts +48 -0
  145. package/src/i18n/ja.ts +48 -0
  146. package/src/i18n/ko.ts +48 -0
  147. package/src/i18n/nl.ts +48 -0
  148. package/src/i18n/pl.ts +48 -0
  149. package/src/i18n/pt.ts +48 -0
  150. package/src/i18n/ro.ts +48 -0
  151. package/src/i18n/ru.ts +48 -0
  152. package/src/i18n/sv.ts +48 -0
  153. package/src/i18n/th.ts +48 -0
  154. package/src/i18n/tr.ts +48 -0
  155. package/src/i18n/types.ts +36 -0
  156. package/src/i18n/uk.ts +48 -0
  157. package/src/i18n/ur.ts +48 -0
  158. package/src/i18n/vi.ts +48 -0
  159. package/src/i18n/zh.ts +48 -0
  160. package/src/index.ts +9 -0
  161. package/src/loader.ts +533 -0
  162. package/src/panel/drag.ts +210 -0
  163. package/src/panel/panel.ts +617 -0
  164. package/src/state.ts +91 -0
  165. package/src/styles/critical.ts +56 -0
  166. package/src/styles/widget.css +739 -0
  167. package/src/types/index.ts +2 -0
  168. package/src/types/locale.ts +55 -0
  169. package/src/types/widget.ts +300 -0
  170. package/src/util/debug.ts +12 -0
  171. package/src/util/dom.ts +68 -0
  172. package/src/util/events.ts +54 -0
  173. package/src/util/feature-icons.ts +163 -0
  174. package/src/util/language-names.ts +41 -0
  175. package/src/util/svg.ts +93 -0
package/src/loader.ts ADDED
@@ -0,0 +1,533 @@
1
+ /*!
2
+ * BAUER GROUP Accessibility Widget — Loader (IIFE, ~4 KB gzip)
3
+ * SPDX-License-Identifier: AGPL-3.0-only · © 2026 BAUER GROUP
4
+ * AGPL-3.0-only or commercial (info@bauer-group.com) — see LICENSE / LICENSING.md
5
+ *
6
+ * Responsibilities:
7
+ * 1. Read persisted preferences and apply them BEFORE first paint (no FOUAC).
8
+ * 2. Inject critical CSS for data-attribute based features.
9
+ * 3. Render the FAB (Floating Action Button).
10
+ * 4. Lazy-load the core bundle on first user intent (click / keyboard shortcut).
11
+ *
12
+ * Does NOT import from /shared — everything must be inlined to keep the bundle tiny.
13
+ */
14
+ import type { FeatureId, WidgetConfig, WidgetState, Locale } from './types/index.js';
15
+ import { buildCriticalCss } from './styles/critical.js';
16
+ import { onWidgetEvent, type WidgetEventMap, type WidgetEventName } from './util/events.js';
17
+
18
+ type CoreApi = NonNullable<Window['AccessibilityWidgetCore']>;
19
+
20
+ const userCfg: WidgetConfig = window.AccessibilityWidgetConfig ?? {};
21
+
22
+ const cfg = {
23
+ corePath: userCfg.corePath ?? '/accessibility-widget/accessibility-widget-core.min.js',
24
+ cssPath: userCfg.cssPath ?? '/accessibility-widget/accessibility-widget.min.css',
25
+ position: userCfg.position ?? 'bottom-right',
26
+ offset: {
27
+ x: Number.isFinite(userCfg.offset?.x) ? (userCfg.offset!.x as number) : 20,
28
+ y: Number.isFinite(userCfg.offset?.y) ? (userCfg.offset!.y as number) : 20,
29
+ },
30
+ zIndex: Number.isFinite(userCfg.zIndex) ? (userCfg.zIndex as number) : 2_147_483_646,
31
+ storageKey: userCfg.storageKey ?? 'accessibility-widget',
32
+ draggableFab: Boolean(userCfg.draggableFab),
33
+ respectReducedMotion: userCfg.respectReducedMotion ?? true,
34
+ primaryColor: userCfg.primaryColor ?? '#0058a3',
35
+ hideOnPrint: userCfg.hideOnPrint ?? true,
36
+ debug: Boolean(userCfg.debug),
37
+ locale: userCfg.locale ?? 'auto',
38
+ coreIntegrity: userCfg.coreIntegrity ?? null,
39
+ cssIntegrity: userCfg.cssIntegrity ?? null,
40
+ buttonLabel: userCfg.buttonLabel ?? null,
41
+ initialFeatures: userCfg.initialFeatures ?? null,
42
+ keyboardShortcut:
43
+ userCfg.keyboardShortcut === undefined ? 'ctrl+alt+a' : userCfg.keyboardShortcut,
44
+ };
45
+
46
+ interface ShortcutSpec {
47
+ alt: boolean;
48
+ ctrl: boolean;
49
+ shift: boolean;
50
+ meta: boolean;
51
+ key: string;
52
+ }
53
+
54
+ /**
55
+ * Parse an `alt+shift+a`-style shortcut spec into a matchable structure.
56
+ * Returns `null` for `false`, empty specs, duplicate non-modifier keys,
57
+ * or specs missing a non-modifier key. Tokens are case-insensitive.
58
+ */
59
+ function parseShortcut(spec: string | false | undefined): ShortcutSpec | null {
60
+ if (!spec) return null;
61
+ const out: ShortcutSpec = { alt: false, ctrl: false, shift: false, meta: false, key: '' };
62
+ for (const p of spec.toLowerCase().split('+')) {
63
+ if (p === 'alt' || p === 'ctrl' || p === 'shift' || p === 'meta') out[p] = true;
64
+ else if (p && !out.key) out.key = p;
65
+ else if (p) return null;
66
+ }
67
+ return out.key ? out : null;
68
+ }
69
+
70
+ const LABELS: Record<Locale, string> = {
71
+ de: 'Barrierefreiheit einstellen',
72
+ en: 'Accessibility settings',
73
+ fr: 'Réglages d’accessibilité',
74
+ es: 'Ajustes de accesibilidad',
75
+ it: 'Impostazioni di accessibilità',
76
+ pl: 'Ustawienia dostępności',
77
+ tr: 'Erişilebilirlik ayarları',
78
+ ar: 'إعدادات إمكانية الوصول',
79
+ zh: '无障碍设置',
80
+ hi: 'सुगम्यता सेटिंग्स',
81
+ pt: 'Definições de acessibilidade',
82
+ bn: 'অ্যাক্সেসযোগ্যতা সেটিংস',
83
+ ru: 'Настройки доступности',
84
+ ja: 'アクセシビリティ設定',
85
+ ko: '접근성 설정',
86
+ vi: 'Cài đặt trợ năng',
87
+ fa: 'تنظیمات دسترس‌پذیری',
88
+ ur: 'رسائی کی ترتیبات',
89
+ th: 'การตั้งค่าการเข้าถึง',
90
+ id: 'Pengaturan aksesibilitas',
91
+ he: 'הגדרות נגישות',
92
+ nl: 'Toegankelijkheidsinstellingen',
93
+ sv: 'Tillgänglighetsinställningar',
94
+ cs: 'Nastavení přístupnosti',
95
+ el: 'Ρυθμίσεις προσβασιμότητας',
96
+ hu: 'Akadálymentességi beállítások',
97
+ ro: 'Setări de accesibilitate',
98
+ uk: 'Налаштування доступності',
99
+ };
100
+
101
+ if (window.__accessibilityWidgetLoaded) {
102
+ // Idempotent: a second loader tag is a no-op.
103
+ } else {
104
+ window.__accessibilityWidgetLoaded = true;
105
+ if (document.readyState === 'loading') {
106
+ document.addEventListener('DOMContentLoaded', boot, { once: true });
107
+ } else {
108
+ boot();
109
+ }
110
+ }
111
+
112
+ function boot(): void {
113
+ injectCriticalCSS();
114
+ seedInitialFeaturesIfEmpty();
115
+ applyPersistedPreferences();
116
+ renderFab();
117
+ if (hasPersistedSettings()) {
118
+ // user has settings → warm core in idle time so next open is instant
119
+ const idle = (cb: () => void) => {
120
+ const w = window as unknown as {
121
+ requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number;
122
+ };
123
+ if (typeof w.requestIdleCallback === 'function') w.requestIdleCallback(cb, { timeout: 2000 });
124
+ else setTimeout(cb, 1000);
125
+ };
126
+ idle(() => {
127
+ loadCore().catch(() => {
128
+ /* silent — user can click FAB to retry */
129
+ });
130
+ });
131
+ }
132
+ }
133
+
134
+ function detectLocale(): Locale {
135
+ // Runtime override wins: setLocale() persists the user choice to
136
+ // state.locale and we must honor it here so the FAB aria-label matches
137
+ // the language the panel will render in.
138
+ const stateLocale = readState()?.locale;
139
+ if (typeof stateLocale === 'string' && isSupportedLocale(stateLocale)) return stateLocale;
140
+ if (cfg.locale !== 'auto') return cfg.locale as Locale;
141
+ const htmlLang = (document.documentElement.lang || '').toLowerCase();
142
+ const candidate = (htmlLang || (navigator.language ?? 'en').toLowerCase()).split(/[-_]/)[0];
143
+ return isSupportedLocale(candidate) ? candidate : 'de';
144
+ }
145
+
146
+ function isSupportedLocale(x: string | undefined): x is Locale {
147
+ return Boolean(x) && x! in LABELS;
148
+ }
149
+
150
+ function readState(): WidgetState | null {
151
+ try {
152
+ const raw = localStorage.getItem(cfg.storageKey);
153
+ return raw ? (JSON.parse(raw) as WidgetState) : null;
154
+ } catch (err) {
155
+ if (cfg.debug) console.warn('[aw] loader.readState failed', err);
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function hasPersistedSettings(): boolean {
161
+ const s = readState();
162
+ return Boolean(s?.features && Object.values(s.features).some(Boolean));
163
+ }
164
+
165
+ /**
166
+ * If the visitor has no persisted state yet and the host declared
167
+ * `initialFeatures`, write that as the initial state. Next-load
168
+ * behavior is identical to a normal returning user — once something
169
+ * is persisted, the persisted state takes over.
170
+ */
171
+ function seedInitialFeaturesIfEmpty(): void {
172
+ if (!cfg.initialFeatures) return;
173
+ let existing: string | null;
174
+ try {
175
+ existing = localStorage.getItem(cfg.storageKey);
176
+ } catch {
177
+ return;
178
+ }
179
+ if (existing) return;
180
+ const features: Record<string, boolean> = {};
181
+ for (const [id, on] of Object.entries(cfg.initialFeatures)) {
182
+ features[id] = Boolean(on);
183
+ }
184
+ const seed = {
185
+ features,
186
+ fontSizeLevel: 1,
187
+ lineHeightLevel: 1.5,
188
+ letterSpacingLevel: 0,
189
+ contrastMode: 'off',
190
+ };
191
+ try {
192
+ localStorage.setItem(cfg.storageKey, JSON.stringify(seed));
193
+ } catch (err) {
194
+ if (cfg.debug) console.warn('[aw] loader.seedInitialFeatures failed', err);
195
+ }
196
+ }
197
+
198
+ function applyPersistedPreferences(): void {
199
+ const s = readState();
200
+ if (!s?.features) return;
201
+ const html = document.documentElement;
202
+ html.setAttribute('data-aw-instant', '1');
203
+ if (typeof s.fontSizeLevel === 'number')
204
+ html.style.setProperty('--aw-font-scale', String(s.fontSizeLevel));
205
+ if (typeof s.lineHeightLevel === 'number')
206
+ html.style.setProperty('--aw-line-height', String(s.lineHeightLevel));
207
+ if (typeof s.letterSpacingLevel === 'number')
208
+ html.style.setProperty('--aw-letter-spacing', `${s.letterSpacingLevel}em`);
209
+ const f = s.features as Partial<Record<FeatureId, boolean>>;
210
+ if (f.contrast) html.setAttribute('data-aw-contrast', s.contrastMode ?? 'high');
211
+ if (f.grayscale) html.setAttribute('data-aw-grayscale', '1');
212
+ if (f.invertColors) html.setAttribute('data-aw-invert', '1');
213
+ if (f.dyslexiaFont) html.setAttribute('data-aw-dyslexia', '1');
214
+ if (f.highlightLinks) html.setAttribute('data-aw-highlight-links', '1');
215
+ if (f.pauseAnimations) html.setAttribute('data-aw-pause-animations', '1');
216
+ if (f.bigCursor) html.setAttribute('data-aw-big-cursor', '1');
217
+ if (f.focusOutline) html.setAttribute('data-aw-focus', '1');
218
+ }
219
+
220
+ function injectCriticalCSS(): void {
221
+ if (document.getElementById('aw-critical-css')) return;
222
+ const css = buildCriticalCss({
223
+ primaryColor: cfg.primaryColor,
224
+ hideOnPrint: cfg.hideOnPrint,
225
+ offsetX: cfg.offset.x,
226
+ offsetY: cfg.offset.y,
227
+ zIndex: cfg.zIndex,
228
+ });
229
+ const style = document.createElement('style');
230
+ style.id = 'aw-critical-css';
231
+ style.textContent = css;
232
+ (document.head ?? document.documentElement).appendChild(style);
233
+ }
234
+
235
+ function renderFab(): void {
236
+ const locale = detectLocale();
237
+ const label = cfg.buttonLabel ?? LABELS[locale];
238
+
239
+ const btn = document.createElement('button');
240
+ btn.type = 'button';
241
+ btn.className = `aw-fab aw-fab--${cfg.position}`;
242
+ btn.setAttribute('aria-label', label);
243
+ btn.setAttribute('aria-haspopup', 'dialog');
244
+ btn.setAttribute('aria-controls', 'aw-panel');
245
+ btn.setAttribute('aria-expanded', 'false');
246
+ btn.setAttribute('data-aw-fab', '1');
247
+
248
+ // Inline SVG person-icon — programmatic, no innerHTML.
249
+ const svgNs = 'http://www.w3.org/2000/svg';
250
+ const svg = document.createElementNS(svgNs, 'svg');
251
+ svg.setAttribute('viewBox', '0 0 24 24');
252
+ svg.setAttribute('aria-hidden', 'true');
253
+ svg.setAttribute('focusable', 'false');
254
+ const path = document.createElementNS(svgNs, 'path');
255
+ path.setAttribute(
256
+ 'd',
257
+ 'M12 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm-6 5 6 2 6-2 .5 1.8L13 10.2v3.2l3.2 7.4-1.7.8L12 15l-2.5 6.6-1.7-.8L11 13.4V10.2L5.5 8.8 6 7z',
258
+ );
259
+ path.setAttribute('fill', 'currentColor');
260
+ svg.appendChild(path);
261
+ btn.appendChild(svg);
262
+
263
+ btn.addEventListener('click', (e) => {
264
+ e.preventDefault();
265
+ // Toggle: if the panel is already open, second click closes it.
266
+ // We read aria-expanded off the FAB itself — it's the single source of
267
+ // truth, kept in sync with panel state via the widget's open/close events.
268
+ if (btn.getAttribute('aria-expanded') === 'true') {
269
+ window.AccessibilityWidgetCore?.close();
270
+ return;
271
+ }
272
+ btn.disabled = true;
273
+ btn.setAttribute('aria-busy', 'true');
274
+ loadCore()
275
+ .then((core) => {
276
+ btn.disabled = false;
277
+ btn.removeAttribute('aria-busy');
278
+ btn.setAttribute('aria-expanded', 'true');
279
+ core.open({
280
+ trigger: btn,
281
+ config: window.AccessibilityWidgetConfig,
282
+ });
283
+ })
284
+ .catch((err) => {
285
+ btn.disabled = false;
286
+ btn.removeAttribute('aria-busy');
287
+ if (cfg.debug) console.error('[aw] Core load failed:', err);
288
+ });
289
+ });
290
+
291
+ // Keep FAB's aria-expanded in sync when the panel closes by any route
292
+ // (ESC key, X button, programmatic close via AccessibilityWidget.close()).
293
+ // Without this, the toggle logic above would think the panel is still open.
294
+ onWidgetEvent('close', () => {
295
+ btn.setAttribute('aria-expanded', 'false');
296
+ });
297
+ onWidgetEvent('open', () => {
298
+ btn.setAttribute('aria-expanded', 'true');
299
+ });
300
+
301
+ const shortcut = parseShortcut(cfg.keyboardShortcut);
302
+ if (!shortcut && cfg.debug && cfg.keyboardShortcut !== false) {
303
+ console.warn(`[aw] invalid keyboardShortcut: ${cfg.keyboardShortcut}`);
304
+ }
305
+ if (shortcut) {
306
+ document.addEventListener('keydown', (e) => {
307
+ if (
308
+ e.altKey === shortcut.alt &&
309
+ e.ctrlKey === shortcut.ctrl &&
310
+ e.shiftKey === shortcut.shift &&
311
+ e.metaKey === shortcut.meta &&
312
+ e.key.toLowerCase() === shortcut.key
313
+ ) {
314
+ e.preventDefault();
315
+ btn.click();
316
+ }
317
+ });
318
+ }
319
+
320
+ if (cfg.draggableFab) {
321
+ attachDragHandlers(btn);
322
+ applyPersistedFabPosition(btn);
323
+ }
324
+
325
+ document.body.appendChild(btn);
326
+ }
327
+
328
+ // ─── FAB drag (opt-in via config.draggableFab) ─────────────────────
329
+ // Shift+Arrow moves in 10 px steps (WCAG 2.1.1 — no pointer-only interactions).
330
+
331
+ function applyPersistedFabPosition(btn: HTMLButtonElement): void {
332
+ const p = readState()?.fabPosition;
333
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) setFabPos(btn, p.x, p.y);
334
+ }
335
+
336
+ function setFabPos(btn: HTMLButtonElement, x: number, y: number): void {
337
+ const size = 48;
338
+ const cx = Math.min(Math.max(0, x), Math.max(0, window.innerWidth - size));
339
+ const cy = Math.min(Math.max(0, y), Math.max(0, window.innerHeight - size));
340
+ btn.setAttribute('data-aw-fab-pos', 'custom');
341
+ btn.style.setProperty('--aw-fab-x', cx + 'px');
342
+ btn.style.setProperty('--aw-fab-y', cy + 'px');
343
+ }
344
+
345
+ function persistFab(x: number, y: number): void {
346
+ try {
347
+ const raw = localStorage.getItem(cfg.storageKey);
348
+ const parsed = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
349
+ parsed.fabPosition = { x, y };
350
+ localStorage.setItem(cfg.storageKey, JSON.stringify(parsed));
351
+ } catch (err) {
352
+ if (cfg.debug) console.warn('[aw] persistFab failed', err);
353
+ }
354
+ }
355
+
356
+ function attachDragHandlers(btn: HTMLButtonElement): void {
357
+ btn.style.touchAction = 'none';
358
+ btn.style.cursor = 'grab';
359
+ let pid = -1;
360
+ let sx = 0;
361
+ let sy = 0;
362
+ let bx = 0;
363
+ let by = 0;
364
+ let moved = false;
365
+
366
+ btn.addEventListener('pointerdown', (e) => {
367
+ if (e.button !== 0 && e.pointerType !== 'touch') return;
368
+ pid = e.pointerId;
369
+ sx = e.clientX;
370
+ sy = e.clientY;
371
+ const r = btn.getBoundingClientRect();
372
+ bx = r.left;
373
+ by = r.top;
374
+ moved = false;
375
+ try {
376
+ btn.setPointerCapture(pid);
377
+ } catch {
378
+ /* noop */
379
+ }
380
+ });
381
+
382
+ btn.addEventListener('pointermove', (e) => {
383
+ if (pid !== e.pointerId) return;
384
+ const dx = e.clientX - sx;
385
+ const dy = e.clientY - sy;
386
+ if (!moved && Math.hypot(dx, dy) < 5) return;
387
+ moved = true;
388
+ e.preventDefault();
389
+ setFabPos(btn, bx + dx, by + dy);
390
+ });
391
+
392
+ const finish = (e: PointerEvent): void => {
393
+ if (pid !== e.pointerId) return;
394
+ try {
395
+ btn.releasePointerCapture(pid);
396
+ } catch {
397
+ /* noop */
398
+ }
399
+ pid = -1;
400
+ if (moved) {
401
+ const r = btn.getBoundingClientRect();
402
+ persistFab(r.left, r.top);
403
+ // Swallow the synthetic click so a drag doesn't open the panel.
404
+ btn.addEventListener(
405
+ 'click',
406
+ (ev) => {
407
+ ev.stopPropagation();
408
+ ev.preventDefault();
409
+ },
410
+ { capture: true, once: true },
411
+ );
412
+ }
413
+ };
414
+ btn.addEventListener('pointerup', finish);
415
+ btn.addEventListener('pointercancel', finish);
416
+
417
+ btn.addEventListener('keydown', (e) => {
418
+ if (!e.shiftKey) return;
419
+ const k = e.key;
420
+ const dx = k === 'ArrowLeft' ? -10 : k === 'ArrowRight' ? 10 : 0;
421
+ const dy = k === 'ArrowUp' ? -10 : k === 'ArrowDown' ? 10 : 0;
422
+ if (!dx && !dy) return;
423
+ e.preventDefault();
424
+ const r = btn.getBoundingClientRect();
425
+ setFabPos(btn, r.left + dx, r.top + dy);
426
+ const final = btn.getBoundingClientRect();
427
+ persistFab(final.left, final.top);
428
+ });
429
+ }
430
+
431
+ let corePromise: Promise<CoreApi> | null = null;
432
+ function loadCore(): Promise<CoreApi> {
433
+ if (corePromise) return corePromise;
434
+ if (cfg.cssPath && !document.querySelector('link[data-aw-css]')) {
435
+ const link = document.createElement('link');
436
+ link.rel = 'stylesheet';
437
+ link.href = cfg.cssPath;
438
+ link.setAttribute('data-aw-css', '1');
439
+ if (cfg.cssIntegrity) {
440
+ link.integrity = cfg.cssIntegrity;
441
+ link.crossOrigin = 'anonymous';
442
+ }
443
+ document.head.appendChild(link);
444
+ }
445
+ corePromise = new Promise<CoreApi>((resolve, reject) => {
446
+ const s = document.createElement('script');
447
+ s.src = cfg.corePath;
448
+ s.async = true;
449
+ s.defer = true;
450
+ if (cfg.coreIntegrity) {
451
+ s.integrity = cfg.coreIntegrity;
452
+ s.crossOrigin = 'anonymous';
453
+ }
454
+ s.onload = () => {
455
+ if (window.AccessibilityWidgetCore) resolve(window.AccessibilityWidgetCore);
456
+ else reject(new Error('Core loaded but AccessibilityWidgetCore is undefined'));
457
+ };
458
+ s.onerror = () => reject(new Error(`Failed to load ${cfg.corePath}`));
459
+ document.head.appendChild(s);
460
+ });
461
+ return corePromise;
462
+ }
463
+
464
+ const publicApi = {
465
+ open(opts?: Parameters<CoreApi['open']>[0]): Promise<void> {
466
+ return loadCore().then((c) => c.open(opts ?? {}));
467
+ },
468
+ close(): void {
469
+ window.AccessibilityWidgetCore?.close();
470
+ },
471
+ reset(): void {
472
+ try {
473
+ localStorage.removeItem(cfg.storageKey);
474
+ } catch (err) {
475
+ if (cfg.debug) console.warn('[aw] loader.reset clear failed', err);
476
+ }
477
+ location.reload();
478
+ },
479
+ set(id: string, value: unknown): Promise<void> {
480
+ return loadCore().then((c) => c.set(id, value));
481
+ },
482
+ applyProfile(id: string): Promise<boolean> {
483
+ return loadCore().then((c) => c.applyProfile(id));
484
+ },
485
+ setLocale(locale: string): Promise<boolean> {
486
+ return loadCore().then((c) => c.setLocale(locale));
487
+ },
488
+ /**
489
+ * Programmatically move the FAB to viewport-pixel coords (from top-left)
490
+ * or reset to the config-anchor via `null`. Works independently of
491
+ * `draggableFab` — the host is always allowed to set the position.
492
+ */
493
+ setPosition(pos: { x: number; y: number } | null): void {
494
+ const btn = document.querySelector<HTMLButtonElement>('[data-aw-fab]');
495
+ if (!btn) return;
496
+ if (pos === null) {
497
+ btn.removeAttribute('data-aw-fab-pos');
498
+ btn.style.removeProperty('--aw-fab-x');
499
+ btn.style.removeProperty('--aw-fab-y');
500
+ try {
501
+ const raw = localStorage.getItem(cfg.storageKey);
502
+ if (raw) {
503
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
504
+ delete parsed.fabPosition;
505
+ localStorage.setItem(cfg.storageKey, JSON.stringify(parsed));
506
+ }
507
+ } catch (err) {
508
+ if (cfg.debug) console.warn('[aw] setPosition(null) clear failed', err);
509
+ }
510
+ return;
511
+ }
512
+ if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) {
513
+ if (cfg.debug) console.warn('[aw] setPosition: x and y must be finite numbers');
514
+ return;
515
+ }
516
+ setFabPos(btn, pos.x, pos.y);
517
+ persistFab(pos.x, pos.y);
518
+ },
519
+ getState(): WidgetState | null {
520
+ return readState();
521
+ },
522
+ /**
523
+ * Subscribe to widget lifecycle events. Returns unsubscribe function.
524
+ * Events dispatch as CustomEvent on document — consumers can also use
525
+ * `document.addEventListener('accessibility-widget:<name>', …)` directly.
526
+ */
527
+ on<K extends WidgetEventName>(name: K, handler: (detail: WidgetEventMap[K]) => void): () => void {
528
+ return onWidgetEvent(name, handler);
529
+ },
530
+ version: __AW_VERSION__,
531
+ };
532
+
533
+ window.AccessibilityWidget = publicApi;