@oksigenia/access-panel 0.6.0 → 0.7.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.
@@ -60,13 +60,7 @@ function resolveEnabledControls(controlsAttr, excludeAttr) {
60
60
  }
61
61
  return enabled;
62
62
  }
63
- var NON_SCOPABLE_CONTROLS = [
64
- "grayscale",
65
- "reading-guide",
66
- "reading-mask",
67
- "big-cursor",
68
- "colorblind"
69
- ];
63
+ var NON_SCOPABLE_CONTROLS = ["grayscale", "colorblind"];
70
64
  function scopedControls(enabled) {
71
65
  const out = new Set(enabled);
72
66
  for (const id of NON_SCOPABLE_CONTROLS) out.delete(id);
@@ -124,6 +118,7 @@ function buildPanelHtml(opts) {
124
118
  const enabled = opts.enabled ?? new Set(ALL_CONTROLS);
125
119
  const showTrigger = opts.showTrigger ?? true;
126
120
  const showPresets = opts.showPresets ?? true;
121
+ const flatLayout = opts.flatLayout ?? false;
127
122
  const trig = TRIGGER_ICONS[triggerIcon];
128
123
  const multi = (id, prefix, levels, label, icon, full = false) => {
129
124
  if (!enabled.has(id)) return "";
@@ -158,44 +153,61 @@ function buildPanelHtml(opts) {
158
153
  };
159
154
  const presetButtons = showPresets ? PRESET_IDS.filter((id) => presetIsAvailable(id, enabled)).map((id) => preset(id, presetLabels[id])) : [];
160
155
  const presetsBlock = presetButtons.length ? `<h4 class="oks-access-title">${escapeHtml(t.presets)}</h4><div class="oks-access-presets">${presetButtons.join("")}</div>` : "";
161
- const triggerBlock = showTrigger ? `
162
- <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
163
- <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">
164
- ${trig}
165
- </button>
166
- <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>
167
- </div>` : "";
168
- return `${triggerBlock}
169
- <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
170
- <div class="oks-access-header">
171
- <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
172
- <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
173
- </div>
174
- <div class="oks-access-content">
175
- ${presetsBlock}
176
- ${section(t.txt, [
156
+ const textBtns = [
177
157
  multi("text-size", "oks-zoom", 4, t.size, ICON_TXT),
178
158
  multi("line-height", "oks-lh", 3, t.lh, ICON_LH),
179
159
  multi("text-align", "oks-align", 3, t.align, ICON_ALIGN),
180
160
  toggle("readable-font", "oks-a11y-font", t.font, ICON_FONT),
181
161
  toggle("dyslexia-font", "oks-dyslexia", t.dyslexia, ICON_DYSLEXIA),
182
162
  multi("letter-spacing", "oks-ls", 3, t.ls, ICON_LS)
183
- ])}
184
- ${section(t.vis, [
163
+ ];
164
+ const visBtns = [
185
165
  toggle("contrast", "oks-a11y-contrast", t.contrast, ICON_CONTRAST),
186
166
  overlay("grayscale", "oks-overlay-gray", t.gray, ICON_GRAY),
187
167
  toggle("hide-images", "oks-a11y-hide", t.hide, ICON_HIDE),
188
168
  toggle("highlight-links", "oks-a11y-links", t.links, ICON_LINK),
189
- multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, true)
190
- ])}
191
- ${section(t.ori, [
169
+ multi("colorblind", "oks-colorblind", 3, t.cb, ICON_COLORBLIND, !flatLayout)
170
+ ];
171
+ const oriBtns = [
192
172
  simple("reading-guide", "guide", t.guide, ICON_GUIDE),
193
173
  simple("reading-mask", "mask", t.mask, ICON_MASK),
194
174
  toggle("big-cursor", "oks-big-cursor", t.cursor, ICON_CURSOR),
195
175
  toggle("big-targets", "oks-a11y-bigtargets", t.targets, ICON_TARGETS),
196
176
  toggle("pause-anim", "oks-a11y-pause", t.pause, ICON_PAUSE),
197
177
  toggle("focus", "oks-a11y-focus", t.focus, ICON_FOCUS)
198
- ])}
178
+ ];
179
+ const addFullWidth = (btn) => btn.replace('class="oks-access-opt', 'class="oks-access-opt full-width');
180
+ const flatContent = () => {
181
+ const groups = [
182
+ { title: t.txt, items: textBtns.filter(Boolean) },
183
+ { title: t.vis, items: visBtns.filter(Boolean) },
184
+ { title: t.ori, items: oriBtns.filter(Boolean) }
185
+ ].filter((g) => g.items.length > 0);
186
+ const total = groups.reduce((n, g) => n + g.items.length, 0);
187
+ if (total % 2 === 1 && groups.length > 0) {
188
+ const last = groups[groups.length - 1];
189
+ last.items[last.items.length - 1] = addFullWidth(last.items[last.items.length - 1]);
190
+ }
191
+ const inner = groups.map((g) => `<h4 class="oks-access-title oks-sr-only">${escapeHtml(g.title)}</h4>${g.items.join("")}`).join("");
192
+ return `<div class="oks-access-grid oks-flat">${inner}</div>`;
193
+ };
194
+ const controlsHtml = flatLayout ? flatContent() : section(t.txt, textBtns) + section(t.vis, visBtns) + section(t.ori, oriBtns);
195
+ const triggerBlock = showTrigger ? `
196
+ <div class="oks-access-wrapper" id="oks-wrapper" data-position="${opts.position}">
197
+ <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">
198
+ ${trig}
199
+ </button>
200
+ <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>
201
+ </div>` : "";
202
+ return `${triggerBlock}
203
+ <div class="oks-access-panel" id="oks-panel" role="dialog" aria-modal="true" aria-labelledby="oks-panel-title" inert>
204
+ <div class="oks-access-header">
205
+ <h3 id="oks-panel-title">${escapeHtml(t.title)}</h3>
206
+ <button class="oks-access-close" id="oks-close" aria-label="${escapeAttr(t.close)}" type="button">${ICON_CLOSE}</button>
207
+ </div>
208
+ <div class="oks-access-content">
209
+ ${presetsBlock}
210
+ ${controlsHtml}
199
211
  </div>
200
212
  <div class="oks-access-footer">
201
213
  <button class="oks-access-reset" id="oks-reset" type="button">${escapeHtml(t.reset)}</button>
@@ -371,8 +383,69 @@ function bindPanelBehavior(root, opts = {}) {
371
383
  return noopController();
372
384
  }
373
385
  let state = loadState(storageKey);
386
+ const scoped = opts.scopeEl !== void 0;
387
+ const GLOBAL_KEY = "oksiac::global";
388
+ const GLOBAL_MAP = [
389
+ { flag: "bigCursor", cls: "oks-big-cursor", match: (b) => b.getAttribute("data-class") === "oks-big-cursor" },
390
+ { flag: "readingGuide", cls: "oks-a11y-guide", match: (b) => b.getAttribute("data-action") === "guide" },
391
+ { flag: "readingMask", cls: "oks-a11y-mask", match: (b) => b.getAttribute("data-action") === "mask" }
392
+ ];
393
+ const globalOfferableOf = (b) => GLOBAL_MAP.find((x) => x.match(b));
394
+ const loadGlobal = () => {
395
+ try {
396
+ const r = JSON.parse(localStorage.getItem(GLOBAL_KEY) ?? "{}");
397
+ return { bigCursor: !!r.bigCursor, readingGuide: !!r.readingGuide, readingMask: !!r.readingMask };
398
+ } catch {
399
+ return { bigCursor: false, readingGuide: false, readingMask: false };
400
+ }
401
+ };
402
+ const saveGlobal = (g) => {
403
+ try {
404
+ if (g.bigCursor || g.readingGuide || g.readingMask) localStorage.setItem(GLOBAL_KEY, JSON.stringify(g));
405
+ else localStorage.removeItem(GLOBAL_KEY);
406
+ } catch {
407
+ }
408
+ };
409
+ const applyGlobal = (g) => {
410
+ document.body.classList.toggle("oks-big-cursor", g.bigCursor);
411
+ document.body.classList.toggle("oks-a11y-guide", g.readingGuide);
412
+ document.body.classList.toggle("oks-a11y-mask", g.readingMask);
413
+ for (const btn of opts$) {
414
+ const m = globalOfferableOf(btn);
415
+ if (!m) continue;
416
+ btn.classList.toggle("is-active", g[m.flag]);
417
+ btn.setAttribute("aria-pressed", g[m.flag] ? "true" : "false");
418
+ }
419
+ };
420
+ const refreshGlobal = () => {
421
+ if (scoped) applyGlobal(loadGlobal());
422
+ };
423
+ const syncGlobalFromState = () => {
424
+ if (!scoped) return;
425
+ const g = loadGlobal();
426
+ let changed = false;
427
+ if (state.bigCursor) {
428
+ g.bigCursor = true;
429
+ state.bigCursor = false;
430
+ changed = true;
431
+ }
432
+ if (state.readingGuide) {
433
+ g.readingGuide = true;
434
+ state.readingGuide = false;
435
+ changed = true;
436
+ }
437
+ if (state.readingMask) {
438
+ g.readingMask = true;
439
+ state.readingMask = false;
440
+ changed = true;
441
+ }
442
+ if (changed) {
443
+ saveGlobal(g);
444
+ applyGlobal(g);
445
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
446
+ }
447
+ };
374
448
  function applyState() {
375
- const scoped = opts.scopeEl !== void 0;
376
449
  const fxRoot = scoped ? opts.scopeEl ?? null : document.body;
377
450
  const rootEl = document.documentElement;
378
451
  if (fxRoot) {
@@ -407,6 +480,7 @@ function bindPanelBehavior(root, opts = {}) {
407
480
  }
408
481
  function syncButtonsFromState() {
409
482
  for (const btn of opts$) {
483
+ if (scoped && globalOfferableOf(btn)) continue;
410
484
  const action = btn.getAttribute("data-action");
411
485
  if (action === "multi" || action === "colorblind") {
412
486
  const prefix = btn.getAttribute("data-prefix") ?? "";
@@ -441,6 +515,17 @@ function bindPanelBehavior(root, opts = {}) {
441
515
  }
442
516
  const onOptClick = (e) => {
443
517
  const btn = e.currentTarget;
518
+ if (scoped) {
519
+ const go = globalOfferableOf(btn);
520
+ if (go) {
521
+ const g = loadGlobal();
522
+ g[go.flag] = !g[go.flag];
523
+ saveGlobal(g);
524
+ applyGlobal(g);
525
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
526
+ return;
527
+ }
528
+ }
444
529
  const action = btn.getAttribute("data-action");
445
530
  if (action === "multi") {
446
531
  const prefix = btn.getAttribute("data-prefix") ?? "";
@@ -469,6 +554,7 @@ function bindPanelBehavior(root, opts = {}) {
469
554
  if (id && PRESETS[id]) Object.assign(state, filterPresetForEnabled(id, enabled));
470
555
  btn.classList.add("is-flashing");
471
556
  setTimeout(() => btn.classList.remove("is-flashing"), 250);
557
+ syncGlobalFromState();
472
558
  }
473
559
  applyState();
474
560
  saveState(storageKey, state);
@@ -477,6 +563,12 @@ function bindPanelBehavior(root, opts = {}) {
477
563
  state = { ...DEFAULT_STATE };
478
564
  applyState();
479
565
  saveState(storageKey, state);
566
+ if (scoped) {
567
+ const cleared = { bigCursor: false, readingGuide: false, readingMask: false };
568
+ saveGlobal(cleared);
569
+ applyGlobal(cleared);
570
+ document.dispatchEvent(new CustomEvent("oksiac:globalchange"));
571
+ }
480
572
  };
481
573
  let opener = null;
482
574
  let ignoreDocClose = false;
@@ -644,14 +736,16 @@ function bindPanelBehavior(root, opts = {}) {
644
736
  saveNudge();
645
737
  };
646
738
  const onMove = (e) => {
647
- if (!state.readingGuide && !state.readingMask) return;
739
+ const guideOn = document.body.classList.contains("oks-a11y-guide");
740
+ const maskOn = document.body.classList.contains("oks-a11y-mask");
741
+ if (!guideOn && !maskOn) return;
648
742
  const y = e.touches?.[0]?.clientY ?? e.clientY;
649
743
  if (typeof y !== "number") return;
650
- if (state.readingGuide) {
744
+ if (guideOn) {
651
745
  const guide = document.getElementById("oks-reading-guide");
652
746
  if (guide) guide.style.top = `${y}px`;
653
747
  }
654
- if (state.readingMask) {
748
+ if (maskOn) {
655
749
  const mask = document.getElementById("oks-reading-mask");
656
750
  if (mask) mask.style.setProperty("--oks-mask-y", `${y}px`);
657
751
  }
@@ -667,9 +761,15 @@ function bindPanelBehavior(root, opts = {}) {
667
761
  const onReposition = () => {
668
762
  if (opts.scopeEl && panel.classList.contains("is-open")) positionScopedDialog();
669
763
  };
670
- if (opts.scopeEl !== void 0) {
764
+ const onGlobalChange = () => refreshGlobal();
765
+ const onStorage = (e) => {
766
+ if (e.key === GLOBAL_KEY) refreshGlobal();
767
+ };
768
+ if (scoped) {
671
769
  window.addEventListener("scroll", onReposition, true);
672
770
  window.addEventListener("resize", onReposition);
771
+ document.addEventListener("oksiac:globalchange", onGlobalChange);
772
+ window.addEventListener("storage", onStorage);
673
773
  }
674
774
  if (nudgeMax > 0 && trigger) {
675
775
  loadNudge();
@@ -682,6 +782,7 @@ function bindPanelBehavior(root, opts = {}) {
682
782
  trigger.addEventListener("keydown", onTriggerKey);
683
783
  }
684
784
  applyState();
785
+ refreshGlobal();
685
786
  const dispose = () => {
686
787
  trigger?.removeEventListener("click", onTriggerClick);
687
788
  closeBtn.removeEventListener("click", closePanel);
@@ -691,9 +792,11 @@ function bindPanelBehavior(root, opts = {}) {
691
792
  document.removeEventListener("keydown", onKeyDown);
692
793
  document.removeEventListener("mousemove", onMove);
693
794
  document.removeEventListener("touchmove", onMove);
694
- if (opts.scopeEl !== void 0) {
795
+ if (scoped) {
695
796
  window.removeEventListener("scroll", onReposition, true);
696
797
  window.removeEventListener("resize", onReposition);
798
+ document.removeEventListener("oksiac:globalchange", onGlobalChange);
799
+ window.removeEventListener("storage", onStorage);
697
800
  }
698
801
  trigger?.removeEventListener("pointerdown", onPointerDown);
699
802
  document.removeEventListener("pointermove", onPointerMove);
@@ -830,6 +933,19 @@ var PANEL_CSS = `
830
933
  text-transform: uppercase;
831
934
  color: #888;
832
935
  }
936
+ /* Screen-reader-only: keeps the category headings in the accessibility tree
937
+ (flat scoped layout) while taking them out of the grid flow so they don't
938
+ force a visual row break or occupy a cell. */
939
+ .oks-sr-only {
940
+ position: absolute !important;
941
+ width: 1px; height: 1px;
942
+ margin: 0; padding: 0;
943
+ overflow: hidden;
944
+ clip: rect(0 0 0 0);
945
+ clip-path: inset(50%);
946
+ white-space: nowrap;
947
+ border: 0;
948
+ }
833
949
  .oks-access-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; }
834
950
  .oks-access-presets { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
835
951
  .oks-preset {
@@ -1001,18 +1117,22 @@ body.oks-ls-1 * { letter-spacing: 0.05em !important; }
1001
1117
  body.oks-ls-2 * { letter-spacing: 0.10em !important; }
1002
1118
  body.oks-ls-3 * { letter-spacing: 0.16em !important; }
1003
1119
 
1120
+ /* Focus highlight colour is themeable via --oks-focus-color (default #005fcc):
1121
+ set it on :root for a dark theme so the highlight stays visible. The
1122
+ persistent "all interactive" dashed derives a 45% tint from it; the glow can
1123
+ be overridden on its own with --oks-focus-glow. */
1004
1124
  body.oks-a11y-focus a:not(oksigenia-access-panel):not(oksigenia-access-panel *),
1005
1125
  body.oks-a11y-focus button:not(oksigenia-access-panel):not(oksigenia-access-panel *),
1006
1126
  body.oks-a11y-focus input,
1007
1127
  body.oks-a11y-focus select,
1008
1128
  body.oks-a11y-focus textarea {
1009
- outline: 2px dashed rgba(0, 95, 204, 0.45) !important;
1129
+ outline: 2px dashed color-mix(in srgb, var(--oks-focus-color, #005fcc) 45%, transparent) !important;
1010
1130
  outline-offset: 2px !important;
1011
1131
  }
1012
1132
  body.oks-a11y-focus *:focus-visible {
1013
- outline: 3px solid #005fcc !important;
1133
+ outline: 3px solid var(--oks-focus-color, #005fcc) !important;
1014
1134
  outline-offset: 3px !important;
1015
- box-shadow: 0 0 0 6px rgba(0, 95, 204, 0.25) !important;
1135
+ box-shadow: 0 0 0 6px var(--oks-focus-glow, color-mix(in srgb, var(--oks-focus-color, #005fcc) 25%, transparent)) !important;
1016
1136
  }
1017
1137
 
1018
1138
  body.oks-a11y-contrast.oks-a11y-focus *:focus,
@@ -1149,13 +1269,13 @@ ${s}.oks-a11y-focus button,
1149
1269
  ${s}.oks-a11y-focus input,
1150
1270
  ${s}.oks-a11y-focus select,
1151
1271
  ${s}.oks-a11y-focus textarea {
1152
- outline: 2px dashed rgba(0, 95, 204, 0.45) !important;
1272
+ outline: 2px dashed color-mix(in srgb, var(--oks-focus-color, #005fcc) 45%, transparent) !important;
1153
1273
  outline-offset: 2px !important;
1154
1274
  }
1155
1275
  ${s}.oks-a11y-focus *:focus-visible {
1156
- outline: 3px solid #005fcc !important;
1276
+ outline: 3px solid var(--oks-focus-color, #005fcc) !important;
1157
1277
  outline-offset: 3px !important;
1158
- box-shadow: 0 0 0 6px rgba(0, 95, 204, 0.25) !important;
1278
+ box-shadow: 0 0 0 6px var(--oks-focus-glow, color-mix(in srgb, var(--oks-focus-color, #005fcc) 25%, transparent)) !important;
1159
1279
  }
1160
1280
 
1161
1281
  ${s}.oks-a11y-contrast, ${s}.oks-a11y-contrast * {
@@ -1360,7 +1480,9 @@ var OksigeniaAccessPanelElement = class extends HTMLElement {
1360
1480
  position,
1361
1481
  enabled,
1362
1482
  showTrigger,
1363
- showPresets
1483
+ showPresets,
1484
+ flatLayout: scope != null
1485
+ // scoped panels use the flat (no-category) layout
1364
1486
  });
1365
1487
  shadow.innerHTML = `<style>${PANEL_CSS}${positionCss(position, positionMobile)}</style>${html}`;
1366
1488
  const scopeEl = scope ? document.querySelector(scope) : void 0;