@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
@@ -0,0 +1,617 @@
1
+ import {
2
+ CONTRAST_MODES,
3
+ FEATURE_IDS,
4
+ PROFILE_IDS,
5
+ SUPPORTED_LOCALES,
6
+ isLocale,
7
+ isRtl,
8
+ type FeatureId,
9
+ type Locale,
10
+ type WidgetState,
11
+ } from '../types/index.js';
12
+ import { createFocusTrap, type FocusTrap } from '../focus-trap.js';
13
+ import { make } from '../util/dom.js';
14
+ import { buildIcon, ICON_CLOSE } from '../util/svg.js';
15
+ import {
16
+ FEATURE_ICONS,
17
+ ICON_CHECK,
18
+ ICON_CHEVRON,
19
+ ICON_GLOBE,
20
+ ICON_GRIP,
21
+ ICON_INFO,
22
+ ICON_MAXIMIZE,
23
+ ICON_RESET,
24
+ } from '../util/feature-icons.js';
25
+ import { LANGUAGE_NAMES } from '../util/language-names.js';
26
+ import { makeDraggable, type DraggableHandle } from './drag.js';
27
+ import { t, translations, type Translation } from '../i18n/index.js';
28
+ import { applyState } from '../features/apply.js';
29
+ import { applyProfile } from '../features/profile.js';
30
+ import { collectReadableText, ttsActive, ttsStart, ttsStop } from '../features/tts.js';
31
+ import { structureNavToggle } from '../features/structure-nav.js';
32
+ import { cycleStep, saveState, STEPS } from '../state.js';
33
+
34
+ interface CycleDescriptor {
35
+ readonly steps: readonly (number | string)[];
36
+ readonly index: number;
37
+ readonly max: number;
38
+ readonly label: string;
39
+ }
40
+
41
+ function indexIn<T extends number | string>(steps: readonly T[], value: T): number {
42
+ const i = (steps as readonly unknown[]).indexOf(value);
43
+ return i < 0 ? 0 : i;
44
+ }
45
+
46
+ function describeCycle(id: FeatureId, state: WidgetState, T: Translation): CycleDescriptor | null {
47
+ switch (id) {
48
+ case 'fontSize':
49
+ return {
50
+ steps: STEPS.fontSize,
51
+ index: state.features.fontSize ? indexIn(STEPS.fontSize, state.fontSizeLevel as never) : 0,
52
+ max: STEPS.fontSize.length - 1,
53
+ label: state.features.fontSize ? String(state.fontSizeLevel) : T.values.off,
54
+ };
55
+ case 'lineHeight':
56
+ return {
57
+ steps: STEPS.lineHeight,
58
+ index: state.features.lineHeight
59
+ ? indexIn(STEPS.lineHeight, state.lineHeightLevel as never)
60
+ : 0,
61
+ max: STEPS.lineHeight.length - 1,
62
+ label: state.features.lineHeight ? String(state.lineHeightLevel) : T.values.off,
63
+ };
64
+ case 'letterSpacing':
65
+ return {
66
+ steps: STEPS.letterSpacing,
67
+ index: state.features.letterSpacing
68
+ ? indexIn(STEPS.letterSpacing, state.letterSpacingLevel as never)
69
+ : 0,
70
+ max: STEPS.letterSpacing.length - 1,
71
+ label: state.features.letterSpacing ? String(state.letterSpacingLevel) : T.values.off,
72
+ };
73
+ case 'contrast':
74
+ return {
75
+ steps: STEPS.contrast,
76
+ index: indexIn(STEPS.contrast, state.contrastMode),
77
+ max: STEPS.contrast.length - 1,
78
+ label: T.contrastLabels[state.contrastMode],
79
+ };
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function featureDescription(id: FeatureId, T: Translation): string {
86
+ return T.featureDescriptions?.[id] ?? translations.en.featureDescriptions?.[id] ?? '';
87
+ }
88
+
89
+ function renderStageDots(currentIndex: number, max: number): HTMLElement {
90
+ const dots: HTMLElement[] = [];
91
+ for (let i = 0; i <= max; i += 1) {
92
+ dots.push(
93
+ make('span', {
94
+ class: 'aw-stage-dot' + (i <= currentIndex && currentIndex > 0 ? ' is-filled' : ''),
95
+ }),
96
+ );
97
+ }
98
+ return make('span', {
99
+ class: 'aw-stage-dots',
100
+ attrs: { 'aria-hidden': 'true' },
101
+ children: dots,
102
+ });
103
+ }
104
+ import type { ResolvedConfig } from '../config.js';
105
+
106
+ interface PanelContext {
107
+ config: ResolvedConfig;
108
+ locale: Locale;
109
+ state: WidgetState;
110
+ statementUrl?: string;
111
+ onClose: () => void;
112
+ onStateChange: (s: WidgetState) => void;
113
+ }
114
+
115
+ export interface PanelHandle {
116
+ root: HTMLDivElement;
117
+ destroy(): void;
118
+ rerender(): void;
119
+ /**
120
+ * Swap the active locale while the panel stays open. Silently no-ops
121
+ * when the locale is already active or not supported.
122
+ */
123
+ setLocale(next: Locale): void;
124
+ }
125
+
126
+ export function openPanel(ctx: PanelContext): PanelHandle {
127
+ let locale = ctx.locale;
128
+ let T = t(locale);
129
+ let state = ctx.state;
130
+ let liveEl: HTMLDivElement | null = null;
131
+ let trap: FocusTrap | null = null;
132
+ let drag: DraggableHandle | null = null;
133
+
134
+ const root = make('div', {
135
+ class: `aw-panel aw-panel--${ctx.config.position}`,
136
+ attrs: {
137
+ id: 'aw-panel',
138
+ role: 'dialog',
139
+ 'aria-modal': 'true',
140
+ 'aria-labelledby': 'aw-panel-title',
141
+ 'data-aw-panel': '1',
142
+ dir: isRtl(locale) ? 'rtl' : 'ltr',
143
+ lang: locale,
144
+ },
145
+ });
146
+
147
+ function applyLocaleAttrs(): void {
148
+ root.setAttribute('dir', isRtl(locale) ? 'rtl' : 'ltr');
149
+ root.setAttribute('lang', locale);
150
+ }
151
+
152
+ function applyOversized(): void {
153
+ root.classList.toggle('aw-panel--xl', Boolean(state.oversized));
154
+ }
155
+ applyOversized();
156
+
157
+ function announce(text: string): void {
158
+ if (liveEl) liveEl.textContent = text;
159
+ }
160
+
161
+ function commit(next: WidgetState, message?: string): void {
162
+ state = next;
163
+ applyState(state);
164
+ saveState(ctx.config.storageKey, state);
165
+ ctx.onStateChange(state);
166
+ if (message) announce(message);
167
+ rerender();
168
+ }
169
+
170
+ function rerender(): void {
171
+ // Preserve focus across re-render via a language-stable selector.
172
+ // aria-label would break when the user switches locale mid-session.
173
+ const focusSelector = focusRestoreSelector();
174
+
175
+ while (root.firstChild) root.removeChild(root.firstChild);
176
+ build();
177
+ attachDrag();
178
+
179
+ if (focusSelector) root.querySelector<HTMLElement>(focusSelector)?.focus();
180
+ }
181
+
182
+ // Forward declaration so build()/commit() can reference it; defined below
183
+ // after the initial DOM exists because it reads from `root` directly.
184
+ function attachDrag(): void {
185
+ const handle = root.querySelector<HTMLElement>('[data-aw-drag-handle]');
186
+ if (!handle) return;
187
+ drag?.destroy();
188
+ drag = makeDraggable({ root, handle, storageKey: ctx.config.storageKey });
189
+ }
190
+
191
+ function focusRestoreSelector(): string | null {
192
+ const el = document.activeElement;
193
+ if (!(el instanceof HTMLElement) || !root.contains(el)) return null;
194
+ for (const attr of ['data-feature', 'data-profile', 'data-aw-action'] as const) {
195
+ const v = el.getAttribute(attr);
196
+ if (v) return `[${attr}="${CSS.escape(v)}"]`;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function build(): void {
202
+ // Header ---------------------------------------------------------
203
+ const closeBtn = make('button', {
204
+ class: 'aw-close',
205
+ attrs: { type: 'button', 'aria-label': T.close, 'data-aw-action': 'close' },
206
+ on: { click: ctx.onClose },
207
+ children: [buildIcon({ ...ICON_CLOSE, width: 20, height: 20 })],
208
+ });
209
+ const title = make('h2', {
210
+ class: 'aw-title',
211
+ attrs: { id: 'aw-panel-title' },
212
+ text: T.title,
213
+ });
214
+ const gripIcon = make('span', {
215
+ class: 'aw-drag-grip',
216
+ attrs: { 'aria-hidden': 'true' },
217
+ children: [buildIcon({ ...ICON_GRIP, width: 20, height: 20 })],
218
+ });
219
+ const header = make('header', {
220
+ class: 'aw-header',
221
+ attrs: { 'data-aw-drag-handle': '1', title: T.aria.dragHandle },
222
+ children: [gripIcon, title, closeBtn],
223
+ });
224
+
225
+ // Toolbar: language + oversized ---------------------------------
226
+ const langSelect = make('select', {
227
+ class: 'aw-lang',
228
+ attrs: { 'aria-label': T.aria.language, 'data-aw-action': 'language' },
229
+ children: SUPPORTED_LOCALES.map((loc) =>
230
+ make('option', {
231
+ attrs: { value: loc, selected: loc === locale },
232
+ text: LANGUAGE_NAMES[loc],
233
+ }),
234
+ ),
235
+ on: {
236
+ change: (ev) => {
237
+ const next = (ev.target as HTMLSelectElement).value;
238
+ if (isLocale(next) && next !== locale) {
239
+ setActiveLocale(next);
240
+ }
241
+ },
242
+ },
243
+ });
244
+ const langWrap = make('label', {
245
+ class: 'aw-tool aw-tool--lang',
246
+ children: [buildIcon({ ...ICON_GLOBE, width: 18, height: 18 }), langSelect],
247
+ });
248
+
249
+ const oversizedBtn = make('button', {
250
+ class: 'aw-tool aw-tool--oversize' + (state.oversized ? ' is-on' : ''),
251
+ attrs: {
252
+ type: 'button',
253
+ role: 'switch',
254
+ 'aria-checked': state.oversized ? 'true' : 'false',
255
+ 'aria-label': T.aria.oversized,
256
+ 'data-aw-action': 'oversized',
257
+ },
258
+ children: [
259
+ buildIcon({ ...ICON_MAXIMIZE, width: 18, height: 18 }),
260
+ make('span', { text: T.aria.oversized }),
261
+ ],
262
+ on: {
263
+ click: () => {
264
+ const next = !state.oversized;
265
+ commit({ ...state, oversized: next }, T.aria.oversized);
266
+ applyOversized();
267
+ },
268
+ },
269
+ });
270
+
271
+ const toolbar = make('div', {
272
+ class: 'aw-toolbar',
273
+ attrs: { role: 'group', 'aria-label': T.title },
274
+ children: [langWrap, oversizedBtn],
275
+ });
276
+
277
+ // Profiles -------------------------------------------------------
278
+ const profGrid = make('div', {
279
+ class: 'aw-profile-grid',
280
+ attrs: { role: 'group', 'aria-label': T.profiles.h },
281
+ children: PROFILE_IDS.map((id) =>
282
+ make('button', {
283
+ class: 'aw-profile-btn',
284
+ attrs: { type: 'button', 'data-profile': id },
285
+ text: T.profiles[id],
286
+ on: {
287
+ click: () => {
288
+ const next = applyProfile(state, id);
289
+ // Strip features the host has disabled so profile presets
290
+ // can't re-enable them.
291
+ for (const disabled of ctx.config.disabledFeatures) {
292
+ next.features[disabled] = false;
293
+ }
294
+ commit(next, T.profiles[id]);
295
+ },
296
+ },
297
+ }),
298
+ ),
299
+ });
300
+
301
+ // Features -------------------------------------------------------
302
+ const visibleFeatures = FEATURE_IDS.filter((id) => !ctx.config.disabledFeatures.has(id));
303
+ const featGrid = make('div', {
304
+ class: 'aw-feat-grid',
305
+ attrs: { role: 'group', 'aria-label': T.features.h },
306
+ children: visibleFeatures.map((id) => renderFeature(id, T)),
307
+ });
308
+
309
+ // Footer ---------------------------------------------------------
310
+ const resetBtn = make('button', {
311
+ class: 'aw-reset',
312
+ attrs: { type: 'button', 'data-aw-action': 'reset' },
313
+ children: [
314
+ buildIcon({ ...ICON_RESET, width: 18, height: 18 }),
315
+ make('span', { text: T.reset }),
316
+ ],
317
+ on: {
318
+ click: () => {
319
+ // Also clear any drag-dropped FAB position so it returns to its
320
+ // config-defined anchor on next paint (user reset-intent spans
321
+ // feature toggles AND visual overrides).
322
+ const fab = document.querySelector<HTMLElement>('[data-aw-fab]');
323
+ if (fab) {
324
+ fab.removeAttribute('data-aw-fab-pos');
325
+ fab.style.removeProperty('--aw-fab-x');
326
+ fab.style.removeProperty('--aw-fab-y');
327
+ }
328
+ const fresh: WidgetState = {
329
+ features: Object.fromEntries(
330
+ FEATURE_IDS.map((id) => [id, false]),
331
+ ) as WidgetState['features'],
332
+ fontSizeLevel: 1,
333
+ lineHeightLevel: 1.5,
334
+ letterSpacingLevel: 0,
335
+ contrastMode: 'off',
336
+ fabPosition: null,
337
+ };
338
+ ttsStop();
339
+ commit(fresh, T.resetDone);
340
+ },
341
+ },
342
+ });
343
+
344
+ liveEl = make('div', {
345
+ class: 'aw-live',
346
+ attrs: { 'aria-live': 'polite', 'aria-atomic': 'true' },
347
+ });
348
+
349
+ const footerChildren: HTMLElement[] = [resetBtn];
350
+ if (ctx.statementUrl) {
351
+ // Absolute / protocol-relative URLs point at a different origin;
352
+ // open those in a new tab so the user doesn't lose their panel state
353
+ // when reading the statement. rel=noopener+noreferrer is the standard
354
+ // hardening against reverse-tabnabbing + referrer leakage on _blank.
355
+ const isExternal = /^(?:https?:)?\/\//i.test(ctx.statementUrl);
356
+ const attrs: Record<string, string> = { href: ctx.statementUrl };
357
+ if (isExternal) {
358
+ attrs.target = '_blank';
359
+ attrs.rel = 'noopener noreferrer';
360
+ }
361
+ footerChildren.push(
362
+ make('a', {
363
+ class: 'aw-statement-link',
364
+ attrs,
365
+ text: T.statementLink,
366
+ }),
367
+ );
368
+ }
369
+ // Host-supplied disclaimer, no default. Kept as plain text so an
370
+ // accidental HTML string doesn't become a markup injection vector.
371
+ if (ctx.config.disclaimer) {
372
+ footerChildren.push(make('p', { class: 'aw-disclaimer', text: ctx.config.disclaimer }));
373
+ }
374
+
375
+ // "Powered by BAUER GROUP Accessibility-Widget" — localised connector +
376
+ // untranslated brand + link back to the product page. Opens in a new tab
377
+ // (external origin) with the usual rel-hardening. Hosts can suppress the
378
+ // whole line via `config.hidePoweredBy: true` for white-label deployments.
379
+ if (!ctx.config.hidePoweredBy) {
380
+ footerChildren.push(
381
+ make('p', {
382
+ class: 'aw-poweredby',
383
+ children: [
384
+ make('span', { text: `${T.poweredBy} ` }),
385
+ make('a', {
386
+ attrs: {
387
+ href: 'https://accessibility-widget.app.professional-hosting.com',
388
+ target: '_blank',
389
+ rel: 'noopener noreferrer',
390
+ },
391
+ text: 'BAUER GROUP Accessibility-Widget',
392
+ }),
393
+ ],
394
+ }),
395
+ );
396
+ }
397
+
398
+ footerChildren.push(liveEl);
399
+ const footer = make('footer', { class: 'aw-footer', children: footerChildren });
400
+
401
+ root.appendChild(header);
402
+ root.appendChild(toolbar);
403
+
404
+ const profSummary = make('summary', {
405
+ class: 'aw-h3 aw-summary',
406
+ children: [
407
+ make('span', { text: T.profiles.h }),
408
+ buildIcon({ ...ICON_CHEVRON, width: 16, height: 16 }),
409
+ ],
410
+ });
411
+ const profDetails = make('details', {
412
+ class: 'aw-section aw-collapsible',
413
+ attrs: { open: true },
414
+ children: [profSummary, profGrid],
415
+ });
416
+ root.appendChild(profDetails);
417
+
418
+ root.appendChild(
419
+ make('div', {
420
+ class: 'aw-section',
421
+ children: [make('h3', { class: 'aw-h3', text: T.features.h }), featGrid],
422
+ }),
423
+ );
424
+ root.appendChild(footer);
425
+ }
426
+
427
+ function renderFeature(id: FeatureId, T: Translation): HTMLElement {
428
+ const label = T.features[id];
429
+ const active = state.features[id];
430
+ const cycle = describeCycle(id, state, T);
431
+ const isCycle = cycle !== null;
432
+ const description = featureDescription(id, T);
433
+
434
+ const iconSpec = FEATURE_ICONS[id];
435
+ const iconEl = make('span', {
436
+ class: 'aw-feat-icon',
437
+ children: [buildIcon({ ...iconSpec, width: 28, height: 28 })],
438
+ });
439
+
440
+ const activeBadge = make('span', {
441
+ class: 'aw-feat-check',
442
+ attrs: { 'aria-hidden': 'true' },
443
+ children: [buildIcon({ ...ICON_CHECK, width: 14, height: 14 })],
444
+ });
445
+
446
+ const descId = `aw-desc-${id}`;
447
+ const infoBadge = description
448
+ ? make('span', {
449
+ class: 'aw-feat-info',
450
+ attrs: { 'aria-hidden': 'true', role: 'img', 'aria-label': T.aria.info },
451
+ children: [buildIcon({ ...ICON_INFO, width: 14, height: 14 })],
452
+ })
453
+ : null;
454
+ const descEl = description
455
+ ? make('span', {
456
+ class: 'aw-feat-tooltip',
457
+ attrs: { id: descId, role: 'tooltip' },
458
+ text: description,
459
+ })
460
+ : null;
461
+
462
+ const children: (HTMLElement | null)[] = [
463
+ infoBadge,
464
+ iconEl,
465
+ make('span', { class: 'aw-feat-label', text: label }),
466
+ ];
467
+ if (cycle) {
468
+ children.push(renderStageDots(cycle.index, cycle.max));
469
+ children.push(make('span', { class: 'aw-feat-badge', text: cycle.label }));
470
+ } else {
471
+ children.push(
472
+ make('span', {
473
+ class: 'aw-feat-badge',
474
+ text: active ? T.values.on : T.values.off,
475
+ }),
476
+ );
477
+ }
478
+ children.push(activeBadge);
479
+
480
+ const attrs: Record<string, string> = {
481
+ type: 'button',
482
+ 'aria-label': `${T.aria.switch} ${label}`,
483
+ 'data-feature': id,
484
+ };
485
+ if (description) attrs['aria-describedby'] = descId;
486
+ if (isCycle && cycle) {
487
+ // Multi-stage control: expose slider semantics so screenreaders announce "step 2 of 3".
488
+ attrs.role = 'slider';
489
+ attrs['aria-valuemin'] = '0';
490
+ attrs['aria-valuemax'] = String(cycle.max);
491
+ attrs['aria-valuenow'] = String(cycle.index);
492
+ attrs['aria-valuetext'] = cycle.label;
493
+ } else {
494
+ attrs.role = 'switch';
495
+ attrs['aria-checked'] = active ? 'true' : 'false';
496
+ }
497
+
498
+ const button = make('button', {
499
+ class: 'aw-feat' + (active ? ' is-on' : ''),
500
+ attrs,
501
+ children: children.filter((c): c is HTMLElement => c !== null),
502
+ on: { click: () => onFeatureClick(id, label) },
503
+ });
504
+
505
+ if (!descEl) return button;
506
+ return make('div', {
507
+ class: 'aw-feat-wrap',
508
+ children: [button, descEl],
509
+ });
510
+ }
511
+
512
+ function onFeatureClick(id: FeatureId, label: string): void {
513
+ if (id === 'tts') {
514
+ if (ttsActive()) {
515
+ ttsStop();
516
+ commit({ ...state, features: { ...state.features, tts: false } }, label);
517
+ } else {
518
+ const text = collectReadableText();
519
+ ttsStart(text, ctx.locale);
520
+ commit({ ...state, features: { ...state.features, tts: true } }, label);
521
+ }
522
+ return;
523
+ }
524
+ if (id === 'structureNav') {
525
+ const opened = structureNavToggle(T.aria.dialog);
526
+ commit({ ...state, features: { ...state.features, structureNav: opened } }, label);
527
+ return;
528
+ }
529
+ if (id === 'fontSize') {
530
+ const { next, wrapped } = cycleStep(state.fontSizeLevel, STEPS.fontSize);
531
+ commit(
532
+ {
533
+ ...state,
534
+ fontSizeLevel: next,
535
+ features: { ...state.features, fontSize: !wrapped },
536
+ },
537
+ label,
538
+ );
539
+ return;
540
+ }
541
+ if (id === 'lineHeight') {
542
+ const { next, wrapped } = cycleStep(state.lineHeightLevel, STEPS.lineHeight);
543
+ commit(
544
+ {
545
+ ...state,
546
+ lineHeightLevel: next,
547
+ features: { ...state.features, lineHeight: !wrapped },
548
+ },
549
+ label,
550
+ );
551
+ return;
552
+ }
553
+ if (id === 'letterSpacing') {
554
+ const { next, wrapped } = cycleStep(state.letterSpacingLevel, STEPS.letterSpacing);
555
+ commit(
556
+ {
557
+ ...state,
558
+ letterSpacingLevel: next,
559
+ features: { ...state.features, letterSpacing: !wrapped },
560
+ },
561
+ label,
562
+ );
563
+ return;
564
+ }
565
+ if (id === 'contrast') {
566
+ const { next, wrapped } = cycleStep(state.contrastMode, CONTRAST_MODES);
567
+ commit(
568
+ {
569
+ ...state,
570
+ contrastMode: next,
571
+ features: { ...state.features, contrast: !wrapped },
572
+ },
573
+ label,
574
+ );
575
+ return;
576
+ }
577
+ commit({ ...state, features: { ...state.features, [id]: !state.features[id] } }, label);
578
+ }
579
+
580
+ function onEsc(e: KeyboardEvent): void {
581
+ if (e.key === 'Escape') ctx.onClose();
582
+ }
583
+
584
+ build();
585
+ document.body.appendChild(root);
586
+ trap = createFocusTrap(root);
587
+ trap.activate();
588
+ document.addEventListener('keydown', onEsc);
589
+ root.querySelector<HTMLButtonElement>('.aw-close')?.focus();
590
+ attachDrag();
591
+
592
+ function setActiveLocale(next: Locale): void {
593
+ if (next === locale || !isLocale(next)) return;
594
+ locale = next;
595
+ T = t(locale);
596
+ applyLocaleAttrs();
597
+ // Persist to state so the new locale survives page reload.
598
+ state = { ...state, locale: next };
599
+ saveState(ctx.config.storageKey, state);
600
+ ctx.onStateChange(state);
601
+ announce(LANGUAGE_NAMES[locale]);
602
+ rerender();
603
+ }
604
+
605
+ return {
606
+ root,
607
+ destroy: () => {
608
+ trap?.deactivate();
609
+ drag?.destroy();
610
+ drag = null;
611
+ document.removeEventListener('keydown', onEsc);
612
+ root.remove();
613
+ },
614
+ rerender,
615
+ setLocale: setActiveLocale,
616
+ };
617
+ }
package/src/state.ts ADDED
@@ -0,0 +1,91 @@
1
+ import {
2
+ DEFAULT_STATE,
3
+ isLocale,
4
+ type WidgetState,
5
+ type FeatureId,
6
+ type ContrastMode,
7
+ } from './types/index.js';
8
+ import { warnIfDebug } from './util/debug.js';
9
+
10
+ export const STEPS = {
11
+ fontSize: [1, 1.2, 1.4, 1.6] as const,
12
+ lineHeight: [1.5, 1.8, 2.0] as const,
13
+ letterSpacing: [0, 0.05, 0.1] as const,
14
+ contrast: ['off', 'high', 'dark', 'invert'] as const satisfies readonly ContrastMode[],
15
+ };
16
+
17
+ export function createDefaultState(): WidgetState {
18
+ return {
19
+ ...DEFAULT_STATE,
20
+ features: { ...DEFAULT_STATE.features },
21
+ };
22
+ }
23
+
24
+ export function loadState(storageKey: string): WidgetState {
25
+ try {
26
+ const raw = localStorage.getItem(storageKey);
27
+ if (!raw) return createDefaultState();
28
+ const parsed = JSON.parse(raw) as Partial<WidgetState>;
29
+ const fresh = createDefaultState();
30
+ const next: WidgetState = {
31
+ features: { ...fresh.features, ...(parsed.features ?? {}) },
32
+ fontSizeLevel: parsed.fontSizeLevel ?? fresh.fontSizeLevel,
33
+ lineHeightLevel: parsed.lineHeightLevel ?? fresh.lineHeightLevel,
34
+ letterSpacingLevel: parsed.letterSpacingLevel ?? fresh.letterSpacingLevel,
35
+ contrastMode: parsed.contrastMode ?? fresh.contrastMode,
36
+ oversized: parsed.oversized ?? fresh.oversized,
37
+ };
38
+ // Preserve optional runtime-override fields. Without these the next save
39
+ // (triggered by any feature toggle) would wipe the user's language
40
+ // choice and their dragged FAB position.
41
+ if (typeof parsed.locale === 'string' && isLocale(parsed.locale)) {
42
+ next.locale = parsed.locale;
43
+ }
44
+ if (
45
+ parsed.fabPosition &&
46
+ typeof parsed.fabPosition.x === 'number' &&
47
+ typeof parsed.fabPosition.y === 'number'
48
+ ) {
49
+ next.fabPosition = { x: parsed.fabPosition.x, y: parsed.fabPosition.y };
50
+ }
51
+ return next;
52
+ } catch (err) {
53
+ warnIfDebug(`loadState("${storageKey}") failed, falling back to defaults`, err);
54
+ return createDefaultState();
55
+ }
56
+ }
57
+
58
+ export function saveState(storageKey: string, state: WidgetState): void {
59
+ try {
60
+ localStorage.setItem(storageKey, JSON.stringify(state));
61
+ } catch (err) {
62
+ // Quota exceeded / storage disabled (Safari Private Mode, Intelligent Tracking Prevention, …).
63
+ warnIfDebug(`saveState("${storageKey}") failed — preferences will not persist`, err);
64
+ }
65
+ }
66
+
67
+ export function clearState(storageKey: string): void {
68
+ try {
69
+ localStorage.removeItem(storageKey);
70
+ } catch (err) {
71
+ warnIfDebug(`clearState("${storageKey}") failed`, err);
72
+ }
73
+ }
74
+
75
+ export function hasAnyFeatureOn(state: WidgetState): boolean {
76
+ return Object.values(state.features).some(Boolean);
77
+ }
78
+
79
+ export function cycleStep<T extends number | string>(
80
+ current: T,
81
+ steps: readonly T[],
82
+ ): { next: T; wrapped: boolean } {
83
+ const idx = steps.indexOf(current);
84
+ const nextIdx = idx < 0 ? 1 : (idx + 1) % steps.length;
85
+ const next = steps[nextIdx] ?? steps[0]!;
86
+ return { next, wrapped: nextIdx === 0 };
87
+ }
88
+
89
+ export function setFeature(state: WidgetState, id: FeatureId, on: boolean): WidgetState {
90
+ return { ...state, features: { ...state.features, [id]: on } };
91
+ }