@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.
package/dist/index.js CHANGED
@@ -37,75 +37,173 @@ var ICON_COLORBLIND = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12
37
37
  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>';
38
38
  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>';
39
39
  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>';
40
+ 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>';
40
41
  var TRIGGER_ICONS = {
41
42
  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>',
42
43
  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>',
43
44
  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>',
44
- 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>'
45
+ universal: UNIVERSAL_GLYPH,
46
+ porthole: UNIVERSAL_GLYPH
45
47
  };
46
48
  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>';
47
49
  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>';
48
50
 
51
+ // src/controls.ts
52
+ var ALL_CONTROLS = [
53
+ "text-size",
54
+ "line-height",
55
+ "text-align",
56
+ "readable-font",
57
+ "dyslexia-font",
58
+ "letter-spacing",
59
+ "contrast",
60
+ "grayscale",
61
+ "hide-images",
62
+ "highlight-links",
63
+ "colorblind",
64
+ "reading-guide",
65
+ "reading-mask",
66
+ "big-cursor",
67
+ "big-targets",
68
+ "pause-anim",
69
+ "focus"
70
+ ];
71
+ var CONTROL_STATE_KEY = {
72
+ "text-size": "zoom",
73
+ "line-height": "lh",
74
+ "text-align": "align",
75
+ "readable-font": "font",
76
+ "dyslexia-font": "dyslexia",
77
+ "letter-spacing": "ls",
78
+ "contrast": "contrast",
79
+ "grayscale": "grayOverlay",
80
+ "hide-images": "hideImages",
81
+ "highlight-links": "highlightLinks",
82
+ "colorblind": "colorblind",
83
+ "reading-guide": "readingGuide",
84
+ "reading-mask": "readingMask",
85
+ "big-cursor": "bigCursor",
86
+ "big-targets": "bigTargets",
87
+ "pause-anim": "pauseAnim",
88
+ "focus": "focusOutline"
89
+ };
90
+ var STATE_KEY_CONTROL = Object.fromEntries(
91
+ Object.entries(CONTROL_STATE_KEY).map(([control, key]) => [key, control])
92
+ );
93
+ var PRESETS = {
94
+ lowvision: { zoom: 2, contrast: true, highlightLinks: true, bigCursor: true, focusOutline: true },
95
+ dyslexia: { dyslexia: true, lh: 2, ls: 2, readingGuide: true },
96
+ motor: { bigCursor: true, bigTargets: true, focusOutline: true },
97
+ calm: { hideImages: true, pauseAnim: true }
98
+ };
99
+ var PRESET_IDS = ["lowvision", "dyslexia", "motor", "calm"];
100
+ function presetControlIds(id) {
101
+ return Object.keys(PRESETS[id]).map((k) => STATE_KEY_CONTROL[k]);
102
+ }
103
+ function resolveEnabledControls(controlsAttr, excludeAttr) {
104
+ const valid = new Set(ALL_CONTROLS);
105
+ const parse = (raw) => (raw ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => valid.has(s));
106
+ const whitelist = controlsAttr != null ? parse(controlsAttr) : null;
107
+ let enabled = whitelist && whitelist.length > 0 ? new Set(whitelist) : new Set(ALL_CONTROLS);
108
+ if (excludeAttr != null) {
109
+ for (const id of parse(excludeAttr)) enabled.delete(id);
110
+ }
111
+ return enabled;
112
+ }
113
+ function filterPresetForEnabled(id, enabled) {
114
+ const out = {};
115
+ for (const [k, v] of Object.entries(PRESETS[id])) {
116
+ if (enabled.has(STATE_KEY_CONTROL[k])) out[k] = v;
117
+ }
118
+ return out;
119
+ }
120
+ function presetIsAvailable(id, enabled) {
121
+ return presetControlIds(id).filter((c) => enabled.has(c)).length >= 2;
122
+ }
123
+
49
124
  // src/render.ts
