@oksigenia/access-panel 0.4.4 → 0.5.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.
@@ -1,3 +1,76 @@
1
+ // src/controls.ts
2
+ var ALL_CONTROLS = [
3
+ "text-size",
4
+ "line-height",
5
+ "text-align",
6
+ "readable-font",
7
+ "dyslexia-font",
8
+ "letter-spacing",
9
+ "contrast",
10
+ "grayscale",
11
+ "hide-images",
12
+ "highlight-links",
13
+ "colorblind",
14
+ "reading-guide",
15
+ "reading-mask",
16
+ "big-cursor",
17
+ "big-targets",
18
+ "pause-anim",
19
+ "focus"
20
+ ];
21
+ var CONTROL_STATE_KEY = {
22
+ "text-size": "zoom",
23
+ "line-height": "lh",
24
+ "text-align": "align",
25
+ "readable-font": "font",
26
+ "dyslexia-font": "dyslexia",
27
+ "letter-spacing": "ls",
28
+ "contrast": "contrast",
29
+ "grayscale": "grayOverlay",
30
+ "hide-images": "hideImages",
31
+ "highlight-links": "highlightLinks",
32
+ "colorblind": "colorblind",
33
+ "reading-guide": "readingGuide",
34
+ "reading-mask": "readingMask",
35
+ "big-cursor": "bigCursor",
36
+ "big-targets": "bigTargets",
37
+ "pause-anim": "pauseAnim",
38
+ "focus": "focusOutline"
39
+ };
40
+ var STATE_KEY_CONTROL = Object.fromEntries(
41
+ Object.entries(CONTROL_STATE_KEY).map(([control, key]) => [key, control])
42
+ );
43
+ var PRESETS = {
44
+ lowvision: { zoom: 2, contrast: true, highlightLinks: true, bigCursor: true, focusOutline: true },
45
+ dyslexia: { dyslexia: true, lh: 2, ls: 2, readingGuide: true },
46
+ motor: { bigCursor: true, bigTargets: true, focusOutline: true },
47
+ calm: { hideImages: true, pauseAnim: true }
48
+ };
49
+ var PRESET_IDS = ["lowvision", "dyslexia", "motor", "calm"];
50
+ function presetControlIds(id) {
51
+ return Object.keys(PRESETS[id]).map((k) => STATE_KEY_CONTROL[k]);
52
+ }
53
+ function resolveEnabledControls(controlsAttr, excludeAttr) {
54
+ const valid = new Set(ALL_CONTROLS);
55
+ const parse = (raw) => (raw ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => valid.has(s));
56
+ const whitelist = controlsAttr != null ? parse(controlsAttr) : null;
57
+ let enabled = whitelist && whitelist.length > 0 ? new Set(whitelist) : new Set(ALL_CONTROLS);
58
+ if (excludeAttr != null) {
59
+ for (const id of parse(excludeAttr)) enabled.delete(id);
60
+ }
61
+ return enabled;
62
+ }
63
+ function filterPresetForEnabled(id, enabled) {
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(PRESETS[id])) {
66
+ if (enabled.has(STATE_KEY_CONTROL[k])) out[k] = v;
67
+ }
68
+ return out;
69
+ }
70
+ function presetIsAvailable(id, enabled) {
71
+ return presetControlIds(id).filter((c) => enabled.has(c)).length >= 2;
72
+ }
73
+
1
74
  // src/icons.ts
2
75
  var ICON_TXT = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.5,4v3h5v12h3V7h5V4H2.5z M21.5,9h-9v3h3v7h3v-7h3V9z"/></svg>';
3
76
  var ICON_LH = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,7h2.5L5,3.5L1.5,7H4v10H1.5L5,20.5L8.5,17H6V7z M10,5v2h12V5H10z M10,19h12v-2H10V19z M10,13h12v-2H10V13z"/></svg>';
@@ -16,75 +89,100 @@ var ICON_COLORBLIND = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12
16
89
  var ICON_FOCUS = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 7V3h4v2H5v2H3zm14-4h4v4h-2V5h-2V3zm4 14v4h-4v-2h2v-2h2zM7 21H3v-4h2v2h2v2z"/></svg>';
17
90
  var ICON_MASK = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M2 4h20v6H2zm0 10h20v6H2zm2-9v4h16V5zm0 10v4h16v-4z" opacity=".55"/><rect x="2" y="10" width="20" height="4"/></svg>';
18
91
  var ICON_TARGETS = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h4v2H5v2H3zm14 0h4v4h-2V5h-2zm0 18v-2h2v-2h2v4zM3 21v-4h2v2h2v2zM12 8a4 4 0 100 8 4 4 0 000-8zm0 2a2 2 0 110 4 2 2 0 010-4z"/></svg>';
