a11y-hud 0.4.1 → 1.0.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/README.md CHANGED
@@ -5,7 +5,8 @@ A framework-agnostic developer overlay that runs [axe-core](https://github.com/d
5
5
  - **No DevTools required** — the HUD runs in the page itself, not as an extension.
6
6
  - **Framework-agnostic** — works with any stack. Framework adapters available for React, Vue, Angular, Svelte, and Solid.
7
7
  - **Interactive** — violation list with severity filters, click-to-highlight, keyboard navigation, and clipboard export.
8
- - **Three built-in themes** — `default` (dark), `light`, `high-contrast`; `auto` respects `prefers-color-scheme`.
8
+ - **Eight built-in themes** — `auto` (follows `prefers-color-scheme`, promotes to `high-contrast` under `prefers-contrast: more`) plus `default` (dark), `light`, `high-contrast`, `github-dark`, `github-light`, `tokyo-night`, `solarized-dark`.
9
+ - **ESM + UMD** — import it from a bundler, or load `dist/index.umd.js` from a CDN. Safe to import in SSR code; the HUD mounts only in the browser.
9
10
 
10
11
  ## Install
11
12
 
@@ -37,10 +38,11 @@ hud.unmount();
37
38
 
38
39
  | Option | Type | Default | Description |
39
40
  |--------|------|---------|-------------|
40
- | `theme` | `"auto" \| "default" \| "light" \| "high-contrast"` | `"auto"` | Panel theme |
41
+ | `theme` | `Theme` | `"auto"` | Panel theme — `"auto"`, `"default"`, `"light"`, `"high-contrast"`, `"github-dark"`, `"github-light"`, `"tokyo-night"`, `"solarized-dark"` |
41
42
  | `scope` | `string \| Element` | `document.body` | Restrict scan to a subtree |
42
43
  | `autoScan` | `boolean` | `true` | Auto-rescan on DOM mutations |
43
44
  | `debounce` | `number` | `500` | Debounce delay in ms |
45
+ | `runOnly` | `string[]` | `[]` | Restrict axe to specific rule-set tags (e.g. `["wcag2a", "wcag2aa"]`) |
44
46
 
45
47
  ### Headless API
46
48
 
@@ -51,6 +53,8 @@ const results = await runScan(document.querySelector("#my-app"));
51
53
  console.log(results.violations);
52
54
  ```
53
55
 
56
+ `runScan()` needs a real browser DOM (run it in the page or inside a Playwright `page.evaluate()`); it is not supported under Node/jsdom.
57
+
54
58
  ## Framework adapters
55
59
 
56
60
  - React — [`@a11y-hud/react`](https://www.npmjs.com/package/@a11y-hud/react)
@@ -121,7 +121,7 @@
121
121
 
122
122
  <div class="version-row">
123
123
  <span>This installer uses <strong>latest</strong>. To pin a specific release:</span>
124
- <a href="javascript:(function(){if(window.A11yHud){window.A11yHud.mount();return;}var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/a11y-hud@0.4.1/dist/index.umd.js';s.onload=function(){window.A11yHud.mount();};document.head.appendChild(s);})()" title="Bookmarklet pinned to v0.4.1">Pinned to v0.4.1</a>
124
+ <a href="javascript:(function(){if(window.A11yHud){window.A11yHud.mount();return;}var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/a11y-hud@1.0.0/dist/index.umd.js';s.onload=function(){window.A11yHud.mount();};document.head.appendChild(s);})()" title="Bookmarklet pinned to v1.0.0">Pinned to v1.0.0</a>
125
125
  </div>
126
126
 
127
127
  <div class="footer">
package/dist/index.d.ts CHANGED
@@ -40,7 +40,8 @@ interface A11yHudInstance {
40
40
  };
41
41
  }
42
42
 
43
- declare class A11yHudElement extends HTMLElement {
43
+ declare const BaseElement: typeof HTMLElement;
44
+ declare class A11yHudElement extends BaseElement {
44
45
  static observedAttributes: string[];
45
46
  private _shadow;
46
47
  private _theme;
@@ -49,7 +50,7 @@ declare class A11yHudElement extends HTMLElement {
49
50
  private _autoScan;
50
51
  private _debounceMs;
51
52
  private _results;
52
- private _scanning;
53
+ private _activeScan;
53
54
  private _mounted;
54
55
  private _observer;
55
56
  private _unwatchTheme;
@@ -75,11 +76,15 @@ declare class A11yHudElement extends HTMLElement {
75
76
  runScan(): Promise<AxeResults>;
76
77
  private _getScopeTarget;
77
78
  private _applyResolvedTheme;
79
+ private _restartObserver;
78
80
  private _startObserver;
81
+ private _scheduleScan;
79
82
  private _runScan;
83
+ private _performScan;
80
84
  private _getFilteredViolations;
81
85
  private _render;
82
86
  private _renderScanning;
87
+ private _renderError;
83
88
  private _renderPanel;
84
89
  private _renderFilters;
85
90
  private _renderBody;
package/dist/index.js CHANGED
@@ -276,7 +276,7 @@ async function runScan(target = document.body, runOnly) {
276
276
  const options = runOnly && runOnly.length > 0 ? { runOnly: { type: "tag", values: runOnly } } : {};
277
277
  const run = axe.run(target, options).then((results) => {
278
278
  const ignores = listIgnores();
279
- results.violations = results.violations.map((v) => ({
279
+ const violations = results.violations.map((v) => ({
280
280
  ...v,
281
281
  nodes: v.nodes.filter((node) => {
282
282
  const first = node.target[0];
@@ -296,7 +296,7 @@ async function runScan(target = document.body, runOnly) {
296
296
  });
297
297
  return nodes.length > 0 ? { ...v, nodes } : null;
298
298
  }).filter((v) => v !== null);
299
- return results;
299
+ return { ...results, violations };
300
300
  }).finally(() => {
301
301
  activeRun = null;
302
302
  });
@@ -381,7 +381,9 @@ function buildStyleSheet(css) {
381
381
  return null;
382
382
  }
383
383
  }
384
- var A11yHudElement = class extends HTMLElement {
384
+ var BaseElement = typeof HTMLElement === "undefined" ? class {
385
+ } : HTMLElement;
386
+ var A11yHudElement = class extends BaseElement {
385
387
  static observedAttributes = ["theme", "scope", "auto-scan", "debounce", "run-only"];
386
388
  _shadow;
387
389
  _theme = "auto";
@@ -390,7 +392,7 @@ var A11yHudElement = class extends HTMLElement {
390
392
  _autoScan = true;
391
393
  _debounceMs = 500;
392
394
  _results;
393
- _scanning = false;
395
+ _activeScan;
394
396
  _mounted = false;
395
397
  _observer;
396
398
  _unwatchTheme;
@@ -407,7 +409,7 @@ var A11yHudElement = class extends HTMLElement {
407
409
  constructor() {
408
410
  super();
409
411
  this._shadow = this.attachShadow({ mode: "open" });
410
- this._debouncedScan = debounce(() => void this._runScan(), this._debounceMs);
412
+ this._debouncedScan = debounce(() => this._scheduleScan(), this._debounceMs);
411
413
  this._applyStyles();
412
414
  }
413
415
  _applyStyles() {
@@ -433,7 +435,7 @@ ${themes_default}`;
433
435
  }