125
+ var PRESET_ICON = {
126
+ lowvision: ICON_CONTRAST,
127
+ dyslexia: ICON_DYSLEXIA,
128
+ motor: ICON_CURSOR,
129
+ calm: ICON_HIDE
130
+ };
50
131
  function buildPanelHtml(opts) {
51
132
  const { t, triggerIcon } = opts;
133
+ const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
134
+ const showTrigger = opts.showTrigger ?? true;
135
+ const showPresets = opts.showPresets ?? true;
52
136
  const trig = TRIGGER_ICONS[triggerIcon];
53
- const grid = (rows) => `<div class="oks-access-grid">${rows.join("")}</div>`;
54
- const multi = (action, prefix, levels, label, icon, full = false) => {
137
+ const multi = (id, prefix, levels, label, icon, full = false) => {
138
+ if (!enabled.has(id)) return "";
139
+ const action = id === "colorblind" ? "colorblind" : "multi";
55
140
  const fullClass = full ? " full-width" : "";
56
141
  const dots = Array.from({ length: levels }, () => "<span></span>").join("");
57
- 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>`;
142
+ 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>`;
143
+ };
144
+ const toggle = (id, klass, label, icon) => {
145
+ if (!enabled.has(id)) return "";
146
+ 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>`;
147
+ };
148
+ const overlay = (id, target, label, icon) => {
149
+ if (!enabled.has(id)) return "";
150
+ 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>`;
151
+ };
152
+ const simple = (id, action, label, icon) => {
153
+ if (!enabled.has(id)) return "";
154
+ 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>`;
155
+ };
156
+ 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>`;
157
+ const section = (title, rows) => {
158
+ const kept = rows.filter(Boolean);
159
+ if (kept.length === 0) return "";
160
+ return `<h4 class="oks-access-title">${escapeHtml(title)}</h4><div class="oks-access-grid">${kept.join("")}</div>`;
161
+ };
162
+ const presetLabels = {
163
+ lowvision: t.pLow,
164
+ dyslexia: t.pDys,
165
+ motor: t.pMot,
166
+ calm: t.pCalm
58
167
  };
59
- 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>`;
60
- 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>`;
61
- 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>`;
62
- 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>`;
63
- 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>`;
64
- return `
168
+ const presetButtons = showPresets ? PRESET_IDS.filter((id) => presetIsAvailable(id, enabled)).map((id) => preset(id, presetLabels[id])) : [];
169
+ const presetsBlock = presetButtons.length ? `<h4 class="oks-access-title">${escapeHtml(t.presets)}</h4><div class="oks-access-presets">${presetButtons.join("")}</div>` : "";
170
+ const triggerBlock = showTrigger ? `
65
171
  <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
66
- <button class="oks-access-btn" id="oks-trigger" aria-label="${escapeAttr(t.title)}" aria-expanded="false" aria-controls="oks-panel" type="button">
172
+ <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">
67
173
  ${trig}
68
174
  </button>
69
175
  <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>
70
- </div>
176
+ </div>` : "";
177
+ return `${triggerBlock}
71
178
  <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
72
179
  <div class="oks-access-header">
73
180
  <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
74
181
  <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
75
182
  </div>
76
183
  <div class="oks-access-content">
77
- <h4 class="oks-access-title">${escapeHtml(t.presets)}</h4>
78
- <div class="oks-access-presets">
79
- ${preset("lowvision", t.pLow, ICON_CONTRAST)}
80
- ${preset("dyslexia", t.pDys, ICON_DYSLEXIA)}
81
- ${preset("motor", t.pMot, ICON_CURSOR)}
82
- ${preset("calm", t.pCalm, ICON_HIDE)}
83
- </div>
84
- <h4 class="oks-access-title">${escapeHtml(t.txt)}</h4>
85
- ${grid([
86
- multi("multi", "oks-zoom", 4, t.size, ICON_TXT),
87
- multi("multi", "oks-lh", 3, t.lh, ICON_LH),
88
- multi("multi", "oks-align", 3, t.align, ICON_ALIGN),
89
- toggle("oks-a11y-font", t.font, ICON_FONT),
90
- toggle("oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
91
- multi("multi", "oks-ls", 3, t.ls, ICON_LS)
184
+ ${presetsBlock}
185
+ ${section(t.txt, [
186
+ multi("text-size", "oks-zoom", 4, t.size, ICON_TXT),
187
+ multi("line-height", "oks-lh", 3, t.lh, ICON_LH),
188
+ multi("text-align", "oks-align", 3, t.align, ICON_ALIGN),
189
+ toggle("readable-font", "oks-a11y-font", t.font, ICON_FONT),
190
+ toggle("dyslexia-font", "oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
191
+ multi("letter-spacing", "oks-ls", 3, t.ls, ICON_LS)
92
192
  ])}
93
- <h4 class="oks-access-title">${escapeHtml(t.vis)}</h4>
94
- ${grid([
95
- toggle("oks-a11y-contrast", t.contrast, ICON_CONTRAST),
96
- overlay("oks-overlay-gray", t.gray, ICON_GRAY),
97
- toggle("oks-a11y-hide", t.hide, ICON_HIDE),
98
- toggle("oks-a11y-links", t.links, ICON_LINK),
193
+ ${section(t.vis, [
194
+ toggle("contrast", "oks-a11y-contrast", t.contrast, ICON_CONTRAST),
195
+ overlay("grayscale", "oks-overlay-gray", t.gray, ICON_GRAY),
196
+ toggle("hide-images", "oks-a11y-hide", t.hide, ICON_HIDE),
197
+ toggle("highlight-links", "oks-a11y-links", t.links, ICON_LINK),
99
198
  multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, true)
100
199
  ])}
101
- <h4 class="oks-access-title">${escapeHtml(t.ori)}</h4>
102
- ${grid([
103
- guide(t.guide, ICON_GUIDE),
104
- mask(t.mask, ICON_MASK),
105
- toggle("oks-big-cursor", t.cursor, ICON_CURSOR),
106
- toggle("oks-a11y-bigtargets", t.targets, ICON_TARGETS),
107
- toggle("oks-a11y-pause", t.pause, ICON_PAUSE),
108
- toggle("oks-a11y-focus", t.focus, ICON_FOCUS)
200
+ ${section(t.ori, [
201
+ simple("reading-guide", "guide", t.guide, ICON_GUIDE),
202
+ simple("reading-mask", "mask", t.mask, ICON_MASK),
203
+ toggle("big-cursor", "oks-big-cursor", t.cursor, ICON_CURSOR),
204
+ toggle("big-targets", "oks-a11y-bigtargets", t.targets, ICON_TARGETS),
205
+ toggle("pause-anim", "oks-a11y-pause", t.pause, ICON_PAUSE),
206
+ toggle("focus", "oks-a11y-focus", t.focus, ICON_FOCUS)
109
207
  ])}
110
208
  </div>
111
209
  <div class="oks-access-footer">
@@ -130,14 +228,20 @@ function positionRules(position) {
130
228
  switch (position) {
131
229
  case "top-left":
132
230
  return { wrap: "top: 20px; left: 20px;", panel: "top: 80px; left: 20px;" };
231
+ case "top-center":
232
+ return { wrap: "top: 20px; left: 50%; transform: translateX(-50%);", panel: "top: 80px; left: 50%; transform: translateX(-50%);" };
133
233
  case "top-right":
134
234
  return { wrap: "top: 20px; right: 20px;", panel: "top: 80px; right: 20px;" };
135
235
  case "mid-left":
136
236
  return { wrap: "top: 50%; left: 20px; transform: translateY(-50%);", panel: "top: 50%; left: 90px; transform: translateY(-50%);" };
237
+ case "mid-center":
238
+ return { wrap: "top: 50%; left: 50%; transform: translate(-50%, -50%);", panel: "top: 50%; left: 50%; transform: translate(-50%, -50%);" };
137
239
  case "mid-right":
138
240
  return { wrap: "top: 50%; right: 20px; transform: translateY(-50%);", panel: "top: 50%; right: 90px; transform: translateY(-50%);" };
139
241
  case "bottom-left":
140
242
  return { wrap: "bottom: 20px; left: 20px;", panel: "bottom: 100px; left: 20px;" };
243
+ case "bottom-center":
244
+ return { wrap: "bottom: 20px; left: 50%; transform: translateX(-50%);", panel: "bottom: 100px; left: 50%; transform: translateX(-50%);" };
141
245
  case "bottom-right":
142
246
  return { wrap: "bottom: 20px; right: 20px;", panel: "bottom: 100px; right: 20px;" };
143
247
  }
@@ -231,38 +335,45 @@ var TOGGLE_KEYS = {
231
335
  "oks-a11y-focus": "focusOutline",
232
336
  "oks-a11y-bigtargets": "bigTargets"
233
337
  };
234
- var PRESETS = {
235
- lowvision: { zoom: 2, contrast: true, highlightLinks: true, bigCursor: true, focusOutline: true },
236
- dyslexia: { dyslexia: true, lh: 2, ls: 2, readingGuide: true },
237
- motor: { bigCursor: true, bigTargets: true, focusOutline: true },
238
- calm: { hideImages: true, pauseAnim: true }
239
- };
338
+ function noopController() {
339
+ return Object.assign(() => {
340
+ }, { open() {
341
+ }, close() {
342
+ }, toggle() {
343
+ } });
344
+ }
345
+ function deepActiveElement() {
346
+ let a = document.activeElement;
347
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
348
+ return a;
349
+ }
240
350
  function bindPanelBehavior(root, opts = {}) {
241
351
  const storageKey = opts.storageKey ?? "oksiacSettings";
242
352
  const t = getTranslation(opts.locale ?? "en");
353
+ const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
354
+ const nudgeMax = Math.max(0, opts.nudgeMax ?? 0);
243
355
  const trigger = root.getElementById("oks-trigger");
244
356
  const panel = root.getElementById("oks-panel");
245
357
  const closeBtn = root.getElementById("oks-close");
246
358
  const resetBtn = root.getElementById("oks-reset");
247
359
  const wrapper = root.getElementById("oks-wrapper");
248
360
  const opts$ = Array.from(root.querySelectorAll(".oks-access-opt, .oks-preset"));
249
- if (!trigger || !panel || !closeBtn || !resetBtn || !wrapper) {
250
- return () => {
251
- };
361
+ if (!panel || !closeBtn || !resetBtn) {
362
+ return noopController();
252
363
  }
253
364
  let state = loadState(storageKey);
254
365
  function applyState() {
255
366
  const body = document.body;
256
- const root2 = document.documentElement;
367
+ const rootEl = document.documentElement;
257
368
  for (const cls of Array.from(body.classList)) {
258
369
  if (cls.startsWith("oks-")) body.classList.remove(cls);
259
370
  }
260
- [1, 2, 3].forEach((l) => root2.classList.remove(`oks-colorblind-${l}`));
371
+ [1, 2, 3].forEach((l) => rootEl.classList.remove(`oks-colorblind-${l}`));
261
372
  if (state.zoom > 0) body.classList.add(`oks-zoom-${state.zoom}`);
262
373
  if (state.lh > 0) body.classList.add(`oks-lh-${state.lh}`);
263
374
  if (state.align > 0) body.classList.add(`oks-align-${state.align}`);
264
375
  if (state.ls > 0) body.classList.add(`oks-ls-${state.ls}`);
265
- if (state.colorblind > 0) root2.classList.add(`oks-colorblind-${state.colorblind}`);
376
+ if (state.colorblind > 0) rootEl.classList.add(`oks-colorblind-${state.colorblind}`);
266
377
  if (state.font) body.classList.add("oks-a11y-font");
267
378
  if (state.dyslexia) body.classList.add("oks-dyslexia");
268
379
  if (state.contrast) body.classList.add("oks-a11y-contrast");
@@ -339,9 +450,8 @@ function bindPanelBehavior(root, opts = {}) {
339
450
  } else if (action === "mask") {
340
451
  state.readingMask = !state.readingMask;
341
452
  } else if (action === "preset") {
342
- const id = btn.getAttribute("data-preset") ?? "";
343
- const preset = PRESETS[id];
344
- if (preset) Object.assign(state, preset);
453
+ const id = btn.getAttribute("data-preset");
454
+ if (id && PRESETS[id]) Object.assign(state, filterPresetForEnabled(id, enabled));
345
455
  btn.classList.add("is-flashing");
346
456
  setTimeout(() => btn.classList.remove("is-flashing"), 250);
347
457
  }
@@ -353,28 +463,50 @@ function bindPanelBehavior(root, opts = {}) {
353
463
  applyState();
354
464
  saveState(storageKey, state);
355
465
  };
466
+ let opener = null;
467
+ let ignoreDocClose = false;
356
468
  const openPanel = () => {
469
+ if (panel.classList.contains("is-open")) return;
470
+ opener = deepActiveElement();
357
471
  panel.classList.add("is-open");
358
472
  panel.removeAttribute("inert");
359
- trigger.setAttribute("aria-expanded", "true");
473
+ trigger?.setAttribute("aria-expanded", "true");
474
+ ignoreDocClose = true;
475
+ setTimeout(() => {
476
+ ignoreDocClose = false;
477
+ }, 0);
360
478
  const first = panel.querySelector("button:not([disabled])");
361
479
  first?.focus();
362
480
  };
363
481
  const closePanel = () => {
482
+ if (!panel.classList.contains("is-open")) return;
364
483
  panel.classList.remove("is-open");
365
484
  panel.setAttribute("inert", "");
366
- trigger.setAttribute("aria-expanded", "false");
367
- trigger.focus();
485
+ trigger?.setAttribute("aria-expanded", "false");
486
+ if (trigger) trigger.focus();
487
+ else if (opener?.isConnected) opener.focus();
488
+ opener = null;
368
489
  };
369
- const onTriggerClick = (e) => {
370
- e.stopPropagation();
490
+ const togglePanel = () => {
371
491
  if (panel.classList.contains("is-open")) closePanel();
372
492
  else openPanel();
373
493
  };
494
+ let suppressClick = false;
495
+ const onTriggerClick = (e) => {
496
+ e.stopPropagation();
497
+ if (suppressClick) {
498
+ suppressClick = false;
499
+ return;
500
+ }
501
+ togglePanel();
502
+ };
374
503
  const onDocClick = (e) => {
375
504
  if (!panel.classList.contains("is-open")) return;
505
+ if (ignoreDocClose) return;
376
506
  const path = e.composedPath();
377
- if (path.includes(panel) || path.includes(trigger) || path.includes(wrapper)) return;
507
+ if (path.includes(panel)) return;
508
+ if (trigger && path.includes(trigger)) return;
509
+ if (wrapper && path.includes(wrapper)) return;
378
510
  closePanel();
379
511
  };
380
512
  const onKeyDown = (e) => {
@@ -401,6 +533,88 @@ function bindPanelBehavior(root, opts = {}) {
401
533
  }
402
534
  }
403
535
  };
536
+ const NUDGE_KEY = `${storageKey}::pos`;
537
+ const clampMax = (v) => Math.max(-nudgeMax, Math.min(nudgeMax, v));
538
+ let nudge = { x: 0, y: 0 };
539
+ const loadNudge = () => {
540
+ if (nudgeMax <= 0) return;
541
+ try {
542
+ const raw = localStorage.getItem(NUDGE_KEY);
543
+ if (!raw) return;
544
+ const p = JSON.parse(raw);
545
+ if (typeof p?.x === "number" && typeof p?.y === "number") {
546
+ nudge = { x: clampMax(p.x), y: clampMax(p.y) };
547
+ }
548
+ } catch {
549
+ }
550
+ };
551
+ const saveNudge = () => {
552
+ try {
553
+ if (nudge.x === 0 && nudge.y === 0) localStorage.removeItem(NUDGE_KEY);
554
+ else localStorage.setItem(NUDGE_KEY, JSON.stringify(nudge));
555
+ } catch {
556
+ }
557
+ };
558
+ const applyNudge = () => {
559
+ if (wrapper) wrapper.style.translate = `${nudge.x}px ${nudge.y}px`;
560
+ };
561
+ const clampOffset = (x, y) => {
562
+ x = clampMax(x);
563
+ y = clampMax(y);
564
+ if (wrapper) {
565
+ const r = wrapper.getBoundingClientRect();
566
+ const pad = 4;
567
+ const baseLeft = r.left - nudge.x;
568
+ const baseTop = r.top - nudge.y;
569
+ const vw = window.innerWidth || 0;
570
+ const vh = window.innerHeight || 0;
571
+ if (r.width > 0 && vw > 0) x = Math.max(pad - baseLeft, Math.min(vw - pad - r.width - baseLeft, x));
572
+ if (r.height > 0 && vh > 0) y = Math.max(pad - baseTop, Math.min(vh - pad - r.height - baseTop, y));
573
+ }
574
+ return { x, y };
575
+ };
576
+ let dragStart = null;
577
+ let didDrag = false;
578
+ const DRAG_THRESHOLD = 4;
579
+ const onPointerDown = (e) => {
580
+ if (nudgeMax <= 0 || e.button !== 0) return;
581
+ dragStart = { px: e.clientX, py: e.clientY, ox: nudge.x, oy: nudge.y };
582
+ didDrag = false;
583
+ trigger?.setPointerCapture?.(e.pointerId);
584
+ };
585
+ const onPointerMove = (e) => {
586
+ if (!dragStart) return;
587
+ const dx = e.clientX - dragStart.px;
588
+ const dy = e.clientY - dragStart.py;
589
+ if (!didDrag && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
590
+ didDrag = true;
591
+ nudge = clampOffset(dragStart.ox + dx, dragStart.oy + dy);
592
+ applyNudge();
593
+ e.preventDefault();
594
+ };
595
+ const onPointerUp = () => {
596
+ if (!dragStart) return;
597
+ dragStart = null;
598
+ if (didDrag) {
599
+ saveNudge();
600
+ suppressClick = true;
601
+ }
602
+ };
603
+ const onTriggerKey = (e) => {
604
+ if (nudgeMax <= 0) return;
605
+ const step = e.shiftKey ? 1 : 10;
606
+ let dx = 0;
607
+ let dy = 0;
608
+ if (e.key === "ArrowLeft") dx = -step;
609
+ else if (e.key === "ArrowRight") dx = step;
610
+ else if (e.key === "ArrowUp") dy = -step;
611
+ else if (e.key === "ArrowDown") dy = step;
612
+ else return;
613
+ e.preventDefault();
614
+ nudge = clampOffset(nudge.x + dx, nudge.y + dy);
615
+ applyNudge();
616
+ saveNudge();
617
+ };
404
618
  const onMove = (e) => {
405
619
  if (!state.readingGuide && !state.readingMask) return;
406
620
  const y = e.touches?.[0]?.clientY ?? e.clientY;
@@ -414,7 +628,7 @@ function bindPanelBehavior(root, opts = {}) {
414
628
  if (mask) mask.style.setProperty("--oks-mask-y", `${y}px`);
415
629
  }
416
630
  };
417
- trigger.addEventListener("click", onTriggerClick);
631
+ trigger?.addEventListener("click", onTriggerClick);
418
632
  closeBtn.addEventListener("click", closePanel);
419
633
  resetBtn.addEventListener("click", onReset);
420
634
  for (const btn of opts$) btn.addEventListener("click", onOptClick);
@@ -422,9 +636,19 @@ function bindPanelBehavior(root, opts = {}) {
422
636
  document.addEventListener("keydown", onKeyDown);
423
637
  document.addEventListener("mousemove", onMove);
424
638
  document.addEventListener("touchmove", onMove, { passive: true });
639
+ if (nudgeMax > 0 && trigger) {
640
+ loadNudge();
641
+ applyNudge();
642
+ trigger.style.cursor = "grab";
643
+ trigger.style.touchAction = "none";
644
+ trigger.addEventListener("pointerdown", onPointerDown);
645
+ document.addEventListener("pointermove", onPointerMove);
646
+ document.addEventListener("pointerup", onPointerUp);
647
+ trigger.addEventListener("keydown", onTriggerKey);
648
+ }
425
649
  applyState();
426
- return () => {
427
- trigger.removeEventListener("click", onTriggerClick);
650
+ const dispose = () => {
651
+ trigger?.removeEventListener("click", onTriggerClick);
428
652
  closeBtn.removeEventListener("click", closePanel);
429
653
  resetBtn.removeEventListener("click", onReset);
430
654
  for (const btn of opts$) btn.removeEventListener("click", onOptClick);
@@ -432,7 +656,12 @@ function bindPanelBehavior(root, opts = {}) {
432
656
  document.removeEventListener("keydown", onKeyDown);
433
657
  document.removeEventListener("mousemove", onMove);
434
658
  document.removeEventListener("touchmove", onMove);
659
+ trigger?.removeEventListener("pointerdown", onPointerDown);
660
+ document.removeEventListener("pointermove", onPointerMove);
661
+ document.removeEventListener("pointerup", onPointerUp);
662
+ trigger?.removeEventListener("keydown", onTriggerKey);
435
663
  };
664
+ return Object.assign(dispose, { open: openPanel, close: closePanel, toggle: togglePanel });
436
665
  }
437
666
  function ensureOverlay() {
438
667
  let el = document.getElementById("oks-overlay-gray");
@@ -487,6 +716,12 @@ var PANEL_CSS = `
487
716
  width: 60%;
488
717
  height: 60%;
489
718
  }
719
+ /* Porthole preset: a brushed-metal ring framing the standard glyph. The glyph
720
+ itself is untouched (discoverability), only the button gets the frame.
721
+ Hosts wanting a bespoke frame can target ::part(trigger) from light DOM. */
722
+ .oks-access-btn[data-trigger-icon="porthole"] {
723
+ box-shadow: 0 0 0 4px #b0bec5, 0 0 0 7px #546e7a, 0 6px 18px rgba(0,0,0,0.3);
724
+ }
490
725
  .oks-active-badge {
491
726
  position: absolute;
492
727
  top: -2px;
@@ -838,6 +1073,6 @@ body.oks-a11y-bigtargets summary:not(oksigenia-access-panel):not(oksigenia-acces
838
1073
  }
839
1074
  `;
840
1075
 
841
- export { COLORBLIND_FILTERS_SVG, DEFAULT_STATE, EFFECT_CSS, PANEL_CSS, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, saveState, supportedLocales };
1076
+ export { ALL_CONTROLS, COLORBLIND_FILTERS_SVG, DEFAULT_STATE, EFFECT_CSS, PANEL_CSS, PRESETS, PRESET_IDS, bindPanelBehavior, buildPanelHtml, getTranslation, isStateEmpty, loadState, positionCss, resolveEnabledControls, saveState, supportedLocales };
842
1077
  //# sourceMappingURL=index.js.map
843
1078
  //# sourceMappingURL=index.js.map