92
+ var UNIVERSAL_GLYPH = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 7h-6v13h-2v-6h-2v6H9V9H3V7h18v2z"/></svg>';
19
93
  var TRIGGER_ICONS = {
20
94
  vitruvian: '<svg viewBox="0 0 122.88 122.88" fill="currentColor"><path d="M61.44,0A61.46,61.46,0,1,1,18,18,61.21,61.21,0,0,1,61.44,0Zm-.39,74.18L52.1,98.91a4.94,4.94,0,0,1-2.58,2.83A5,5,0,0,1,42.7,95.5l6.24-17.28a26.3,26.3,0,0,0,1.17-4,40.64,40.64,0,0,0,.54-4.18c.24-2.53.41-5.27.54-7.9s.22-5.18.29-7.29c.09-2.63-.62-2.8-2.73-3.3l-.44-.1-18-3.39A5,5,0,0,1,27.08,46a5,5,0,0,1,5.05-7.74l19.34,3.63c.77.07,1.52.16,2.31.25a57.64,57.64,0,0,0,7.18.53A81.13,81.13,0,0,0,69.9,42c.9-.1,1.75-.21,2.6-.29l18.25-3.42A5,5,0,0,1,94.5,39a5,5,0,0,1,1.3,7,5,5,0,0,1-3.21,2.09L75.15,51.37c-.58.13-1.1.22-1.56.29-1.82.31-2.72.47-2.61,3.06.08,1.89.31,4.15.61,6.51.35,2.77.81,5.71,1.29,8.4.31,1.77.6,3.19,1,4.55s.79,2.75,1.39,4.42l6.11,16.9a5,5,0,0,1-6.82,6.24,4.94,4.94,0,0,1-2.58-2.83L63,74.23,62,72.4l-1,1.78Zm.39-53.52a8.83,8.83,0,1,1-6.24,2.59,8.79,8.79,0,0,1,6.24-2.59Zm36.35,4.43a51.42,51.42,0,1,0,15,36.35,51.27,51.27,0,0,0-15-36.35Z"/></svg>',
21
95
  wheelchair: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13v-2c-1.54.02-3.09-.75-4.07-1.83l-1.29-1.43c-.17-.19-.38-.34-.61-.45-.01 0-.01-.01-.02-.01H13c-.35-.2-.75-.3-1.19-.26C10.76 7.11 10 8.04 10 9.09V15c0 1.1.9 2 2 2h5v5h2v-5.5c0-1.1-.9-2-2-2h-3v-3.45c1.29 1.07 3.25 1.94 5 1.95zm-6.17 5c-.41 1.16-1.52 2-2.83 2-1.66 0-3-1.34-3-3 0-1.31.84-2.41 2-2.83V12.1c-2.28.46-4 2.48-4 4.9 0 2.76 2.24 5 5 5 2.42 0 4.44-1.72 4.9-4h-2.07zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z"/></svg>',
22
96
  eye: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>',
23
- universal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm9 7h-6v13h-2v-6h-2v6H9V9H3V7h18v2z"/></svg>'
97
+ universal: UNIVERSAL_GLYPH,
98
+ porthole: UNIVERSAL_GLYPH
24
99
  };
25
100
  var ICON_CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
26
101
  var COLORBLIND_FILTERS_SVG = '<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden;" aria-hidden="true"><defs><filter id="oks-filter-protanopia"><feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/></filter><filter id="oks-filter-deuteranopia"><feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/></filter><filter id="oks-filter-tritanopia"><feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/></filter></defs></svg>';
27
102
 
28
103
  // src/render.ts
