accented 1.1.1 → 1.2.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.
Files changed (67) hide show
  1. package/README.md +2 -2
  2. package/dist/accented.d.ts.map +1 -1
  3. package/dist/accented.js +2 -4
  4. package/dist/accented.js.map +1 -1
  5. package/dist/dom-updater.js +6 -6
  6. package/dist/dom-updater.js.map +1 -1
  7. package/dist/elements/accented-dialog.d.ts +2 -2
  8. package/dist/elements/accented-dialog.d.ts.map +1 -1
  9. package/dist/elements/accented-dialog.js +15 -14
  10. package/dist/elements/accented-dialog.js.map +1 -1
  11. package/dist/elements/accented-trigger.d.ts +6 -6
  12. package/dist/elements/accented-trigger.d.ts.map +1 -1
  13. package/dist/elements/accented-trigger.js +2 -2
  14. package/dist/elements/accented-trigger.js.map +1 -1
  15. package/dist/intersection-observer.js +2 -2
  16. package/dist/intersection-observer.js.map +1 -1
  17. package/dist/logger.d.ts +1 -4
  18. package/dist/logger.d.ts.map +1 -1
  19. package/dist/logger.js +15 -15
  20. package/dist/logger.js.map +1 -1
  21. package/dist/scanner.d.ts.map +1 -1
  22. package/dist/scanner.js +4 -5
  23. package/dist/scanner.js.map +1 -1
  24. package/dist/state.js +4 -4
  25. package/dist/state.js.map +1 -1
  26. package/dist/utils/get-element-position.d.ts +1 -1
  27. package/dist/utils/get-element-position.d.ts.map +1 -1
  28. package/dist/utils/get-element-position.js +6 -6
  29. package/dist/utils/get-element-position.js.map +1 -1
  30. package/dist/utils/get-scrollable-ancestors.d.ts +1 -1
  31. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
  32. package/dist/utils/get-scrollable-ancestors.js +2 -2
  33. package/dist/utils/get-scrollable-ancestors.js.map +1 -1
  34. package/dist/utils/recalculate-positions.js +1 -1
  35. package/dist/utils/recalculate-positions.js.map +1 -1
  36. package/dist/utils/recalculate-scrollable-ancestors.js +1 -1
  37. package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -1
  38. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +4 -4
  39. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -1
  40. package/dist/utils/shadow-dom-aware-mutation-observer.js +29 -29
  41. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -1
  42. package/dist/utils/supports-anchor-positioning.d.ts +1 -5
  43. package/dist/utils/supports-anchor-positioning.d.ts.map +1 -1
  44. package/dist/utils/supports-anchor-positioning.js +4 -6
  45. package/dist/utils/supports-anchor-positioning.js.map +1 -1
  46. package/dist/utils/update-elements-with-issues.d.ts +1 -4
  47. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  48. package/dist/utils/update-elements-with-issues.js +8 -10
  49. package/dist/utils/update-elements-with-issues.js.map +1 -1
  50. package/package.json +5 -5
  51. package/src/accented.ts +2 -4
  52. package/src/dom-updater.ts +6 -6
  53. package/src/elements/accented-dialog.ts +16 -17
  54. package/src/elements/accented-trigger.ts +2 -2
  55. package/src/intersection-observer.ts +2 -2
  56. package/src/logger.ts +15 -15
  57. package/src/scanner.ts +4 -5
  58. package/src/state.ts +5 -5
  59. package/src/utils/get-element-position.ts +6 -6
  60. package/src/utils/get-scrollable-ancestors.ts +2 -2
  61. package/src/utils/recalculate-positions.ts +1 -1
  62. package/src/utils/recalculate-scrollable-ancestors.ts +1 -1
  63. package/src/utils/shadow-dom-aware-mutation-observer.test.ts +413 -0
  64. package/src/utils/shadow-dom-aware-mutation-observer.ts +36 -30
  65. package/src/utils/supports-anchor-positioning.ts +4 -10
  66. package/src/utils/update-elements-with-issues.test.ts +29 -54
  67. package/src/utils/update-elements-with-issues.ts +7 -11
