accented 0.0.0-20250303013509 → 0.0.0-20250424114613

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +10 -4
  3. package/dist/accented.d.ts +2 -2
  4. package/dist/accented.d.ts.map +1 -1
  5. package/dist/accented.js +10 -5
  6. package/dist/accented.js.map +1 -1
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +1 -0
  10. package/dist/constants.js.map +1 -1
  11. package/dist/dom-updater.d.ts.map +1 -1
  12. package/dist/dom-updater.js +66 -25
  13. package/dist/dom-updater.js.map +1 -1
  14. package/dist/elements/accented-dialog.d.ts +11 -7
  15. package/dist/elements/accented-dialog.d.ts.map +1 -1
  16. package/dist/elements/accented-dialog.js +85 -86
  17. package/dist/elements/accented-dialog.js.map +1 -1
  18. package/dist/elements/accented-trigger.d.ts +9 -5
  19. package/dist/elements/accented-trigger.d.ts.map +1 -1
  20. package/dist/elements/accented-trigger.js +35 -11
  21. package/dist/elements/accented-trigger.js.map +1 -1
  22. package/dist/fullscreen-listener.d.ts +2 -0
  23. package/dist/fullscreen-listener.d.ts.map +1 -0
  24. package/dist/fullscreen-listener.js +18 -0
  25. package/dist/fullscreen-listener.js.map +1 -0
  26. package/dist/logger.d.ts.map +1 -1
  27. package/dist/logger.js +4 -1
  28. package/dist/logger.js.map +1 -1
  29. package/dist/scanner.d.ts +2 -2
  30. package/dist/scanner.d.ts.map +1 -1
  31. package/dist/scanner.js +33 -19
  32. package/dist/scanner.js.map +1 -1
  33. package/dist/state.d.ts +2 -1
  34. package/dist/state.d.ts.map +1 -1
  35. package/dist/state.js +3 -0
  36. package/dist/state.js.map +1 -1
  37. package/dist/task-queue.d.ts +2 -2
  38. package/dist/task-queue.d.ts.map +1 -1
  39. package/dist/task-queue.js +2 -1
  40. package/dist/task-queue.js.map +1 -1
  41. package/dist/types.d.ts +42 -8
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/types.js.map +1 -1
  44. package/dist/utils/are-elements-with-issues-equal.d.ts +3 -0
  45. package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -0
  46. package/dist/utils/are-elements-with-issues-equal.js +5 -0
  47. package/dist/utils/are-elements-with-issues-equal.js.map +1 -0
  48. package/dist/utils/containing-blocks.d.ts +3 -0
  49. package/dist/utils/containing-blocks.d.ts.map +1 -0
  50. package/dist/utils/containing-blocks.js +46 -0
  51. package/dist/utils/containing-blocks.js.map +1 -0
  52. package/dist/utils/contains.d.ts +2 -0
  53. package/dist/utils/contains.d.ts.map +1 -0
  54. package/dist/utils/contains.js +19 -0
  55. package/dist/utils/contains.js.map +1 -0
  56. package/dist/utils/deduplicate-nodes.d.ts +2 -0
  57. package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
  58. package/dist/utils/deduplicate-nodes.js +5 -0
  59. package/dist/utils/deduplicate-nodes.js.map +1 -0
  60. package/dist/utils/dom-helpers.d.ts +9 -0
  61. package/dist/utils/dom-helpers.d.ts.map +1 -0
  62. package/dist/utils/dom-helpers.js +32 -0
  63. package/dist/utils/dom-helpers.js.map +1 -0
  64. package/dist/utils/ensure-non-empty.d.ts +2 -0
  65. package/dist/utils/ensure-non-empty.d.ts.map +1 -0
  66. package/dist/utils/ensure-non-empty.js +7 -0
  67. package/dist/utils/ensure-non-empty.js.map +1 -0
  68. package/dist/utils/get-element-position.d.ts +8 -0
  69. package/dist/utils/get-element-position.d.ts.map +1 -1
  70. package/dist/utils/get-element-position.js +27 -11
  71. package/dist/utils/get-element-position.js.map +1 -1
  72. package/dist/utils/get-parent.d.ts +2 -0
  73. package/dist/utils/get-parent.d.ts.map +1 -0
  74. package/dist/utils/get-parent.js +12 -0
  75. package/dist/utils/get-parent.js.map +1 -0
  76. package/dist/utils/get-scan-context.d.ts +3 -0
  77. package/dist/utils/get-scan-context.d.ts.map +1 -0
  78. package/dist/utils/get-scan-context.js +28 -0
  79. package/dist/utils/get-scan-context.js.map +1 -0
  80. package/dist/utils/get-scrollable-ancestors.d.ts +1 -1
  81. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
  82. package/dist/utils/get-scrollable-ancestors.js +6 -2
  83. package/dist/utils/get-scrollable-ancestors.js.map +1 -1
  84. package/dist/utils/is-node-in-scan-context.d.ts +3 -0
  85. package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
  86. package/dist/utils/is-node-in-scan-context.js +26 -0
  87. package/dist/utils/is-node-in-scan-context.js.map +1 -0
  88. package/dist/utils/normalize-context.d.ts +3 -0
  89. package/dist/utils/normalize-context.d.ts.map +1 -0
  90. package/dist/utils/normalize-context.js +57 -0
  91. package/dist/utils/normalize-context.js.map +1 -0
  92. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +10 -0
  93. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -0
  94. package/dist/utils/shadow-dom-aware-mutation-observer.js +64 -0
  95. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -0
  96. package/dist/utils/transform-violations.d.ts +1 -1
  97. package/dist/utils/transform-violations.d.ts.map +1 -1
  98. package/dist/utils/transform-violations.js +18 -5
  99. package/dist/utils/transform-violations.js.map +1 -1
  100. package/dist/utils/update-elements-with-issues.d.ts +10 -4
  101. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  102. package/dist/utils/update-elements-with-issues.js +33 -6
  103. package/dist/utils/update-elements-with-issues.js.map +1 -1
  104. package/dist/validate-options.d.ts.map +1 -1
  105. package/dist/validate-options.js +86 -0
  106. package/dist/validate-options.js.map +1 -1
  107. package/package.json +8 -3
  108. package/src/accented.ts +10 -5
  109. package/src/constants.ts +1 -0
  110. package/src/dom-updater.ts +70 -24
  111. package/src/elements/accented-dialog.ts +88 -90
  112. package/src/elements/accented-trigger.ts +36 -12
  113. package/src/fullscreen-listener.ts +17 -0
  114. package/src/logger.ts +9 -1
  115. package/src/scanner.ts +37 -20
  116. package/src/state.ts +10 -2
  117. package/src/task-queue.ts +6 -4
  118. package/src/types.ts +55 -9
  119. package/src/utils/are-elements-with-issues-equal.ts +9 -0
  120. package/src/utils/containing-blocks.ts +57 -0
  121. package/src/utils/contains.test.ts +55 -0
  122. package/src/utils/contains.ts +19 -0
  123. package/src/utils/deduplicate-nodes.ts +3 -0
  124. package/src/utils/dom-helpers.ts +38 -0
  125. package/src/utils/ensure-non-empty.ts +6 -0
  126. package/src/utils/get-element-position.ts +28 -11
  127. package/src/utils/get-parent.ts +14 -0
  128. package/src/utils/get-scan-context.test.ts +79 -0
  129. package/src/utils/get-scan-context.ts +39 -0
  130. package/src/utils/get-scrollable-ancestors.ts +10 -5
  131. package/src/utils/is-node-in-scan-context.test.ts +70 -0
  132. package/src/utils/is-node-in-scan-context.ts +29 -0
  133. package/src/utils/normalize-context.test.ts +105 -0
  134. package/src/utils/normalize-context.ts +58 -0
  135. package/src/utils/shadow-dom-aware-mutation-observer.ts +78 -0
  136. package/src/utils/transform-violations.test.ts +10 -8
  137. package/src/utils/transform-violations.ts +20 -6
  138. package/src/utils/update-elements-with-issues.test.ts +102 -15
  139. package/src/utils/update-elements-with-issues.ts +51 -7
  140. package/src/validate-options.ts +88 -1
  141. package/dist/utils/is-html-element.d.ts +0 -2
  142. package/dist/utils/is-html-element.d.ts.map +0 -1
  143. package/dist/utils/is-html-element.js +0 -7
  144. package/dist/utils/is-html-element.js.map +0 -1
  145. package/src/utils/is-html-element.ts +0 -6