104
+ var PRESET_ICON = {
105
+ lowvision: ICON_CONTRAST,
106
+ dyslexia: ICON_DYSLEXIA,
107
+ motor: ICON_CURSOR,
108
+ calm: ICON_HIDE
109
+ };
29
110
  function buildPanelHtml(opts) {
30
111
  const { t, triggerIcon } = opts;
112
+ const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
113
+ const showTrigger = opts.showTrigger ?? true;
114
+ const showPresets = opts.showPresets ?? true;
31
115
  const trig = TRIGGER_ICONS[triggerIcon];
32
- const grid = (rows) => `<div class="oks-access-grid">${rows.join("")}</div>`;
33
- const multi = (action, prefix, levels, label, icon, full = false) => {
116
+ const multi = (id, prefix, levels, label, icon, full = false) => {
117
+ if (!enabled.has(id)) return "";
118
+ const action = id === "colorblind" ? "colorblind" : "multi";
34
119
  const fullClass = full ? " full-width" : "";
35
120
  const dots = Array.from({ length: levels }, () => "<span></span>").join("");
36
- return `<button class="oks-access-opt multi-step${fullClass}" data-action="${action}" data-prefix="${prefix}" data-levels="${levels}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span><div class="oks-levels">${dots}</div></button>`;
121
+ return `<button class="oks-access-opt multi-step${fullClass}" data-control="${id}" data-action="${action}" data-prefix="${prefix}" data-levels="${levels}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span><div class="oks-levels">${dots}</div></button>`;
37
122
  };
38
- const toggle = (klass, label, icon) => `<button class="oks-access-opt" data-action="toggle" data-class="${klass}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
39
- const overlay = (id, label, icon) => `<button class="oks-access-opt" data-action="overlay" data-target="${id}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
40
- const guide = (label, icon) => `<button class="oks-access-opt" data-action="guide" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
41
- const mask = (label, icon) => `<button class="oks-access-opt" data-action="mask" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
42
- const preset = (id, label, icon) => `<button class="oks-preset" data-action="preset" data-preset="${id}" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
43
- return `
123
+ const toggle = (id, klass, label, icon) => {
124
+ if (!enabled.has(id)) return "";
125
+ return `<button class="oks-access-opt" data-control="${id}" data-action="toggle" data-class="${klass}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
126
+ };
127
+ const overlay = (id, target, label, icon) => {
128
+ if (!enabled.has(id)) return "";
129
+ return `<button class="oks-access-opt" data-control="${id}" data-action="overlay" data-target="${target}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
130
+ };
131
+ const simple = (id, action, label, icon) => {
132
+ if (!enabled.has(id)) return "";
133
+ return `<button class="oks-access-opt" data-control="${id}" data-action="${action}" aria-pressed="false" type="button"><span class="oks-icon">${icon}</span><span class="oks-label">${label}</span></button>`;
134
+ };
135
+ const preset = (id, label) => `<button class="oks-preset" data-action="preset" data-preset="${id}" type="button"><span class="oks-icon">${PRESET_ICON[id]}</span><span class="oks-label">${label}</span></button>`;
136
+ const section = (title, rows) => {
137
+ const kept = rows.filter(Boolean);
138
+ if (kept.length === 0) return "";
139
+ return `<h4 class="oks-access-title">${escapeHtml(title)}</h4><div class="oks-access-grid">${kept.join("")}</div>`;
140
+ };
141
+ const presetLabels = {
142
+ lowvision: t.pLow,
143
+ dyslexia: t.pDys,
144
+ motor: t.pMot,
145
+ calm: t.pCalm
146
+ };
147
+ const presetButtons = showPresets ? PRESET_IDS.filter((id) => presetIsAvailable(id, enabled)).map((id) => preset(id, presetLabels[id])) : [];
148
+ const presetsBlock = presetButtons.length ? `<h4 class="oks-access-title">${escapeHtml(t.presets)}</h4><div class="oks-access-presets">${presetButtons.join("")}</div>` : "";
149
+ const triggerBlock = showTrigger ? `
44
150
  <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
45
- <button class="oks-access-btn" id="oks-trigger" aria-label="${escapeAttr(t.title)}" aria-expanded="false" aria-controls="oks-panel" type="button">
151
+ <button class="oks-access-btn" id="oks-trigger" part="trigger" data-trigger-icon="${triggerIcon}" aria-label="${escapeAttr(t.title)}" aria-expanded="false" aria-controls="oks-panel" type="button">
46
152
  ${trig}
47
153
  </button>
48
154
  <span class="oks-active-badge"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4"><polyline points="20 6 9 17 4 12"/></svg></span>
49
- </div>
155
+ </div>` : "";
156
+ return `${triggerBlock}
50
157
  <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
51
158
  <div class="oks-access-header">
52
159
  <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
53
160
  <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
54
161
  </div>
55
162
  <div class="oks-access-content">
56
- <h4 class="oks-access-title">${escapeHtml(t.presets)}</h4>
57
- <div class="oks-access-presets">
58
- ${preset("lowvision", t.pLow, ICON_CONTRAST)}
59
- ${preset("dyslexia", t.pDys, ICON_DYSLEXIA)}
60
- ${preset("motor", t.pMot, ICON_CURSOR)}
61
- ${preset("calm", t.pCalm, ICON_HIDE)}
62
- </div>
63
- <h4 class="oks-access-title">${escapeHtml(t.txt)}</h4>
64
- ${grid([
65
- multi("multi", "oks-zoom", 4, t.size, ICON_TXT),
66
- multi("multi", "oks-lh", 3, t.lh, ICON_LH),
67
- multi("multi", "oks-align", 3, t.align, ICON_ALIGN),
68
- toggle("oks-a11y-font", t.font, ICON_FONT),
69
- toggle("oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
70
- multi("multi", "oks-ls", 3, t.ls, ICON_LS)
163
+ ${presetsBlock}
164
+ ${section(t.txt, [
165
+ multi("text-size", "oks-zoom", 4, t.size, ICON_TXT),
166
+ multi("line-height", "oks-lh", 3, t.lh, ICON_LH),
167
+ multi("text-align", "oks-align", 3, t.align, ICON_ALIGN),
168
+ toggle("readable-font", "oks-a11y-font", t.font, ICON_FONT),
169
+ toggle("dyslexia-font", "oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
170
+ multi("letter-spacing", "oks-ls", 3, t.ls, ICON_LS)
71
171
  ])}
72
- <h4 class="oks-access-title">${escapeHtml(t.vis)}</h4>
73
- ${grid([
74
- toggle("oks-a11y-contrast", t.contrast, ICON_CONTRAST),
75
- overlay("oks-overlay-gray", t.gray, ICON_GRAY),
76
- toggle("oks-a11y-hide", t.hide, ICON_HIDE),
77
- toggle("oks-a11y-links", t.links, ICON_LINK),
172
+ ${section(t.vis, [
173
+ toggle("contrast", "oks-a11y-contrast", t.contrast, ICON_CONTRAST),
174
+ overlay("grayscale", "oks-overlay-gray", t.gray, ICON_GRAY),
175
+ toggle("hide-images", "oks-a11y-hide", t.hide, ICON_HIDE),
176
+ toggle("highlight-links", "oks-a11y-links", t.links, ICON_LINK),
78
177
  multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, true)
79
178
  ])}
80
- <h4 class="oks-access-title">${escapeHtml(t.ori)}</h4>
81
- ${grid([
82
- guide(t.guide, ICON_GUIDE),
83
- mask(t.mask, ICON_MASK),
84
- toggle("oks-big-cursor", t.cursor, ICON_CURSOR),
85
- toggle("oks-a11y-bigtargets", t.targets, ICON_TARGETS),
86
- toggle("oks-a11y-pause", t.pause, ICON_PAUSE),
87
- toggle("oks-a11y-focus", t.focus, ICON_FOCUS)
179
+ ${section(t.ori, [
180
+ simple("reading-guide", "guide", t.guide, ICON_GUIDE),
181
+ simple("reading-mask", "mask", t.mask, ICON_MASK),
182
+ toggle("big-cursor", "oks-big-cursor", t.cursor, ICON_CURSOR),
183
+ toggle("big-targets", "oks-a11y-bigtargets", t.targets, ICON_TARGETS),
184
+ toggle("pause-anim", "oks-a11y-pause", t.pause, ICON_PAUSE),
185
+ toggle("focus", "oks-a11y-focus", t.focus, ICON_FOCUS)
88
186
  ])}
89
187
  </div>
90
188
  <div class="oks-access-footer">
@@ -109,14 +207,20 @@ function positionRules(position) {
109
207
  switch (position) {
110
208
  case "top-left":
111
209
  return { wrap: "top: 20px; left: 20px;", panel: "top: 80px; left: 20px;" };
210
+ case "top-center":
211
+ return { wrap: "top: 20px; left: 50%; transform: translateX(-50%);", panel: "top: 80px; left: 50%; transform: translateX(-50%);" };
112
212
  case "top-right":
113
213
  return { wrap: "top: 20px; right: 20px;", panel: "top: 80px; right: 20px;" };
114
214
  case "mid-left":
115
215
  return { wrap: "top: 50%; left: 20px; transform: translateY(-50%);", panel: "top: 50%; left: 90px; transform: translateY(-50%);" };
216
+ case "mid-center":
217
+ return { wrap: "top: 50%; left: 50%; transform: translate(-50%, -50%);", panel: "top: 50%; left: 50%; transform: translate(-50%, -50%);" };
116
218
  case "mid-right":
117
219
  return { wrap: "top: 50%; right: 20px; transform: translateY(-50%);", panel: "top: 50%; right: 90px; transform: translateY(-50%);" };
118
220
  case "bottom-left":
119
221
  return { wrap: "bottom: 20px; left: 20px;", panel: "bottom: 100px; left: 20px;" };
222
+ case "bottom-center":
223
+ return { wrap: "bottom: 20px; left: 50%; transform: translateX(-50%);", panel: "bottom: 100px; left: 50%; transform: translateX(-50%);" };
120
224
  case "bottom-right":
121
225
  return { wrap: "bottom: 20px; right: 20px;", panel: "bottom: 100px; right: 20px;" };
122
226
  }
@@ -228,38 +332,45 @@ var TOGGLE_KEYS = {
228
332
  "oks-a11y-focus": "focusOutline",
229
333
  "oks-a11y-bigtargets": "bigTargets"
230
334
  };
231
- var PRESETS = {
232
- lowvision: { zoom: 2, contrast: true, highlightLinks: true, bigCursor: true, focusOutline: true },
233
- dyslexia: { dyslexia: true, lh: 2, ls: 2, readingGuide: true },
234
- motor: { bigCursor: true, bigTargets: true, focusOutline: true },
235
- calm: { hideImages: true, pauseAnim: true }
236
- };
335
+ function noopController() {
336
+ return Object.assign(() => {
337
+ }, { open() {
338
+ }, close() {
339
+ }, toggle() {
340
+ } });
341
+ }
342
+ function deepActiveElement() {
343
+ let a = document.activeElement;
344
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
345
+ return a;
346
+ }
237
347
  function bindPanelBehavior(root, opts = {}) {
238
348
  const storageKey = opts.storageKey ?? "oksiacSettings";
239
349
  const t = getTranslation(opts.locale ?? "en");
350
+ const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
351
+ const nudgeMax = Math.max(0, opts.nudgeMax ?? 0);
240
352
  const trigger = root.getElementById("oks-trigger");
241
353
  const panel = root.getElementById("oks-panel");
242
354
  const closeBtn = root.getElementById("oks-close");
243
355
  const resetBtn = root.getElementById("oks-reset");
244
356
  const wrapper = root.getElementById("oks-wrapper");
245
357
  const opts$ = Array.from(root.querySelectorAll(".oks-access-opt, .oks-preset"));
246
- if (!trigger || !panel || !closeBtn || !resetBtn || !wrapper) {
247
- return () => {
248
- };
358
+ if (!panel || !closeBtn || !resetBtn) {
359
+ return noopController();
249
360
  }
250
361
  let state = loadState(storageKey);
251
362
  function applyState() {
252
363
  const body = document.body;
253
- const root2 = document.documentElement;
364
+ const rootEl = document.documentElement;
254
365
  for (const cls of Array.from(body.classList)) {
255
366
  if (cls.startsWith("oks-")) body.classList.remove(cls);
256
367
  }
257
- [1, 2, 3].forEach((l) => root2.classList.remove(`oks-colorblind-${l}`));
368
+ [1, 2, 3].forEach((l) => rootEl.classList.remove(`oks-colorblind-${l}`));
258
369
  if (state.zoom > 0) body.classList.add(`oks-zoom-${state.zoom}`);
259
370
  if (state.lh > 0) body.classList.add(`oks-lh-${state.lh}`);
260
371
  if (state.align > 0) body.classList.add(`oks-align-${state.align}`);
261
372
  if (state.ls > 0) body.classList.add(`oks-ls-${state.ls}`);
262
- if (state.colorblind > 0) root2.classList.add(`oks-colorblind-${state.colorblind}`);
373
+ if (state.colorblind > 0) rootEl.classList.add(`oks-colorblind-${state.colorblind}`);
263
374
  if (state.font) body.classList.add("oks-a11y-font");
264
375
  if (state.dyslexia) body.classList.add("oks-dyslexia");
265
376
  if (state.contrast) body.classList.add("oks-a11y-contrast");
@@ -336,9 +447,8 @@ function bindPanelBehavior(root, opts = {}) {
336
447
  } else if (action === "mask") {
337
448
  state.readingMask = !state.readingMask;
338
449
  } else if (action === "preset") {
339
- const id = btn.getAttribute("data-preset") ?? "";
340
- const preset = PRESETS[id];
341
- if (preset) Object.assign(state, preset);
450
+ const id = btn.getAttribute("data-preset");
451
+ if (id && PRESETS[id]) Object.assign(state, filterPresetForEnabled(id, enabled));
342
452
  btn.classList.add("is-flashing");
343
453
  setTimeout(() => btn.classList.remove("is-flashing"), 250);
344
454
  }
@@ -350,28 +460,50 @@ function bindPanelBehavior(root, opts = {}) {
350
460
  applyState();
351
461
  saveState(storageKey, state);
352
462
  };
463
+ let opener = null;
464
+ let ignoreDocClose = false;
353
465
  const openPanel = () => {
466
+ if (panel.classList.contains("is-open")) return;
467
+ opener = deepActiveElement();
354
468
  panel.classList.add("is-open");
355
469
  panel.removeAttribute("inert");
356
- trigger.setAttribute("aria-expanded", "true");
470
+ trigger?.setAttribute("aria-expanded", "true");
471
+ ignoreDocClose = true;
472
+ setTimeout(() => {
473
+ ignoreDocClose = false;
474
+ }, 0);
357
475
  const first = panel.querySelector("button:not([disabled])");
358
476
  first?.focus();
359
477
  };
360
478
  const closePanel = () => {
479
+ if (!panel.classList.contains("is-open")) return;
361
480
  panel.classList.remove("is-open");
362
481
  panel.setAttribute("inert", "");
363
- trigger.setAttribute("aria-expanded", "false");
364
- trigger.focus();
482
+ trigger?.setAttribute("aria-expanded", "false");
483
+ if (trigger) trigger.focus();
484
+ else if (opener?.isConnected) opener.focus();
485
+ opener = null;
365
486
  };
366
- const onTriggerClick = (e) => {
367
- e.stopPropagation();
487
+ const togglePanel = () => {
368
488
  if (panel.classList.contains("is-open")) closePanel();
369
489
  else openPanel();
370
490
  };
491
+ let suppressClick = false;
492
+ const onTriggerClick = (e) => {
493
+ e.stopPropagation();
494
+ if (suppressClick) {
495
+ suppressClick = false;
496
+ return;
497
+ }
498
+ togglePanel();
499
+ };
371
500
  const onDocClick = (e) => {
372
501
  if (!panel.classList.contains("is-open")) return;
502
+ if (ignoreDocClose) return;
373
503
  const path = e.composedPath();
374
- if (path.includes(panel) || path.includes(trigger) || path.includes(wrapper)) return;
504
+ if (path.includes(panel)) return;
505
+ if (trigger && path.includes(trigger)) return;
506
+ if (wrapper && path.includes(wrapper)) return;
375
507
  closePanel();
376
508
  };
377
509
  const onKeyDown = (e) => {
@@ -398,6 +530,88 @@ function bindPanelBehavior(root, opts = {}) {
398
530
  }
399
531
  }
400
532
  };
533
+ const NUDGE_KEY = `${storageKey}::pos`;
534
+ const clampMax = (v) => Math.max(-nudgeMax, Math.min(nudgeMax, v));
535
+ let nudge = { x: 0, y: 0 };
536
+ const loadNudge = () => {
537
+ if (nudgeMax <= 0) return;
538
+ try {
539
+ const raw = localStorage.getItem(NUDGE_KEY);
540
+ if (!raw) return;
541
+ const p = JSON.parse(raw);
542
+ if (typeof p?.x === "number" && typeof p?.y === "number") {
543
+ nudge = { x: clampMax(p.x), y: clampMax(p.y) };
544
+ }
545
+ } catch {
546
+ }
547
+ };
548
+ const saveNudge = () => {
549
+ try {
550
+ if (nudge.x === 0 && nudge.y === 0) localStorage.removeItem(NUDGE_KEY);
551
+ else localStorage.setItem(NUDGE_KEY, JSON.stringify(nudge));
552
+ } catch {
553
+ }
554
+ };
555
+ const applyNudge = () => {
556
+ if (wrapper) wrapper.style.translate = `${nudge.x}px ${nudge.y}px`;
557
+ };
558
+ const clampOffset = (x, y) => {
559
+ x = clampMax(x);
560
+ y = clampMax(y);
561
+ if (wrapper) {
562
+ const r = wrapper.getBoundingClientRect();
563
+ const pad = 4;
564
+ const baseLeft = r.left - nudge.x;
565
+ const baseTop = r.top - nudge.y;
566
+ const vw = window.innerWidth || 0;
567
+ const vh = window.innerHeight || 0;
568
+ if (r.width > 0 && vw > 0) x = Math.max(pad - baseLeft, Math.min(vw - pad - r.width - baseLeft, x));
569
+ if (r.height > 0 && vh > 0) y = Math.max(pad - baseTop, Math.min(vh - pad - r.height - baseTop, y));
570
+ }
571
+ return { x, y };
572
+ };
573
+ let dragStart = null;
574
+ let didDrag = false;
575
+ const DRAG_THRESHOLD = 4;
576
+ const onPointerDown = (e) => {
577
+ if (nudgeMax <= 0 || e.button !== 0) return;
578
+ dragStart = { px: e.clientX, py: e.clientY, ox: nudge.x, oy: nudge.y };
579
+ didDrag = false;
580
+ trigger?.setPointerCapture?.(e.pointerId);
581
+ };
582
+ const onPointerMove = (e) => {
583
+ if (!dragStart) return;
584
+ const dx = e.clientX - dragStart.px;
585
+ const dy = e.clientY - dragStart.py;
586
+ if (!didDrag && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
587
+ didDrag = true;
588
+ nudge = clampOffset(dragStart.ox + dx, dragStart.oy + dy);
589
+ applyNudge();
590
+ e.preventDefault();
591
+ };
592
+ const onPointerUp = () => {
593
+ if (!dragStart) return;
594
+ dragStart = null;
595
+ if (didDrag) {
596
+ saveNudge();
597
+ suppressClick = true;
598
+ }
599
+ };
600
+ const onTriggerKey = (e) => {
601
+ if (nudgeMax <= 0) return;
602
+ const step = e.shiftKey ? 1 : 10;
603
+ let dx = 0;
604
+ let dy = 0;
605
+ if (e.key === "ArrowLeft") dx = -step;
606
+ else if (e.key === "ArrowRight") dx = step;
607
+ else if (e.key === "ArrowUp") dy = -step;
608
+ else if (e.key === "ArrowDown") dy = step;
609
+ else return;
610
+ e.preventDefault();
611
+ nudge = clampOffset(nudge.x + dx, nudge.y + dy);
612
+ applyNudge();
613
+ saveNudge();
614
+ };
401
615
  const onMove = (e) => {
402
616
  if (!state.readingGuide && !state.readingMask) return;
403
617
  const y = e.touches?.[0]?.clientY ?? e.clientY;
@@ -411,7 +625,7 @@ function bindPanelBehavior(root, opts = {}) {
411
625
  if (mask) mask.style.setProperty("--oks-mask-y", `${y}px`);
412
626
  }
413
627
  };
414
- trigger.addEventListener("click", onTriggerClick);
628
+ trigger?.addEventListener("click", onTriggerClick);
415
629
  closeBtn.addEventListener("click", closePanel);
416
630
  resetBtn.addEventListener("click", onReset);
417
631
  for (const btn of opts$) btn.addEventListener("click", onOptClick);
@@ -419,9 +633,19 @@ function bindPanelBehavior(root, opts = {}) {
419
633
  document.addEventListener("keydown", onKeyDown);
420
634
  document.addEventListener("mousemove", onMove);
421
635
  document.addEventListener("touchmove", onMove, { passive: true });
636
+ if (nudgeMax > 0 && trigger) {
637
+ loadNudge();
638
+ applyNudge();
639
+ trigger.style.cursor = "grab";
640
+ trigger.style.touchAction = "none";
641
+ trigger.addEventListener("pointerdown", onPointerDown);
642
+ document.addEventListener("pointermove", onPointerMove);
643
+ document.addEventListener("pointerup", onPointerUp);
644
+ trigger.addEventListener("keydown", onTriggerKey);
645
+ }
422
646
  applyState();
423
- return () => {
424
- trigger.removeEventListener("click", onTriggerClick);
647
+ const dispose = () => {
648
+ trigger?.removeEventListener("click", onTriggerClick);
425
649
  closeBtn.removeEventListener("click", closePanel);
426
650
  resetBtn.removeEventListener("click", onReset);
427
651
  for (const btn of opts$) btn.removeEventListener("click", onOptClick);
@@ -429,7 +653,12 @@ function bindPanelBehavior(root, opts = {}) {
429
653
  document.removeEventListener("keydown", onKeyDown);
430
654
  document.removeEventListener("mousemove", onMove);
431
655
  document.removeEventListener("touchmove", onMove);
656
+ trigger?.removeEventListener("pointerdown", onPointerDown);
657
+ document.removeEventListener("pointermove", onPointerMove);
658
+ document.removeEventListener("pointerup", onPointerUp);
659
+ trigger?.removeEventListener("keydown", onTriggerKey);
432
660
  };
661
+ return Object.assign(dispose, { open: openPanel, close: closePanel, toggle: togglePanel });
433
662
  }
434
663
  function ensureOverlay() {
435
664
  let el = document.getElementById("oks-overlay-gray");
@@ -484,6 +713,12 @@ var PANEL_CSS = `
484
713
  width: 60%;
485
714
  height: 60%;
486
715
  }
716
+ /* Porthole preset: a brushed-metal ring framing the standard glyph. The glyph
717
+ itself is untouched (discoverability), only the button gets the frame.
718
+ Hosts wanting a bespoke frame can target ::part(trigger) from light DOM. */
719
+ .oks-access-btn[data-trigger-icon="porthole"] {
720
+ box-shadow: 0 0 0 4px #b0bec5, 0 0 0 7px #546e7a, 0 6px 18px rgba(0,0,0,0.3);
721
+ }
487
722
  .oks-active-badge {
488
723
  position: absolute;
489
724
  top: -2px;
@@ -836,7 +1071,19 @@ body.oks-a11y-bigtargets summary:not(oksigenia-access-panel):not(oksigenia-acces
836
1071
  `;
837
1072
 
838
1073
  // src/web-component.ts
839
- var OBSERVED = ["locale", "position", "position-mobile", "trigger-icon", "storage-key"];
1074
+ var OBSERVED = [
1075
+ "locale",
1076
+ "position",
1077
+ "position-mobile",
1078
+ "trigger-icon",
1079
+ "storage-key",
1080
+ "controls",
1081
+ "exclude",
1082
+ "trigger",
1083
+ "effects-exclude",
1084
+ "nudge",
1085
+ "presets"
1086
+ ];
840
1087
  var STYLE_ID = "oksigenia-access-effects";
841
1088
  var FILTERS_ID = "oksigenia-access-filters";
842
1089
  var GUIDE_ID = "oks-reading-guide";
@@ -871,18 +1118,23 @@ function ensureGlobalStyles() {
871
1118
  }
872
1119
  var VALID_POSITIONS = [
873
1120
  "top-left",
1121
+ "top-center",
874
1122
  "top-right",
875
1123
  "mid-left",
1124
+ "mid-center",
876
1125
  "mid-right",
877
1126
  "bottom-left",
1127
+ "bottom-center",
878
1128
  "bottom-right"
879
1129
  ];
880
- var VALID_ICONS = ["vitruvian", "wheelchair", "eye", "universal"];
1130
+ var VALID_ICONS = ["vitruvian", "wheelchair", "eye", "universal", "porthole"];
1131
+ var fxSeq = 0;
881
1132
  var OksigeniaAccessPanelElement = class extends HTMLElement {
882
1133
  static get observedAttributes() {
883
1134
  return OBSERVED;
884
1135
  }
885
- _dispose = null;
1136
+ _controller = null;
1137
+ _fxId = `oks-access-fx-${++fxSeq}`;
886
1138
  constructor() {
887
1139
  super();
888
1140
  this.attachShadow({ mode: "open" });
@@ -892,12 +1144,24 @@ var OksigeniaAccessPanelElement = class extends HTMLElement {
892
1144
  this.render();
893
1145
  }
894
1146
  disconnectedCallback() {
895
- this._dispose?.();
896
- this._dispose = null;
1147
+ this._controller?.();
1148
+ this._controller = null;
1149
+ if (typeof document !== "undefined") document.getElementById(this._fxId)?.remove();
897
1150
  }
898
1151
  attributeChangedCallback() {
899
1152
  if (this.isConnected) this.render();
900
1153
  }
1154
+ /** Imperative API (#2): open / close / toggle the panel from the host.
1155
+ * Useful with `trigger="none"`, where the host drives its own button. */
1156
+ open() {
1157
+ this._controller?.open();
1158
+ }
1159
+ close() {
1160
+ this._controller?.close();
1161
+ }
1162
+ toggle() {
1163
+ this._controller?.toggle();
1164
+ }
901
1165
  getPosition() {
902
1166
  const attr = this.getAttribute("position") ?? "mid-left";
903
1167
  return VALID_POSITIONS.includes(attr) ? attr : "mid-left";
@@ -913,22 +1177,59 @@ var OksigeniaAccessPanelElement = class extends HTMLElement {
913
1177
  getLocale() {
914
1178
  return this.getAttribute("locale") ?? (typeof navigator !== "undefined" ? navigator.language : "en");
915
1179
  }
1180
+ /** `nudge` (bare) ⇒ default 80px cap; `nudge="50"` ⇒ 50; absent/invalid ⇒ off. */
1181
+ getNudgeMax() {
1182
+ if (!this.hasAttribute("nudge")) return 0;
1183
+ const raw = this.getAttribute("nudge");
1184
+ if (raw == null || raw.trim() === "") return 80;
1185
+ const n = parseInt(raw, 10);
1186
+ return Number.isFinite(n) && n > 0 ? n : 0;
1187
+ }
1188
+ /** Inject the per-instance effects-exclude rule (#4): under high-contrast,
1189
+ * drop the destructive filter from selectors the host marks as essential
1190
+ * media (video, canvas, scientific imagery). Grayscale is handled by not
1191
+ * offering the control (curation), not here. */
1192
+ updateEffectsExclude() {
1193
+ if (typeof document === "undefined") return;
1194
+ const sel = (this.getAttribute("effects-exclude") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
1195
+ const existing = document.getElementById(this._fxId);
1196
+ if (sel.length === 0) {
1197
+ existing?.remove();
1198
+ return;
1199
+ }
1200
+ const style = existing ?? document.createElement("style");
1201
+ if (!existing) {
1202
+ style.id = this._fxId;
1203
+ document.head.appendChild(style);
1204
+ }
1205
+ const list = sel.join(", ");
1206
+ style.textContent = `body.oks-a11y-contrast :is(${list}), body.oks-a11y-contrast :is(${list}) * { filter: none !important; }`;
1207
+ }
916
1208
  render() {
917
1209
  const shadow = this.shadowRoot;
918
1210
  if (!shadow) return;
919
- this._dispose?.();
1211
+ this._controller?.();
920
1212
  const position = this.getPosition();
921
1213
  const positionMobile = this.getPositionMobile();
1214
+ const enabled = resolveEnabledControls(this.getAttribute("controls"), this.getAttribute("exclude"));
1215
+ const showTrigger = this.getAttribute("trigger") !== "none";
1216
+ const showPresets = (this.getAttribute("presets") ?? "").trim().toLowerCase() !== "none";
922
1217
  const html = buildPanelHtml({
923
1218
  t: getTranslation(this.getLocale()),
924
1219
  triggerIcon: this.getTriggerIcon(),
925
- position
1220
+ position,
1221
+ enabled,
1222
+ showTrigger,
1223
+ showPresets
926
1224
  });
927
1225
  shadow.innerHTML = `<style>${PANEL_CSS}${positionCss(position, positionMobile)}</style>${html}`;
928
- this._dispose = bindPanelBehavior(shadow, {
1226
+ this._controller = bindPanelBehavior(shadow, {
929
1227
  storageKey: this.getAttribute("storage-key") ?? void 0,
930
- locale: this.getLocale()
1228
+ locale: this.getLocale(),
1229
+ enabled,
1230
+ nudgeMax: this.getNudgeMax()
931
1231
  });
1232
+ this.updateEffectsExclude();
932
1233
  }
933
1234
  };
934
1235
  if (typeof customElements !== "undefined" && !customElements.get("oksigenia-access-panel")) {