package/src/logger.ts CHANGED
@@ -53,8 +53,8 @@ type IssueType = {
53
53
 
54
54
  type GroupedByIssueType = Record<string, IssueType>;
55
55
 
56
- const getIssueTypeGroups = (elementsWithIssues: Array<ElementWithIssues>) => {
57
- const groupedByIssueType = elementsWithIssues.reduce((acc, { element, issues }) => {
56
+ const getIssueTypeGroups = (elsWithIssues: Array<ElementWithIssues>) => {
57
+ const groupedByIssueType = elsWithIssues.reduce((acc, { element, issues }) => {
58
58
  for (const issue of issues) {
59
59
  if (!acc[issue.id]) {
60
60
  acc[issue.id] = {
@@ -80,11 +80,11 @@ const getIssueTypeGroups = (elementsWithIssues: Array<ElementWithIssues>) => {
80
80
  return sorted;
81
81
  };
82
82
 
83
- function logIssuesByElement(elementsWithIssues: Array<ElementWithIssues>) {
83
+ function logIssuesByElement(elsWithIssues: Array<ElementWithIssues>) {
84
84
  // Elements with more severe issues (or with a higher number of issues of the same severity)
85
85
  // will appear higher in the output.
86
86
  // This way, issues with a higher severity will be prioritized.
87
- const sortedElementsWithIssues = elementsWithIssues.toSorted((a, b) => {
87
+ const sortedElementsWithIssues = elsWithIssues.toSorted((a, b) => {
88
88
  const impacts = orderedImpacts.toReversed();
89
89
  const impactWithDifferentIssueCount = impacts.find((impact) => {
90
90
  const aCount = a.issues.filter((issue) => issue.impact === impact).length;
@@ -172,12 +172,12 @@ function logIssuesByType(issueTypeGroups: Array<IssueType>) {
172
172
  }
173
173
 
174
174
  function logNewIssues(
175
- elementsWithIssues: Array<ElementWithIssues>,
175
+ elsWithIssues: Array<ElementWithIssues>,
176
176
  previousElementsWithIssues: Array<ElementWithIssues>,
177
177
  ) {
178
178
  // The elements with accessibility issues that didn't have any associated issues
179
179
  // or that weren't in the DOM at the time of last scan.
180
- const addedElements = elementsWithIssues.filter((elementWithIssues) => {
180
+ const addedElements = elsWithIssues.filter((elementWithIssues) => {
181
181
  return !previousElementsWithIssues.some((previousElementWithIssues) =>
182
182
  areElementsWithIssuesEqual(previousElementWithIssues, elementWithIssues),
183
183
  );
@@ -185,7 +185,7 @@ function logNewIssues(
185
185
 
186
186
  // The elements that now have more issues than at the time of last scan,
187
187
  // with just the new issues (previously existing issues are filtered out).
188
- const existingElementsWithNewIssues = elementsWithIssues.reduce<Array<ElementWithIssues>>(
188
+ const existingElementsWithNewIssues = elsWithIssues.reduce<Array<ElementWithIssues>>(
189
189
  (acc, elementWithIssues) => {
190
190
  let foundElementWithIssues: ElementWithIssues | null = null;
191
191
  for (const previousElementWithIssues of previousElementsWithIssues) {
@@ -232,17 +232,17 @@ function logNewIssues(
232
232
  }
233
233
 
234
234
  function logIssues(
235
- elementsWithIssues: Array<ElementWithIssues>,
235
+ elsWithIssues: Array<ElementWithIssues>,
236
236
  previousElementsWithIssues: Array<ElementWithIssues>,
237
237
  ) {
238
- const elementCount = elementsWithIssues.length;
238
+ const elementCount = elsWithIssues.length;
239
239
 
240
240
  if (elementCount === 0) {
241
241
  console.log(`No accessibility issues (Accented, ${accentedUrl}).`);
242
242
  return;
243
243
  }
244
244
 
245
- const issueCount = elementsWithIssues.reduce((acc, { issues }) => acc + issues.length, 0);
245
+ const issueCount = elsWithIssues.reduce((acc, { issues }) => acc + issues.length, 0);
246
246
  console.group(
247
247
  `%c${issueCount} accessibility issue${issueCount === 1 ? '' : 's'} in ${elementCount} element${elementCount === 1 ? '' : 's'} (Accented, ${accentedUrl}):\n`,
248
248
  defaultStyle,
@@ -251,16 +251,16 @@ function logIssues(
251
251
  if (issueCount <= MAX_ISSUES_BEFORE_OUTPUT_COLLAPSE) {
252
252
  // Don't collapse issues if there are not too many (this hopefully helps user avoid unnecessary clicks in the console).
253
253
  // Output by element (no specific reason for this choice, just a preference).
254
- logIssuesByElement(elementsWithIssues);
254
+ logIssuesByElement(elsWithIssues);
255
255
  } else {
256
256
  // When there are many issues, outputting them all would probably make the console too noisy,
257
257
  // so we collapse them.
258
258
  // Moreover, we output all issues twice, by element and by issue type, to give users more choice.
259
- console.groupCollapsed(`%cAll by element (${elementsWithIssues.length})`, defaultStyle);
260
- logIssuesByElement(elementsWithIssues);
259
+ console.groupCollapsed(`%cAll by element (${elsWithIssues.length})`, defaultStyle);
260
+ logIssuesByElement(elsWithIssues);
261
261
  console.groupEnd();
262
262
 
263
- const issueTypeGroups = getIssueTypeGroups(elementsWithIssues);
263
+ const issueTypeGroups = getIssueTypeGroups(elsWithIssues);
264
264
  console.groupCollapsed(`%cAll by issue type (${issueTypeGroups.length})`, defaultStyle);
265
265
  logIssuesByType(issueTypeGroups);
266
266
  console.groupEnd();
@@ -269,7 +269,7 @@ function logIssues(
269
269
  if (previousElementsWithIssues.length > 0) {
270
270
  // Log new issues separately, to make it easier for the user to know what issues
271
271
  // were introduced recently.
272
- logNewIssues(elementsWithIssues, previousElementsWithIssues);
272
+ logNewIssues(elsWithIssues, previousElementsWithIssues);
273
273
  }
274
274
 
275
275
  console.groupEnd();
package/src/scanner.ts CHANGED
@@ -96,7 +96,6 @@ export function createScanner(
96
96
  extendedElementsWithIssues,
97
97
  scanContext,
98
98
  violations: result.violations,
99
- win: window,
100
99
  name,
101
100
  });
102
101
 
@@ -143,7 +142,7 @@ export function createScanner(
143
142
  return !(onlyAccentedElementsAddedOrRemoved || accentedElementChanged);
144
143
  });
145
144
 
146
- if (listWithoutAccentedElements.length !== 0 && !supportsAnchorPositioning(window)) {
145
+ if (listWithoutAccentedElements.length !== 0 && !supportsAnchorPositioning()) {
147
146
  // Something has changed in the DOM, so we need to realign all triggers with respective elements.
148
147
  recalculatePositions();
149
148
 
@@ -159,14 +158,14 @@ export function createScanner(
159
158
  // we may miss other mutations on those same elements caused by Accented,
160
159
  // leading to extra runs of the mutation observer.
161
160
  const elementsWithAccentedAttributeChanges = listWithoutAccentedElements.reduce(
162
- (nodes, mutationRecord) => {
161
+ (acc, mutationRecord) => {
163
162
  if (
164
163
  mutationRecord.type === 'attributes' &&
165
164
  mutationRecord.attributeName === `data-${name}`
166
165
  ) {
167
- nodes.add(mutationRecord.target);
166
+ acc.add(mutationRecord.target);
168
167
  }
169
- return nodes;
168
+ return acc;
170
169
  },
171
170
  new Set<Node>(),
172
171
  );
package/src/state.ts CHANGED
@@ -14,10 +14,10 @@ export const elementsWithIssues = computed<Array<ElementWithIssues>>(() =>
14
14
  })),
15
15
  );
16
16
 
17
- export const rootNodes = computed<Set<Node>>(
17
+ export const rootNodes = computed(
18
18
  () =>
19
19
  new Set(
20
- (enabled.value ? [document as Node] : []).concat(
20
+ Array.from<Node>(enabled.value ? [document] : []).concat(
21
21
  ...extendedElementsWithIssues.value.map(
22
22
  (extendedElementWithIssues) => extendedElementWithIssues.rootNode,
23
23
  ),
@@ -26,10 +26,10 @@ export const rootNodes = computed<Set<Node>>(
26
26
  );
27
27
 
28
28
  export const scrollableAncestors = computed<Set<Element>>(() =>
29
- extendedElementsWithIssues.value.reduce((scrollableAncestors, extendedElementWithIssues) => {
29
+ extendedElementsWithIssues.value.reduce((acc, extendedElementWithIssues) => {
30
30
  for (const scrollableAncestor of extendedElementWithIssues.scrollableAncestors.value) {
31
- scrollableAncestors.add(scrollableAncestor);
31
+ acc.add(scrollableAncestor);
32
32
  }
33
- return scrollableAncestors;
33
+ return acc;
34
34
  }, new Set<Element>()),
35
35
  );
@@ -4,8 +4,8 @@ import { isHtmlElement } from './dom-helpers.js';
4
4
  import { getParent } from './get-parent.js';
5
5
 
6
6
  // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#identifying_the_containing_block
7
- function isContainingBlock(element: Element, win: Window): boolean {
8
- const style = win.getComputedStyle(element);
7
+ function isContainingBlock(element: Element): boolean {
8
+ const style = getComputedStyle(element);
9
9
  const {
10
10
  transform,
11
11
  perspective,
@@ -33,11 +33,11 @@ function isContainingBlock(element: Element, win: Window): boolean {
33
33
  );
34
34
  }
35
35
 
36
- function getNonInitialContainingBlock(element: Element, win: Window): Element | null {
36
+ function getNonInitialContainingBlock(element: Element): Element | null {
37
37
  let currentElement: Element | null = element;
38
38
  while (currentElement) {
39
39
  currentElement = getParent(currentElement);
40
- if (currentElement && isContainingBlock(currentElement, win)) {
40
+ if (currentElement && isContainingBlock(currentElement)) {
41
41
  return currentElement;
42
42
  }
43
43
  }
@@ -52,8 +52,8 @@ function getNonInitialContainingBlock(element: Element, win: Window): Element |
52
52
  * * The element itself, or one of the element's ancestors has a scale or rotate transform.
53
53
  * * The browser doesn't support anchor positioning.
54
54
  */
55
- export function getElementPosition(element: Element, win: Window): Position {
56
- const nonInitialContainingBlock = getNonInitialContainingBlock(element, win);
55
+ export function getElementPosition(element: Element): Position {
56
+ const nonInitialContainingBlock = getNonInitialContainingBlock(element);
57
57
  // If an element has a containing block as an ancestor,
58
58
  // and that containing block is not the <html> element (the initial containing block),
59
59
  // fixed positioning works differently.
@@ -2,7 +2,7 @@ import { getParent } from './get-parent.js';
2
2
 
3
3
  const scrollableOverflowValues = new Set(['auto', 'scroll', 'hidden']);
4
4
 
5
- export function getScrollableAncestors(element: Element, win: Window) {
5
+ export function getScrollableAncestors(element: Element) {
6
6
  let currentElement: Element | null = element;
7
7
  const scrollableAncestors = new Set<Element>();
8
8
  while (true) {
@@ -10,7 +10,7 @@ export function getScrollableAncestors(element: Element, win: Window) {
10
10
  if (!currentElement) {
11
11
  break;
12
12
  }
13
- const computedStyle = win.getComputedStyle(currentElement);
13
+ const computedStyle = getComputedStyle(currentElement);
14
14
  if (
15
15
  scrollableOverflowValues.has(computedStyle.overflowX) ||
16
16
  scrollableOverflowValues.has(computedStyle.overflowY)
@@ -16,7 +16,7 @@ export function recalculatePositions() {
16
16
  batch(() => {
17
17
  for (const { element, position, visible } of extendedElementsWithIssues.value) {
18
18
  if (visible.value && element.isConnected) {
19
- position.value = getElementPosition(element, window);
19
+ position.value = getElementPosition(element);
20
20
  }
21
21
  }
22
22
  });
@@ -6,7 +6,7 @@ export function recalculateScrollableAncestors() {
6
6
  batch(() => {
7
7
  for (const { element, scrollableAncestors } of extendedElementsWithIssues.value) {
8
8
  if (element.isConnected) {
9
- scrollableAncestors.value = getScrollableAncestors(element, window);
9
+ scrollableAncestors.value = getScrollableAncestors(element);
10
10
  }
11
11
  }
12
12
  });
@@ -0,0 +1,413 @@
1
+ import assert from 'node:assert/strict';
2
+ import { beforeEach, suite, test } from 'node:test';
3
+ import { JSDOM } from 'jsdom';
4
+ import { createShadowDOMAwareMutationObserver } from './shadow-dom-aware-mutation-observer';
5
+
6
+ type MutationAssertion = {
7
+ type: string;
8
+ target?: Node;
9
+ addedNodes?: { length: number; tagName?: string };
10
+ removedNodes?: { length: number; tagName?: string };
11
+ };
12
+
13
+ const createTestDOM = (html = '<div id="container"></div>') => {
14
+ const dom = new JSDOM(html);
15
+ return { dom, document: dom.window.document };
16
+ };
17
+
18
+ const waitForMutation = (ms = 10) => new Promise((resolve) => setTimeout(resolve, ms));
19
+
20
+ const createElementWithShadowRoot = (document: Document, tagName = 'div') => {
21
+ const element = document.createElement(tagName);
22
+ const shadowRoot = element.attachShadow({ mode: 'open' });
23
+ return { element, shadowRoot };
24
+ };
25
+
26
+ const assertMutationEquals = (mutation: MutationRecord, expected: MutationAssertion) => {
27
+ assert.equal(mutation.type, expected.type);
28
+ if (expected.target) {
29
+ assert.equal(mutation.target, expected.target);
30
+ }
31
+ if (expected.addedNodes) {
32
+ assert.equal(mutation.addedNodes.length, expected.addedNodes.length);
33
+ if (expected.addedNodes.tagName) {
34
+ assert.equal((mutation.addedNodes[0] as Element)?.tagName, expected.addedNodes.tagName);
35
+ }
36
+ }
37
+ if (expected.removedNodes) {
38
+ assert.equal(mutation.removedNodes.length, expected.removedNodes.length);
39
+ if (expected.removedNodes.tagName) {
40
+ assert.equal((mutation.removedNodes[0] as Element)?.tagName, expected.removedNodes.tagName);
41
+ }
42
+ }
43
+ };
44
+
45
+ const createAsyncMutationTest = (expectedMutations: number) => {
46
+ let mutationCount = 0;
47
+ let resolveTest: () => void;
48
+ const testComplete = new Promise<void>((resolve) => {
49
+ resolveTest = resolve;
50
+ });
51
+
52
+ const handleMutation =
53
+ (callback: (mutations: MutationRecord[], count: number) => void) =>
54
+ (mutations: MutationRecord[]) => {
55
+ mutationCount++;
56
+ callback(mutations, mutationCount);
57
+
58
+ if (mutationCount === expectedMutations) {
59
+ resolveTest();
60
+ }
61
+ };
62
+
63
+ return { handleMutation, testComplete, getMutationCount: () => mutationCount };
64
+ };
65
+
66
+ suite('ShadowDOMAwareMutationObserver', () => {
67
+ beforeEach(() => {
68
+ const dom = new JSDOM();
69
+ global.Node = dom.window.Node;
70
+ global.MutationObserver = dom.window.MutationObserver;
71
+ });
72
+
73
+ suite('inserting elements', () => {
74
+ test('fires when a node is inserted in an observed node', (_t, done) => {
75
+ const { document } = createTestDOM();
76
+ const container = document.querySelector('#container')!;
77
+
78
+ const observer = createShadowDOMAwareMutationObserver('test', (mutations) => {
79
+ assert.equal(mutations.length, 1);
80
+ assertMutationEquals(mutations[0]!, {
81
+ type: 'childList',
82
+ addedNodes: { length: 1, tagName: 'DIV' },
83
+ });
84
+
85
+ done();
86
+ });
87
+
88
+ observer.observe(container, { childList: true });
89
+
90
+ const newDiv = document.createElement('div');
91
+ container.appendChild(newDiv);
92
+ });
93
+
94
+ test('fires when a node is inserted in a shadow root of an observed element', (_t, done) => {
95
+ const { document } = createTestDOM('<div id="container"><div id="host"></div></div>');
96
+ const container = document.querySelector('#container')!;
97
+ const host = document.querySelector('#host')!;
98
+
99
+ const shadowRoot = host.attachShadow({ mode: 'open' });
100
+
101
+ const observer = createShadowDOMAwareMutationObserver('test', (mutations) => {
102
+ assert.equal(mutations.length, 1);
103
+ assertMutationEquals(mutations[0]!, {
104
+ type: 'childList',
105
+ addedNodes: { length: 1, tagName: 'DIV' },
106
+ });
107
+
108
+ done();
109
+ });
110
+
111
+ observer.observe(container, { childList: true });
112
+
113
+ const newDiv = document.createElement('div');
114
+ shadowRoot.appendChild(newDiv);
115
+ });
116
+
117
+ // This is an unfortunate limitation: we cannot be notified when a shadow root is created
118
+ // in the observed DOM. When that happens, the whole shadow root subtree will stay unobserved.
119
+ test('does not fire when a node is inserted in a shadow root created after observer starts', (_t, done) => {
120
+ const { document } = createTestDOM('<div id="container"><div id="host"></div></div>');
121
+ const container = document.querySelector('#container')!;
122
+ const host = document.querySelector('#host')!;
123
+
124
+ let callbackFired = false;
125
+ const observer = createShadowDOMAwareMutationObserver('test', () => {
126
+ callbackFired = true;
127
+ done(new Error('Callback should not have fired'));
128
+ });
129
+
130
+ observer.observe(container, { childList: true, subtree: true });
131
+
132
+ const shadowRoot = host.attachShadow({ mode: 'open' });
133
+ const newDiv = document.createElement('div');
134
+ shadowRoot.appendChild(newDiv);
135
+
136
+ setTimeout(() => {
137
+ assert.equal(
138
+ callbackFired,
139
+ false,
140
+ 'Callback should not have fired for shadow root created after observation',
141
+ );
142
+ done();
143
+ }, 50);
144
+ });
145
+
146
+ test('discovers shadow roots when descendants with shadow roots are added', async () => {
147
+ // This test verifies that when a wrapper element containing descendant shadow hosts
148
+ // is added to the DOM, the shadow roots are properly discovered and observed
149
+ const { document } = createTestDOM();
150
+ const container = document.querySelector('#container')!;
151
+
152
+ const { handleMutation, testComplete } = createAsyncMutationTest(2);
153
+
154
+ const { element: hostElement, shadowRoot } = createElementWithShadowRoot(document);
155
+ const wrapper = document.createElement('div');
156
+ wrapper.appendChild(hostElement);
157
+
158
+ const observer = createShadowDOMAwareMutationObserver(
159
+ 'test',
160
+ handleMutation((mutations, count) => {
161
+ assert.equal(mutations.length, 1);
162
+
163
+ if (count === 1) {
164
+ // First mutation: wrapper with descendant that has shadow root
165
+ assertMutationEquals(mutations[0]!, {
166
+ type: 'childList',
167
+ target: container,
168
+ addedNodes: { length: 1, tagName: 'DIV' },
169
+ });
170
+ } else if (count === 2) {
171
+ // Second mutation: element added to the shadow root that was discovered
172
+ assertMutationEquals(mutations[0]!, {
173
+ type: 'childList',
174
+ target: shadowRoot,
175
+ addedNodes: { length: 1, tagName: 'SPAN' },
176
+ });
177
+ }
178
+ }),
179
+ );
180
+
181
+ observer.observe(container, { childList: true, subtree: true });
182
+
183
+ container.appendChild(wrapper);
184
+ await waitForMutation();
185
+
186
+ const shadowDiv = document.createElement('span');
187
+ shadowRoot.appendChild(shadowDiv);
188
+
189
+ await testComplete;
190
+ });
191
+
192
+ test('discovers shadow roots on directly added elements', async () => {
193
+ // This test verifies that shadow roots are discovered on elements that are
194
+ // added directly to the observed container (not just on descendants)
195
+ const { document } = createTestDOM();
196
+ const container = document.querySelector('#container')!;
197
+
198
+ const { handleMutation, testComplete } = createAsyncMutationTest(2);
199
+ const { element: hostElement, shadowRoot } = createElementWithShadowRoot(document);
200
+
201
+ const observer = createShadowDOMAwareMutationObserver(
202
+ 'test',
203
+ handleMutation((mutations, count) => {
204
+ assert.equal(mutations.length, 1);
205
+
206
+ if (count === 1) {
207
+ // First mutation: element with shadow root added directly
208
+ assertMutationEquals(mutations[0]!, {
209
+ type: 'childList',
210
+ target: container,
211
+ addedNodes: { length: 1, tagName: 'DIV' },
212
+ });
213
+ } else if (count === 2) {
214
+ // Second mutation: element added to the shadow root on the directly added element
215
+ assertMutationEquals(mutations[0]!, {
216
+ type: 'childList',
217
+ target: shadowRoot,
218
+ addedNodes: { length: 1, tagName: 'SPAN' },
219
+ });
220
+ }
221
+ }),
222
+ );
223
+
224
+ observer.observe(container, { childList: true, subtree: true });
225
+
226
+ // Add element with shadow root directly to container
227
+ // This should now work with the bug fix (previously didn't work)
228
+ container.appendChild(hostElement);
229
+ await waitForMutation();
230
+
231
+ const shadowDiv = document.createElement('span');
232
+ shadowRoot.appendChild(shadowDiv);
233
+
234
+ await testComplete;
235
+ });
236
+
237
+ test('discovers and observes nested shadow roots (two levels deep)', async () => {
238
+ const { document } = createTestDOM();
239
+ const container = document.querySelector('#container')!;
240
+
241
+ const { handleMutation, testComplete } = createAsyncMutationTest(3);
242
+ const { element: outerElement, shadowRoot: outerShadowRoot } =
243
+ createElementWithShadowRoot(document);
244
+ const { element: innerElement, shadowRoot: innerShadowRoot } = createElementWithShadowRoot(
245
+ document,
246
+ 'span',
247
+ );
248
+
249
+ const observer = createShadowDOMAwareMutationObserver(
250
+ 'test',
251
+ handleMutation((mutations, count) => {
252
+ assert.equal(mutations.length, 1);
253
+
254
+ if (count === 1) {
255
+ // First mutation: outer element with shadow root added
256
+ assertMutationEquals(mutations[0]!, {
257
+ type: 'childList',
258
+ target: container,
259
+ addedNodes: { length: 1, tagName: 'DIV' },
260
+ });
261
+ } else if (count === 2) {
262
+ // Second mutation: inner element with nested shadow root added to outer shadow root
263
+ assertMutationEquals(mutations[0]!, {
264
+ type: 'childList',
265
+ target: outerShadowRoot,
266
+ addedNodes: { length: 1, tagName: 'SPAN' },
267
+ });
268
+ } else if (count === 3) {
269
+ // Third mutation: element added to nested (inner) shadow root
270
+ assertMutationEquals(mutations[0]!, {
271
+ type: 'childList',
272
+ target: innerShadowRoot,
273
+ addedNodes: { length: 1, tagName: 'P' },
274
+ });
275
+ }
276
+ }),
277
+ );
278
+
279
+ observer.observe(container, { childList: true, subtree: true });
280
+
281
+ container.appendChild(outerElement);
282
+ await waitForMutation();
283
+
284
+ outerShadowRoot.appendChild(innerElement);
285
+ await waitForMutation();
286
+
287
+ const deepElement = document.createElement('p');
288
+ innerShadowRoot.appendChild(deepElement);
289
+
290
+ await testComplete;
291
+ });
292
+ });
293
+
294
+ suite('removing elements', () => {
295
+ // Tests for proper cleanup when elements are removed from the DOM
296
+ test('does not fire when elements are added to removed elements', async () => {
297
+ const { document } = createTestDOM();
298
+ const container = document.querySelector('#container')!;
299
+
300
+ const { handleMutation, testComplete } = createAsyncMutationTest(2);
301
+ let completionScheduled = false;
302
+
303
+ const observer = createShadowDOMAwareMutationObserver(
304
+ 'test',
305
+ handleMutation((mutations, count) => {
306
+ assert.equal(mutations.length, 1);
307
+
308
+ if (count === 1) {
309
+ // First mutation: element added to container
310
+ assertMutationEquals(mutations[0]!, {
311
+ type: 'childList',
312
+ target: container,
313
+ addedNodes: { length: 1, tagName: 'DIV' },
314
+ });
315
+ } else if (count === 2) {
316
+ // Second mutation: element removed from container
317
+ assertMutationEquals(mutations[0]!, {
318
+ type: 'childList',
319
+ target: container,
320
+ removedNodes: { length: 1, tagName: 'DIV' },
321
+ });
322
+
323
+ // Schedule completion check to ensure no third mutation occurs
324
+ if (!completionScheduled) {
325
+ completionScheduled = true;
326
+ }
327
+ } else {
328
+ throw new Error(`Unexpected mutation count: ${count}`);
329
+ }
330
+ }),
331
+ );
332
+
333
+ observer.observe(container, { childList: true, subtree: true });
334
+
335
+ const testElement = document.createElement('div');
336
+ container.appendChild(testElement);
337
+ await waitForMutation();
338
+
339
+ container.removeChild(testElement);
340
+ await waitForMutation();
341
+
342
+ // Add element to the removed element (should NOT trigger mutation)
343
+ const childElement = document.createElement('span');
344
+ testElement.appendChild(childElement);
345
+
346
+ await testComplete;
347
+ });
348
+
349
+ test('does not fire when elements are added to shadow roots of removed hosts', async () => {
350
+ const { document } = createTestDOM();
351
+ const container = document.querySelector('#container')!;
352
+
353
+ const { handleMutation, testComplete } = createAsyncMutationTest(3);
354
+ const { element: shadowHost, shadowRoot } = createElementWithShadowRoot(document);
355
+ let completionScheduled = false;
356
+
357
+ const observer = createShadowDOMAwareMutationObserver(
358
+ 'test',
359
+ handleMutation((mutations, count) => {
360
+ assert.equal(mutations.length, 1);
361
+
362
+ if (count === 1) {
363
+ // First mutation: shadow host added to container
364
+ assertMutationEquals(mutations[0]!, {
365
+ type: 'childList',
366
+ target: container,
367
+ addedNodes: { length: 1, tagName: 'DIV' },
368
+ });
369
+ } else if (count === 2) {
370
+ // Second mutation: element added to shadow root
371
+ assertMutationEquals(mutations[0]!, {
372
+ type: 'childList',
373
+ target: shadowRoot,
374
+ addedNodes: { length: 1, tagName: 'SPAN' },
375
+ });
376
+ } else if (count === 3) {
377
+ // Third mutation: shadow host removed from container
378
+ assertMutationEquals(mutations[0]!, {
379
+ type: 'childList',
380
+ target: container,
381
+ removedNodes: { length: 1, tagName: 'DIV' },
382
+ });
383
+
384
+ // Schedule completion check to ensure no fourth mutation occurs
385
+ if (!completionScheduled) {
386
+ completionScheduled = true;
387
+ }
388
+ } else {
389
+ throw new Error(`Unexpected mutation count: ${count}`);
390
+ }
391
+ }),
392
+ );
393
+
394
+ observer.observe(container, { childList: true, subtree: true });
395
+
396
+ container.appendChild(shadowHost);
397
+ await waitForMutation();
398
+
399
+ const shadowChild = document.createElement('span');
400
+ shadowRoot.appendChild(shadowChild);
401
+ await waitForMutation();
402
+
403
+ container.removeChild(shadowHost);
404
+ await waitForMutation();
405
+
406
+ // Add another element to the shadow root of removed host (should NOT trigger mutation)
407
+ const anotherShadowChild = document.createElement('p');
408
+ shadowRoot.appendChild(anotherShadowChild);
409
+
410
+ await testComplete;
411
+ });
412
+ });
413
+ });