434
436
  );
435
437
  if (this._autoScan) this._startObserver();
436
- void this._runScan();
438
+ this._scheduleScan();
437
439
  }
438
440
  disconnectedCallback() {
439
441
  this._mounted = false;
@@ -454,6 +456,7 @@ ${themes_default}`;
454
456
  case "scope":
455
457
  this._scopeSelector = value ?? void 0;
456
458
  this._scopeElement = void 0;
459
+ this._restartObserver();
457
460
  break;
458
461
  case "auto-scan":
459
462
  this._autoScan = value !== null;
@@ -470,7 +473,7 @@ ${themes_default}`;
470
473
  const parsed = Number.parseInt(value ?? "", 10);
471
474
  this._debounceMs = Number.isNaN(parsed) ? 500 : parsed;
472
475
  this._debouncedScan.cancel();
473
- this._debouncedScan = debounce(() => void this._runScan(), this._debounceMs);
476
+ this._debouncedScan = debounce(() => this._scheduleScan(), this._debounceMs);
474
477
  break;
475
478
  }
476
479
  case "run-only": {
@@ -489,8 +492,10 @@ ${themes_default}`;
489
492
  return this._scopeElement;
490
493
  }
491
494
  set scopeElement(el) {
495
+ const changed = el !== this._scopeElement || this._scopeSelector !== void 0;
492
496
  this._scopeElement = el;
493
497
  this._scopeSelector = void 0;
498
+ if (changed) this._restartObserver();
494
499
  }
495
500
  setTheme(theme) {
496
501
  this._theme = theme;
@@ -506,27 +511,33 @@ ${themes_default}`;
506
511
  }