@@ -0,0 +1,79 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import assert from 'node:assert/strict';
3
+ import { suite, test } from 'node:test';
4
+ import getScanContext from './get-scan-context';
5
+
6
+ suite('getScanContext', () => {
7
+ test('when context doesn’t overlap with nodes, the result is empty', () => {
8
+ const dom = new JSDOM('<div><div id="context"></div><div id="mutated-node"></div></div>');
9
+ const { document } = dom.window;
10
+ global.document = document;
11
+ const mutatedNode = document.querySelector('#mutated-node')!;
12
+ const scanContext = getScanContext([mutatedNode], '#context');
13
+
14
+ assert.deepEqual(scanContext, {
15
+ include: [],
16
+ exclude: []
17
+ });
18
+ });
19
+
20
+ test('when context is within the mutated node, the result is the context', () => {
21
+ const dom = new JSDOM('<div><div id="mutated-node"><div id="context"></div></div></div>');
22
+ const { document } = dom.window;
23
+ global.document = document;
24
+ const mutatedNode = document.querySelector('#mutated-node')!;
25
+ const scanContext = getScanContext([mutatedNode], '#context');
26
+ const contextNode = document.querySelector('#context')!;
27
+
28
+ assert.deepEqual(scanContext, {
29
+ include: [contextNode],
30
+ exclude: []
31
+ });
32
+ });
33
+
34
+ test('when mutated node is within context, the mutated node and the context nodes within it are correctly included / excluded', () => {
35
+ const dom = new JSDOM(`<div>
36
+ <div class="include" id="outer-include">
37
+ <div id="mutated-node">
38
+ <div class="exclude" id="inner-exclude">
39
+ <div class="include" id="inner-include"></div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>`);
44
+ const { document } = dom.window;
45
+ global.document = document;
46
+ const mutatedNode = document.querySelector('#mutated-node')!;
47
+ const scanContext = getScanContext([mutatedNode], {include: ['.include'], exclude: ['.exclude']});
48
+ const innerExclude = document.querySelector('#inner-exclude')!;
49
+ const innerInclude = document.querySelector('#inner-include')!;
50
+
51
+ assert.deepEqual(scanContext, {
52
+ include: [mutatedNode, innerInclude],
53
+ exclude: [innerExclude]
54
+ });
55
+ });
56
+
57
+ test('when mutated node is within exclude, context elements within it are still included', () => {
58
+ const dom = new JSDOM(`<div>
59
+ <div class="exclude" id="outer-exclude">
60
+ <div id="mutated-node">
61
+ <div class="exclude" id="inner-exclude">
62
+ <div class="include" id="inner-include"></div>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>`);
67
+ const { document } = dom.window;
68
+ global.document = document;
69
+ const mutatedNode = document.querySelector('#mutated-node')!;
70
+ const scanContext = getScanContext([mutatedNode], {include: ['.include'], exclude: ['.exclude']});
71
+ const innerExclude = document.querySelector('#inner-exclude')!;
72
+ const innerInclude = document.querySelector('#inner-include')!;
73
+
74
+ assert.deepEqual(scanContext, {
75
+ include: [innerInclude],
76
+ exclude: [innerExclude]
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,39 @@
1
+ import type { Context, ScanContext } from '../types';
2
+ import contains from './contains.js';
3
+ import { deduplicateNodes } from './deduplicate-nodes.js';
4
+ import isNodeInScanContext from './is-node-in-scan-context.js';
5
+ import normalizeContext from './normalize-context.js';
6
+
7
+ export default function getScanContext(nodes: Array<Node>, context: Context): ScanContext {
8
+ const {
9
+ include: contextInclude,
10
+ exclude: contextExclude
11
+ } = normalizeContext(context);
12
+
13
+ // Filter only nodes that are included by context (see isNodeInContext above).
14
+ const nodesInContext = nodes.filter(node =>
15
+ isNodeInScanContext(node, {
16
+ include: contextInclude,
17
+ exclude: contextExclude
18
+ })
19
+ );
20
+
21
+ const include: Array<Node> = [];
22
+ const exclude: Array<Node> = [];
23
+
24
+ // Adds all nodesInContext to the include array.
25
+ include.push(...nodesInContext);
26
+
27
+ // Now add any included and excluded context nodes that are contained by any of the original nodes.
28
+ for (const node of nodes) {
29
+ const includeDescendants = contextInclude.filter(item => contains(node, item));
30
+ include.push(...includeDescendants);
31
+ const excludeDescendants = contextExclude.filter(item => contains(node, item));
32
+ exclude.push(...excludeDescendants);
33
+ }
34
+
35
+ return {
36
+ include: deduplicateNodes(include),
37
+ exclude: deduplicateNodes(exclude)
38
+ };
39
+ }
@@ -1,10 +1,15 @@
1
+ import getParent from './get-parent.js';
2
+
1
3
  const scrollableOverflowValues = new Set(['auto', 'scroll', 'hidden']);
2
4
 
3
- export default function getScrollableAncestors (element: HTMLElement, win: Window) {
4
- let currentElement = element;
5
- let scrollableAncestors = new Set<HTMLElement>();
6
- while (currentElement.parentElement) {
7
- currentElement = currentElement.parentElement;
5
+ export default function getScrollableAncestors (element: Element, win: Window) {
6
+ let currentElement: Element | null = element;
7
+ let scrollableAncestors = new Set<Element>();
8
+ while (true) {
9
+ currentElement = getParent(currentElement);
10
+ if (!currentElement) {
11
+ break;
12
+ }
8
13
  const computedStyle = win.getComputedStyle(currentElement);
9
14
  if (scrollableOverflowValues.has(computedStyle.overflowX) || scrollableOverflowValues.has(computedStyle.overflowY)) {
10
15
  scrollableAncestors.add(currentElement);
@@ -0,0 +1,70 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import assert from 'node:assert/strict';
3
+ import {suite, test} from 'node:test';
4
+ import isNodeInScanContext from './is-node-in-scan-context';
5
+
6
+ suite('isNodeInScanContext', () => {
7
+ test('doesn’t include an element if scan context is empty', () => {
8
+ const dom = new JSDOM('<div id="test"></div>');
9
+ const { document } = dom.window;
10
+ const node = document.querySelector('#test')!;
11
+
12
+ const scanContext = { include: [], exclude: [] };
13
+
14
+ assert.equal(isNodeInScanContext(node, scanContext), false);
15
+ });
16
+
17
+ test('includes an element whose ancestor is included', () => {
18
+ const dom = new JSDOM('<div id="test"></div>');
19
+ const { document } = dom.window;
20
+ const node = document.querySelector('#test')!;
21
+ const body = document.body;
22
+
23
+ const scanContext = { include: [body], exclude: [] };
24
+
25
+ assert.ok(isNodeInScanContext(node, scanContext));
26
+ });
27
+
28
+ test('does not include an element whose closest ancestor is excluded', () => {
29
+ const dom = new JSDOM('<div id="excluded"><div id="test"></div></div>');
30
+ const { document } = dom.window;
31
+ const node = document.querySelector('#test')!;
32
+ const body = document.body;
33
+ const excludedAncestor = document.querySelector('#excluded')!;
34
+
35
+ const scanContext = { include: [body], exclude: [excludedAncestor] };
36
+
37
+ assert.equal(isNodeInScanContext(node, scanContext), false);
38
+ });
39
+
40
+ test('includes an element if it itself is included', () => {
41
+ const dom = new JSDOM('<div id="test"></div>');
42
+ const { document } = dom.window;
43
+ const node = document.querySelector('#test')!;
44
+
45
+ const scanContext = { include: [node], exclude: [] };
46
+
47
+ assert.ok(isNodeInScanContext(node, scanContext));
48
+ });
49
+
50
+ test('doesn’t include an element if it itself is excluded', () => {
51
+ const dom = new JSDOM('<div id="test"></div>');
52
+ const { document } = dom.window;
53
+ const node = document.querySelector('#test')!;
54
+ const body = document.body;
55
+
56
+ const scanContext = { include: [body], exclude: [node] };
57
+
58
+ assert.equal(isNodeInScanContext(node, scanContext), false);
59
+ });
60
+
61
+ test('includes an element if it’s both included and excluded (include takes precedence)', () => {
62
+ const dom = new JSDOM('<div id="test"></div>');
63
+ const { document } = dom.window;
64
+ const node = document.querySelector('#test')!;
65
+
66
+ const scanContext = { include: [node], exclude: [node] };
67
+
68
+ assert.ok(isNodeInScanContext(node, scanContext));
69
+ });
70
+ });
@@ -0,0 +1,29 @@
1
+ /* Adapted from https://github.com/dequelabs/axe-core/blob/fd6239bfc97ebc904044f93f68d7e49137f744ad/lib/core/utils/is-node-in-context.js */
2
+
3
+ import type { ScanContext } from '../types';
4
+ import contains from './contains.js';
5
+ import ensureNonEmpty from './ensure-non-empty.js';
6
+
7
+ function getDeepest(nodes: [Node, ...Node[]]): Node {
8
+ let deepest = nodes[0];
9
+ for (const node of nodes.slice(1)) {
10
+ if (!contains(node, deepest)) {
11
+ deepest = node;
12
+ }
13
+ }
14
+ return deepest;
15
+ }
16
+
17
+ export default function isNodeInScanContext(node: Node, { include, exclude }: ScanContext): boolean {
18
+ const filteredInclude = include.filter(includeNode => contains(includeNode, node));
19
+ if (filteredInclude.length === 0) {
20
+ return false;
21
+ }
22
+ const filteredExclude = exclude.filter(excludeNode => contains(excludeNode, node));
23
+ if (filteredExclude.length === 0) {
24
+ return true;
25
+ }
26
+ const deepestInclude = getDeepest(ensureNonEmpty(filteredInclude));
27
+ const deepestExclude = getDeepest(ensureNonEmpty(filteredExclude));
28
+ return contains(deepestExclude, deepestInclude);
29
+ }
@@ -0,0 +1,105 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import assert from 'node:assert/strict';
3
+ import { suite, test } from 'node:test';
4
+ import normalizeContext from './normalize-context';
5
+
6
+ suite('normalizeContext', () => {
7
+ test('when document is passed, only document is returned in include', () => {
8
+ const dom = new JSDOM('<div id="test"></div>');
9
+ const { document } = dom.window;
10
+ const normalizedContext = normalizeContext(document);
11
+
12
+ assert.deepEqual(normalizedContext, {
13
+ include: [document],
14
+ exclude: []
15
+ });
16
+ });
17
+
18
+ test('when an element is passed, only that element is returned in include', () => {
19
+ const dom = new JSDOM('<div id="test"></div>');
20
+ const { document } = dom.window;
21
+ const element = document.querySelector('#test')!;
22
+ const normalizedContext = normalizeContext(element);
23
+
24
+ assert.deepEqual(normalizedContext, {
25
+ include: [element],
26
+ exclude: []
27
+ });
28
+ });
29
+
30
+ test('when a selector is passed and elements matching that selector exist, all those elements are included', () => {
31
+ const dom = new JSDOM(`<div>
32
+ <div class="matches"></div>
33
+ <div class="matches"></div>
34
+ <div class="doesnt-match"></div>
35
+ </div>`);
36
+ const { document } = dom.window;
37
+ global.document = document;
38
+ const matchingElements = Array.from(document.querySelectorAll('.matches'));
39
+ const normalizedContext = normalizeContext('.matches');
40
+
41
+ assert.equal(matchingElements.length, 2);
42
+ assert.deepEqual(normalizedContext, {
43
+ include: matchingElements,
44
+ exclude: []
45
+ });
46
+ });
47
+
48
+ test('when a selector is passed and no elements matching that selector exist, `include` is empty', () => {
49
+ const dom = new JSDOM(`<div>
50
+ <div class="doesnt-match"></div>
51
+ <div class="doesnt-match"></div>
52
+ <div class="doesnt-match"></div>
53
+ </div>`);
54
+ const { document } = dom.window;
55
+ global.document = document;
56
+ const normalizedContext = normalizeContext('.matches');
57
+
58
+ assert.deepEqual(normalizedContext, {
59
+ include: [],
60
+ exclude: []
61
+ });
62
+ });
63
+
64
+ test('when a node list is passed, all the nodes from the list are included', () => {
65
+ const dom = new JSDOM(`<div>
66
+ <div class="matches"></div>
67
+ <div class="matches"></div>
68
+ <div class="doesnt-match"></div>
69
+ </div>`);
70
+ const { document } = dom.window;
71
+ const matchingElements = document.querySelectorAll('.matches');
72
+ const normalizedContext = normalizeContext(matchingElements);
73
+
74
+ assert.equal(matchingElements.length, 2);
75
+ assert.deepEqual(normalizedContext, {
76
+ include: Array.from(matchingElements),
77
+ exclude: []
78
+ });
79
+ });
80
+
81
+ test('when an object with `fromShadowDom` is passed, all the matching nodes are included', () => {
82
+ const dom = new JSDOM(`<div>
83
+ <div class="host"></div>
84
+ <div class="host"></div>
85
+ </div>`);
86
+ const { document } = dom.window;
87
+ global.document = document;
88
+ const hosts = document.querySelectorAll('.host');
89
+ const matchingElements = [];
90
+ for (const host of hosts) {
91
+ const shadowRoot = host.attachShadow({ mode: 'open' });
92
+ const matchingElement = document.createElement('div');
93
+ matchingElement.classList.add('matches');
94
+ shadowRoot.appendChild(matchingElement);
95
+ matchingElements.push(matchingElement);
96
+ }
97
+ const normalizedContext = normalizeContext({fromShadowDom: ['.host', '.matches']});
98
+
99
+ assert.equal(matchingElements.length, 2);
100
+ assert.deepEqual(normalizedContext, {
101
+ include: matchingElements,
102
+ exclude: []
103
+ });
104
+ });
105
+ });
@@ -0,0 +1,58 @@
1
+ import type { Context, ContextProp, Selector, ScanContext } from '../types';
2
+ import { isNode, isNodeList } from './dom-helpers.js';
3
+ import { deduplicateNodes } from './deduplicate-nodes.js';
4
+
5
+ function recursiveSelectAll(selectors: Array<string>, root: Document | ShadowRoot): Array<Node> {
6
+ const nodesOnCurrentLevel = root.querySelectorAll(selectors[0]!);
7
+ if (selectors.length === 1) {
8
+ return Array.from(nodesOnCurrentLevel);
9
+ }
10
+ const restSelectors: Array<string> = selectors.slice(1);
11
+ const selected = [];
12
+ for (const node of nodesOnCurrentLevel) {
13
+ if (node.shadowRoot) {
14
+ selected.push(...recursiveSelectAll(restSelectors, node.shadowRoot));
15
+ }
16
+ }
17
+ return selected;
18
+ }
19
+
20
+ function selectorToNodes(selector: Selector): Array<Node> {
21
+ if (typeof selector === 'string') {
22
+ return recursiveSelectAll([selector], document);
23
+ } else if (isNode(selector)) {
24
+ return [selector];
25
+ } else {
26
+ return recursiveSelectAll(selector.fromShadowDom, document);
27
+ }
28
+ }
29
+
30
+ function contextPropToNodes(contextProp: ContextProp): Array<Node> {
31
+ let nodes: Array<Node> = [];
32
+ if (typeof contextProp === 'object' && (Array.isArray(contextProp) || isNodeList(contextProp))) {
33
+ nodes = Array.from(contextProp).map(item => selectorToNodes(item)).flat();
34
+ } else {
35
+ nodes = selectorToNodes(contextProp);
36
+ }
37
+ return deduplicateNodes(nodes);
38
+ }
39
+
40
+ export default function normalizeContext(context: Context): ScanContext {
41
+ let contextInclude: Array<Node> = [];
42
+ let contextExclude: Array<Node> = [];
43
+ if (typeof context === 'object' && ('include' in context || 'exclude' in context)) {
44
+ if (context.include !== undefined) {
45
+ contextInclude = contextPropToNodes(context.include);
46
+ }
47
+ if (context.exclude !== undefined) {
48
+ contextExclude = contextPropToNodes(context.exclude);
49
+ }
50
+ } else {
51
+ contextInclude = contextPropToNodes(context);
52
+ }
53
+
54
+ return {
55
+ include: contextInclude,
56
+ exclude: contextExclude
57
+ };
58
+ }
@@ -0,0 +1,78 @@
1
+ import { isElement, isDocument, isDocumentFragment } from './dom-helpers.js';
2
+ import { getAccentedElementNames } from '../constants.js';
3
+
4
+ export default function createShadowDOMAwareMutationObserver (name: string, callback: MutationCallback) {
5
+ class ShadowDOMAwareMutationObserver extends MutationObserver {
6
+ #shadowRoots = new Set();
7
+
8
+ #options: MutationObserverInit | undefined;
9
+
10
+ constructor(callback: MutationCallback) {
11
+ super((mutations, observer) => {
12
+ const accentedElementNames = getAccentedElementNames(name);
13
+ const childListMutations = mutations
14
+ .filter(mutation => mutation.type === 'childList')
15
+
16
+ const newElements = childListMutations
17
+ .map(mutation => [...mutation.addedNodes])
18
+ .flat()
19
+ .filter(node => isElement(node))
20
+ .filter(node => !accentedElementNames.includes(node.nodeName.toLowerCase()));
21
+
22
+ this.#observeShadowRoots(newElements);
23
+
24
+ const removedElements = childListMutations
25
+ .map(mutation => [...mutation.removedNodes])
26
+ .flat()
27
+ .filter(node => isElement(node))
28
+ .filter(node => !accentedElementNames.includes(node.nodeName.toLowerCase()));
29
+
30
+ // Mutation observer has no "unobserve" method, so we're simply deleting
31
+ // the elements from the set of shadow roots.
32
+ this.#deleteShadowRoots(removedElements);
33
+
34
+ callback(mutations, observer);
35
+ });
36
+ }
37
+
38
+ override observe(target: Node, options?: MutationObserverInit): void {
39
+ this.#options ??= options;
40
+ if (isElement(target) || isDocument(target) || isDocumentFragment(target)) {
41
+ this.#observeShadowRoots([target]);
42
+ }
43
+ super.observe(target, options);
44
+ }
45
+
46
+ override disconnect(): void {
47
+ this.#shadowRoots.clear();
48
+ super.disconnect();
49
+ }
50
+
51
+ #observeShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
52
+ const shadowRoots = elements
53
+ .map(element => [...element.querySelectorAll('*')])
54
+ .flat()
55
+ .filter(element => element.shadowRoot)
56
+ .map(element => element.shadowRoot!);
57
+
58
+ for (const shadowRoot of shadowRoots) {
59
+ this.#shadowRoots.add(shadowRoot);
60
+ this.observe(shadowRoot, this.#options);
61
+ }
62
+ }
63
+
64
+ #deleteShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
65
+ const shadowRoots = elements
66
+ .map(element => [...element.querySelectorAll('*')])
67
+ .flat()
68
+ .filter(element => element.shadowRoot)
69
+ .map(element => element.shadowRoot!);
70
+
71
+ for (const shadowRoot of shadowRoots) {
72
+ this.#shadowRoots.delete(shadowRoot);
73
+ }
74
+ }
75
+ }
76
+
77
+ return new ShadowDOMAwareMutationObserver(callback);
78
+ }
@@ -24,12 +24,14 @@ const commonViolationProps2: Omit<Violation, 'nodes'> = {
24
24
  impact: 'serious'
25
25
  };
26
26
 
27
+ const getRootNode = (): Node => ({} as Node);
28
+
27
29
  // @ts-expect-error element is not HTMLElement
28
- const element1: HTMLElement = {};
30
+ const element1: HTMLElement = {getRootNode};
29
31
  // @ts-expect-error element is not HTMLElement
30
- const element2: HTMLElement = {};
32
+ const element2: HTMLElement = {getRootNode};
31
33
  // @ts-expect-error element is not HTMLElement
32
- const element3: HTMLElement = {};
34
+ const element3: HTMLElement = {getRootNode};
33
35
 
34
36
  const commonNodeProps = {
35
37
  html: '<div></div>',
@@ -65,7 +67,7 @@ suite('transformViolations', () => {
65
67
  ...commonViolationProps1,
66
68
  nodes: [node1]
67
69
  };
68
- const elementsWithIssues = transformViolations([violation]);
70
+ const elementsWithIssues = transformViolations([violation], 'accented');
69
71
  assert.equal(elementsWithIssues.length, 1);
70
72
  assert.equal(elementsWithIssues[0]?.element, element1);
71
73
  assert.equal(elementsWithIssues[0].issues.length, 1);
@@ -82,7 +84,7 @@ suite('transformViolations', () => {
82
84
  ...commonViolationProps2,
83
85
  nodes: [node1, node3]
84
86
  };
85
- const elementsWithIssues = transformViolations([violation1, violation2]);
87
+ const elementsWithIssues = transformViolations([violation1, violation2], 'accented');
86
88
  assert.equal(elementsWithIssues.length, 3);
87
89
  const elementWithTwoIssues = elementsWithIssues.find(elementWithIssues => elementWithIssues.element === element1);
88
90
  assert.equal(elementWithTwoIssues?.issues.length, 2);
@@ -101,7 +103,7 @@ suite('transformViolations', () => {
101
103
  nodes: [node]
102
104
  };
103
105
 
104
- const elementsWithIssues = transformViolations([violation]);
106
+ const elementsWithIssues = transformViolations([violation], 'accented');
105
107
  assert.equal(elementsWithIssues.length, 0);
106
108
  });
107
109
 
@@ -118,7 +120,7 @@ suite('transformViolations', () => {
118
120
  nodes: [node]
119
121
  };
120
122
 
121
- const elementsWithIssues = transformViolations([violation]);
122
- assert.equal(elementsWithIssues.length, 0);
123
+ const elementsWithIssues = transformViolations([violation], 'accented');
124
+ assert.equal(elementsWithIssues.length, 1);
123
125
  });
124
126
  });
@@ -1,12 +1,29 @@
1
1
  import type { AxeResults, ImpactValue } from 'axe-core';
2
2
  import type { Issue, ElementWithIssues } from '../types';
3
3
 
4
+ // This is a list of axe-core violations (their ids) that may be flagged by axe-core
5
+ // as false positives if an Accented trigger is a descendant of the element with the issue.
6
+ const violationsAffectedByAccentedTriggers = [
7
+ 'aria-hidden-focus',
8
+ 'aria-text',
9
+ 'definition-list',
10
+ 'label-content-name-mismatch',
11
+ 'list',
12
+ 'nested-interactive',
13
+ 'scrollable-region-focusable' // The Accented trigger might make the content grow such that scrolling is required.
14
+ ];
15
+
16
+ function maybeCausedByAccented(violationId: string, element: HTMLElement, name: string) {
17
+ return violationsAffectedByAccentedTriggers.includes(violationId)
18
+ && Boolean(element.querySelector(`${name}-trigger`));
19
+ }
20
+
4
21
  function impactCompare(a: ImpactValue, b: ImpactValue) {
5
22
  const impactOrder = [null, 'minor', 'moderate', 'serious', 'critical'];
6
23
  return impactOrder.indexOf(a) - impactOrder.indexOf(b);
7
24
  }
8
25
 
9
- export default function transformViolations(violations: typeof AxeResults.violations) {
26
+ export default function transformViolations(violations: typeof AxeResults.violations, name: string) {
10
27
  const elementsWithIssues: Array<ElementWithIssues> = [];
11
28
 
12
29
  for (const violation of violations) {
@@ -21,11 +38,7 @@ export default function transformViolations(violations: typeof AxeResults.violat
21
38
  // A consumer of Accented can instead scan the iframed document by calling Accented initialization from that document.
22
39
  const isInIframe = target.length > 1;
23
40
 
24
- // Highlighting elements in shadow DOM is not yet supported, see https://github.com/pomerantsev/accented/issues/25
25
- // Until then, we don’t want such elements to be added to the set.
26
- const isInShadowDOM = Array.isArray(target[0]);
27
-
28
- if (element && !isInIframe && !isInShadowDOM) {
41
+ if (element && !isInIframe && !maybeCausedByAccented(violation.id, element, name)) {
29
42
  const issue: Issue = {
30
43
  id: violation.id,
31
44
  title: violation.help,
@@ -37,6 +50,7 @@ export default function transformViolations(violations: typeof AxeResults.violat
37
50
  if (existingElementIndex === -1) {
38
51
  elementsWithIssues.push({
39
52
  element,
53
+ rootNode: element.getRootNode(),
40
54
  issues: [issue]
41
55
  });
42
56
  } else {