507
512
  this._updateRunOnlyChips();
508
513
  }
509
- async runScan() {
514
+ runScan() {
510
515
  return this._runScan();
511
516
  }
512
517
  _getScopeTarget() {
513
518
  if (this._scopeElement) return this._scopeElement;
514
519
  if (this._scopeSelector) {
515
- return document.querySelector(this._scopeSelector) ?? document.body;
520
+ try {
521
+ return document.querySelector(this._scopeSelector) ?? document.body;
522
+ } catch {
523
+ return document.body;
524
+ }
516
525
  }
517
526
  return document.body;
518
527
  }
519
528
  _applyResolvedTheme() {
520
529
  this.dataset.theme = resolveTheme(this._theme);
521
530
  }
531
+ _restartObserver() {
532
+ if (this._mounted && this._autoScan) this._startObserver();
533
+ }
522
534
  _startObserver() {
523
535
  this._observer?.disconnect();
524
536
  const target = this._getScopeTarget();
525
- const debouncedScan = this._debouncedScan;
526
537
  this._observer = new MutationObserver((records) => {
527
538
  if (records.every((r) => r.type === "attributes" && r.attributeName === HIGHLIGHT_ATTR))
528
539
  return;
529
- debouncedScan();
540
+ this._debouncedScan();
530
541
  });
531
542
  this._observer.observe(target, {
532
543
  childList: true,
@@ -535,9 +546,20 @@ ${themes_default}`;
535
546
  characterData: false
536
547
  });
537
548
  }
538
- async _runScan() {
539
- if (this._scanning) return this._results ?? { violations: [] };
540
- this._scanning = true;
549
+ // Fire-and-forget entry point for internal triggers (observer, buttons).
550
+ // Errors are surfaced in the panel by _runScan, so swallow the rejection here.
551
+ _scheduleScan() {
552
+ this._runScan().catch(() => {
553
+ });
554
+ }
555
+ _runScan() {
556
+ if (this._activeScan) return this._activeScan;
557
+ this._activeScan = this._performScan().finally(() => {
558
+ this._activeScan = void 0;
559
+ });
560
+ return this._activeScan;
561
+ }
562
+ async _performScan() {
541
563
  const rescanBtn = this._shadow.querySelector("#btn-rescan");
542
564
  if (rescanBtn) {
543
565
  rescanBtn.setAttribute("data-scanning", "");
@@ -552,8 +574,10 @@ ${themes_default}`;
552
574
  this._results = results;
553
575
  this._render();
554
576
  return results;
577
+ } catch (error) {
578
+ if (this._mounted) this._renderError(error);
579
+ throw error;
555
580
  } finally {
556
- this._scanning = false;
557
581
  const btn = this._shadow.querySelector("#btn-rescan");
558
582
  if (btn) {
559
583
  btn.removeAttribute("data-scanning");
@@ -612,6 +636,20 @@ ${themes_default}`;
612
636
  `;
613
637
  }
614
638
  }
639
+ _renderError(error) {
640
+ if (this._keyboardMode) return;
641
+ const body = this._shadow.querySelector(".panel-body");
642
+ if (!body) return;
643
+ const message = error instanceof Error ? error.message : String(error);
644
+ body.innerHTML = `
645
+ <div class="empty-state" role="alert">
646
+ <span class="empty-state-icon" aria-hidden="true">${icon("alert-triangle")}</span>
647
+ <span class="empty-state-title">Scan failed</span>
648
+ <span class="empty-state-body">${escapeHtml(message)}</span>
649
+ </div>
650
+ ${this._renderIgnoredSection()}
651
+ `;
652
+ }
615
653
  _renderPanel(violations, total) {
616
654
  return `
617
655
  <div class="panel" role="complementary" aria-label="Accessibility panel">
@@ -741,7 +779,7 @@ ${themes_default}`;
741
779
  const nodeCount = violation.nodes.length;
742
780
  const nodeLabel = `${nodeCount} node${nodeCount !== 1 ? "s" : ""}`;
743
781
  const nodes = violation.nodes.map((node, ni) => {
744
- const selector = this._nodeSelector(node.target);
782
+ const selector = escapeHtml(this._nodeSelector(node.target));
745
783
  return `
746
784
  <li class="violation-node-item">
747
785
  <button class="btn-highlight" data-violation="${index}" data-node="${ni}" aria-label="Highlight: ${selector}">
@@ -784,7 +822,7 @@ ${themes_default}`;
784
822
  const panel = this._shadow.querySelector(".panel");
785
823
  if (!panel) return;
786
824
  panel.querySelector(".btn-panel-title")?.addEventListener("click", () => this._toggleFilters());
787
- panel.querySelector("#btn-rescan")?.addEventListener("click", () => void this._runScan());
825
+ panel.querySelector("#btn-rescan")?.addEventListener("click", () => this._scheduleScan());
788
826
  panel.querySelector("#btn-minimize")?.addEventListener("click", () => {
789
827
  this.setAttribute("data-minimized", "");
790
828
  });
@@ -850,7 +888,7 @@ ${themes_default}`;
850
888
  const ruleId = ignoreBtn.dataset.ignoreRule;
851
889
  if (ruleId) {
852
890
  addIgnore(ruleId);
853
- void this._runScan();
891
+ this._scheduleScan();
854
892
  }
855
893
  return;
856
894
  }
@@ -860,7 +898,7 @@ ${themes_default}`;
860
898
  const selector = removeIgnoreBtn.dataset.removeSelector || void 0;
861
899
  if (ruleId) {
862
900
  removeIgnore(ruleId, selector);
863
- void this._runScan();
901
+ this._scheduleScan();
864
902
  }
865
903
  return;
866
904
  }
@@ -874,7 +912,7 @@ ${themes_default}`;
874
912
  }
875
913
  if (target.closest(".btn-clear-ignores")) {
876
914
  clearIgnores();
877
- void this._runScan();
915
+ this._scheduleScan();
878
916
  return;
879
917
  }
880
918
  const ignoredToggle = target.closest(".ignored-section-toggle");
@@ -921,7 +959,7 @@ ${themes_default}`;
921
959
  chip.setAttribute("aria-pressed", "true");
922
960
  }
923
961
  this._updateFilterToggle();
924
- void this._runScan();
962
+ this._scheduleScan();
925
963
  }
926
964
  }
927
965
  }
@@ -1123,7 +1161,7 @@ ${themes_default}`;
1123
1161
  const reader = new FileReader();
1124
1162
  reader.onload = (evt) => {
1125
1163
  importIgnores(evt.target?.result ?? "");
1126
- void this._runScan();
1164
+ this._scheduleScan();
1127
1165
  };
1128
1166
  reader.readAsText(file);
1129
1167
  };
@@ -1179,7 +1217,9 @@ ${themes_default}`;
1179
1217
  this._removeHighlight = () => el.removeAttribute(HIGHLIGHT_ATTR);
1180
1218
  }
1181
1219
  };
1182
- customElements.define("a11y-hud", A11yHudElement);
1220
+ if (typeof customElements !== "undefined" && !customElements.get("a11y-hud")) {
1221
+ customElements.define("a11y-hud", A11yHudElement);
1222
+ }
1183
1223
 
1184
1224
  // src/mount.ts
1185
1225
  function mount(options = {}) {