accented 0.0.0-20250404114312 → 0.0.0-20250618181418

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 (207) hide show
  1. package/README.md +0 -207
  2. package/dist/accented.d.ts +2 -2
  3. package/dist/accented.d.ts.map +1 -1
  4. package/dist/accented.js +29 -23
  5. package/dist/accented.js.map +1 -1
  6. package/dist/common/tokens.d.ts +2 -0
  7. package/dist/common/tokens.d.ts.map +1 -0
  8. package/dist/common/tokens.js +2 -0
  9. package/dist/common/tokens.js.map +1 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/dom-updater.d.ts +1 -1
  12. package/dist/dom-updater.d.ts.map +1 -1
  13. package/dist/dom-updater.js +43 -16
  14. package/dist/dom-updater.js.map +1 -1
  15. package/dist/elements/accented-dialog.d.ts +13 -10
  16. package/dist/elements/accented-dialog.d.ts.map +1 -1
  17. package/dist/elements/accented-dialog.js +46 -69
  18. package/dist/elements/accented-dialog.js.map +1 -1
  19. package/dist/elements/accented-trigger.d.ts +12 -9
  20. package/dist/elements/accented-trigger.d.ts.map +1 -1
  21. package/dist/elements/accented-trigger.js +14 -15
  22. package/dist/elements/accented-trigger.js.map +1 -1
  23. package/dist/fullscreen-listener.d.ts +1 -1
  24. package/dist/fullscreen-listener.d.ts.map +1 -1
  25. package/dist/fullscreen-listener.js +3 -4
  26. package/dist/fullscreen-listener.js.map +1 -1
  27. package/dist/intersection-observer.d.ts +1 -1
  28. package/dist/intersection-observer.d.ts.map +1 -1
  29. package/dist/intersection-observer.js +12 -6
  30. package/dist/intersection-observer.js.map +1 -1
  31. package/dist/log-and-rethrow.d.ts +1 -1
  32. package/dist/log-and-rethrow.d.ts.map +1 -1
  33. package/dist/log-and-rethrow.js +2 -3
  34. package/dist/log-and-rethrow.js.map +1 -1
  35. package/dist/logger.d.ts +1 -1
  36. package/dist/logger.d.ts.map +1 -1
  37. package/dist/logger.js +6 -3
  38. package/dist/logger.js.map +1 -1
  39. package/dist/register-elements.d.ts +1 -1
  40. package/dist/register-elements.d.ts.map +1 -1
  41. package/dist/register-elements.js +6 -7
  42. package/dist/register-elements.js.map +1 -1
  43. package/dist/resize-listener.d.ts +1 -1
  44. package/dist/resize-listener.d.ts.map +1 -1
  45. package/dist/resize-listener.js +3 -4
  46. package/dist/resize-listener.js.map +1 -1
  47. package/dist/scanner.d.ts +2 -2
  48. package/dist/scanner.d.ts.map +1 -1
  49. package/dist/scanner.js +39 -36
  50. package/dist/scanner.js.map +1 -1
  51. package/dist/scroll-listeners.d.ts +1 -1
  52. package/dist/scroll-listeners.d.ts.map +1 -1
  53. package/dist/scroll-listeners.js +3 -4
  54. package/dist/scroll-listeners.js.map +1 -1
  55. package/dist/state.d.ts +1 -1
  56. package/dist/state.d.ts.map +1 -1
  57. package/dist/state.js +4 -5
  58. package/dist/state.js.map +1 -1
  59. package/dist/task-queue.d.ts +4 -4
  60. package/dist/task-queue.d.ts.map +1 -1
  61. package/dist/task-queue.js +3 -2
  62. package/dist/task-queue.js.map +1 -1
  63. package/dist/types.d.ts +28 -6
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/types.js.map +1 -1
  66. package/dist/utils/are-elements-with-issues-equal.d.ts +2 -2
  67. package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -1
  68. package/dist/utils/are-elements-with-issues-equal.js +3 -3
  69. package/dist/utils/are-elements-with-issues-equal.js.map +1 -1
  70. package/dist/utils/are-issue-sets-equal.d.ts +2 -2
  71. package/dist/utils/are-issue-sets-equal.d.ts.map +1 -1
  72. package/dist/utils/are-issue-sets-equal.js +3 -3
  73. package/dist/utils/are-issue-sets-equal.js.map +1 -1
  74. package/dist/utils/containing-blocks.d.ts +3 -0
  75. package/dist/utils/containing-blocks.d.ts.map +1 -0
  76. package/dist/utils/containing-blocks.js +46 -0
  77. package/dist/utils/containing-blocks.js.map +1 -0
  78. package/dist/utils/contains.d.ts +2 -0
  79. package/dist/utils/contains.d.ts.map +1 -0
  80. package/dist/utils/contains.js +19 -0
  81. package/dist/utils/contains.js.map +1 -0
  82. package/dist/utils/deduplicate-nodes.d.ts +2 -0
  83. package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
  84. package/dist/utils/deduplicate-nodes.js +4 -0
  85. package/dist/utils/deduplicate-nodes.js.map +1 -0
  86. package/dist/utils/deep-merge.d.ts +1 -1
  87. package/dist/utils/deep-merge.d.ts.map +1 -1
  88. package/dist/utils/deep-merge.js +6 -5
  89. package/dist/utils/deep-merge.js.map +1 -1
  90. package/dist/utils/dom-helpers.d.ts +3 -0
  91. package/dist/utils/dom-helpers.d.ts.map +1 -1
  92. package/dist/utils/dom-helpers.js +15 -0
  93. package/dist/utils/dom-helpers.js.map +1 -1
  94. package/dist/utils/ensure-non-empty.d.ts +2 -0
  95. package/dist/utils/ensure-non-empty.d.ts.map +1 -0
  96. package/dist/utils/ensure-non-empty.js +7 -0
  97. package/dist/utils/ensure-non-empty.js.map +1 -0
  98. package/dist/utils/get-element-html.d.ts +1 -1
  99. package/dist/utils/get-element-html.d.ts.map +1 -1
  100. package/dist/utils/get-element-html.js +4 -2
  101. package/dist/utils/get-element-html.js.map +1 -1
  102. package/dist/utils/get-element-position.d.ts +10 -2
  103. package/dist/utils/get-element-position.d.ts.map +1 -1
  104. package/dist/utils/get-element-position.js +34 -23
  105. package/dist/utils/get-element-position.js.map +1 -1
  106. package/dist/utils/get-parent.d.ts +1 -1
  107. package/dist/utils/get-parent.d.ts.map +1 -1
  108. package/dist/utils/get-parent.js +1 -1
  109. package/dist/utils/get-parent.js.map +1 -1
  110. package/dist/utils/get-scan-context.d.ts +3 -0
  111. package/dist/utils/get-scan-context.d.ts.map +1 -0
  112. package/dist/utils/get-scan-context.js +28 -0
  113. package/dist/utils/get-scan-context.js.map +1 -0
  114. package/dist/utils/get-scrollable-ancestors.d.ts +1 -1
  115. package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
  116. package/dist/utils/get-scrollable-ancestors.js +5 -5
  117. package/dist/utils/get-scrollable-ancestors.js.map +1 -1
  118. package/dist/utils/is-node-in-scan-context.d.ts +3 -0
  119. package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
  120. package/dist/utils/is-node-in-scan-context.js +26 -0
  121. package/dist/utils/is-node-in-scan-context.js.map +1 -0
  122. package/dist/utils/is-non-empty.d.ts +2 -0
  123. package/dist/utils/is-non-empty.d.ts.map +1 -0
  124. package/dist/utils/is-non-empty.js +4 -0
  125. package/dist/utils/is-non-empty.js.map +1 -0
  126. package/dist/utils/normalize-context.d.ts +3 -0
  127. package/dist/utils/normalize-context.d.ts.map +1 -0
  128. package/dist/utils/normalize-context.js +59 -0
  129. package/dist/utils/normalize-context.js.map +1 -0
  130. package/dist/utils/recalculate-positions.d.ts +1 -1
  131. package/dist/utils/recalculate-positions.d.ts.map +1 -1
  132. package/dist/utils/recalculate-positions.js +5 -5
  133. package/dist/utils/recalculate-positions.js.map +1 -1
  134. package/dist/utils/recalculate-scrollable-ancestors.d.ts +1 -1
  135. package/dist/utils/recalculate-scrollable-ancestors.d.ts.map +1 -1
  136. package/dist/utils/recalculate-scrollable-ancestors.js +4 -4
  137. package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -1
  138. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +1 -1
  139. package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -1
  140. package/dist/utils/shadow-dom-aware-mutation-observer.js +19 -22
  141. package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -1
  142. package/dist/utils/supports-anchor-positioning.d.ts +1 -1
  143. package/dist/utils/supports-anchor-positioning.d.ts.map +1 -1
  144. package/dist/utils/supports-anchor-positioning.js +1 -1
  145. package/dist/utils/supports-anchor-positioning.js.map +1 -1
  146. package/dist/utils/transform-violations.d.ts +2 -2
  147. package/dist/utils/transform-violations.d.ts.map +1 -1
  148. package/dist/utils/transform-violations.js +9 -9
  149. package/dist/utils/transform-violations.js.map +1 -1
  150. package/dist/utils/update-elements-with-issues.d.ts +11 -5
  151. package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
  152. package/dist/utils/update-elements-with-issues.js +54 -26
  153. package/dist/utils/update-elements-with-issues.js.map +1 -1
  154. package/dist/validate-options.d.ts +2 -2
  155. package/dist/validate-options.d.ts.map +1 -1
  156. package/dist/validate-options.js +91 -4
  157. package/dist/validate-options.js.map +1 -1
  158. package/package.json +11 -5
  159. package/src/accented.test.ts +2 -2
  160. package/src/accented.ts +38 -28
  161. package/src/common/tokens.ts +1 -0
  162. package/src/dom-updater.ts +59 -22
  163. package/src/elements/accented-dialog.ts +102 -106
  164. package/src/elements/accented-trigger.ts +58 -48
  165. package/src/fullscreen-listener.ts +15 -11
  166. package/src/intersection-observer.ts +27 -16
  167. package/src/log-and-rethrow.ts +2 -3
  168. package/src/logger.ts +14 -4
  169. package/src/register-elements.ts +7 -7
  170. package/src/resize-listener.ts +15 -11
  171. package/src/scanner.ts +70 -50
  172. package/src/scroll-listeners.ts +27 -19
  173. package/src/state.ts +24 -21
  174. package/src/task-queue.test.ts +5 -4
  175. package/src/task-queue.ts +8 -6
  176. package/src/types.ts +74 -42
  177. package/src/utils/are-elements-with-issues-equal.ts +7 -5
  178. package/src/utils/are-issue-sets-equal.test.ts +10 -6
  179. package/src/utils/are-issue-sets-equal.ts +8 -6
  180. package/src/utils/containing-blocks.ts +60 -0
  181. package/src/utils/contains.test.ts +55 -0
  182. package/src/utils/contains.ts +19 -0
  183. package/src/utils/deduplicate-nodes.ts +3 -0
  184. package/src/utils/deep-merge.test.ts +8 -1
  185. package/src/utils/deep-merge.ts +11 -8
  186. package/src/utils/dom-helpers.ts +20 -0
  187. package/src/utils/ensure-non-empty.ts +6 -0
  188. package/src/utils/get-element-html.ts +4 -2
  189. package/src/utils/get-element-position.ts +51 -22
  190. package/src/utils/get-parent.ts +1 -1
  191. package/src/utils/get-scan-context.test.ts +85 -0
  192. package/src/utils/get-scan-context.ts +36 -0
  193. package/src/utils/get-scrollable-ancestors.ts +8 -5
  194. package/src/utils/is-node-in-scan-context.test.ts +70 -0
  195. package/src/utils/is-node-in-scan-context.ts +29 -0
  196. package/src/utils/is-non-empty.ts +3 -0
  197. package/src/utils/normalize-context.test.ts +105 -0
  198. package/src/utils/normalize-context.ts +65 -0
  199. package/src/utils/recalculate-positions.ts +5 -5
  200. package/src/utils/recalculate-scrollable-ancestors.ts +4 -4
  201. package/src/utils/shadow-dom-aware-mutation-observer.ts +21 -24
  202. package/src/utils/supports-anchor-positioning.ts +3 -3
  203. package/src/utils/transform-violations.test.ts +22 -20
  204. package/src/utils/transform-violations.ts +14 -10
  205. package/src/utils/update-elements-with-issues.test.ts +102 -49
  206. package/src/utils/update-elements-with-issues.ts +122 -58
  207. package/src/validate-options.ts +154 -14
@@ -1,15 +1,36 @@
1
- import type { Position } from '../types';
1
+ import type { Position } from '../types.ts';
2
+ import { createsContainingBlock } from './containing-blocks.js';
2
3
  import { isHtmlElement } from './dom-helpers.js';
3
- import getParent from './get-parent.js';
4
+ import { getParent } from './get-parent.js';
4
5
 
5
6
  // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#identifying_the_containing_block
6
7
  function isContainingBlock(element: Element, win: Window): boolean {
7
8
  const style = win.getComputedStyle(element);
8
- const { transform, perspective } = style;
9
- // TODO: https://github.com/pomerantsev/accented/issues/119
10
- // Support other types of containing blocks
11
- return transform !== 'none'
12
- || perspective !== 'none';
9
+ const {
10
+ transform,
11
+ perspective,
12
+ contain,
13
+ contentVisibility,
14
+ containerType,
15
+ filter,
16
+ backdropFilter,
17
+ willChange,
18
+ } = style;
19
+ const containItems = contain.split(' ');
20
+ const willChangeItems = willChange.split(/\s*,\s*/);
21
+
22
+ return (
23
+ transform !== 'none' ||
24
+ perspective !== 'none' ||
25
+ containItems.some((item) => ['layout', 'paint', 'strict', 'content'].includes(item)) ||
26
+ contentVisibility === 'auto' ||
27
+ (createsContainingBlock('containerType') && containerType !== 'normal') ||
28
+ (createsContainingBlock('filter') && filter !== 'none') ||
29
+ (createsContainingBlock('backdropFilter') && backdropFilter !== 'none') ||
30
+ willChangeItems.some((item) =>
31
+ ['transform', 'perspective', 'contain', 'filter', 'backdrop-filter'].includes(item),
32
+ )
33
+ );
13
34
  }
14
35
 
15
36
  function getNonInitialContainingBlock(element: Element, win: Window): Element | null {
@@ -23,10 +44,20 @@ function getNonInitialContainingBlock(element: Element, win: Window): Element |
23
44
  return null;
24
45
  }
25
46
 
26
- export default function getElementPosition(element: Element, win: Window): Position {
47
+ /**
48
+ * https://github.com/pomerantsev/accented/issues/116
49
+ *
50
+ * This calculation leads to incorrectly positioned Accented triggers when all of the following are true:
51
+ * * The element is an SVG element.
52
+ * * The element itself, or one of the element's ancestors has a scale or rotate transform.
53
+ * * The browser doesn't support anchor positioning.
54
+ */
55
+ export function getElementPosition(element: Element, win: Window): Position {
27
56
  const nonInitialContainingBlock = getNonInitialContainingBlock(element, win);
28
- // If an element has an ancestor whose transform is not 'none',
57
+ // If an element has a containing block as an ancestor,
58
+ // and that containing block is not the <html> element (the initial containing block),
29
59
  // fixed positioning works differently.
60
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#effects_of_the_containing_block
30
61
  // https://achrafkassioui.com/blog/position-fixed-and-CSS-transforms/
31
62
  if (nonInitialContainingBlock) {
32
63
  if (isHtmlElement(element)) {
@@ -42,19 +73,17 @@ export default function getElementPosition(element: Element, win: Window): Posit
42
73
  currentElement = currentElement.offsetParent as HTMLElement | null;
43
74
  }
44
75
  return { top, left, width, height };
45
- } else {
46
- // TODO: https://github.com/pomerantsev/accented/issues/116
47
- // This is half-baked. It works incorrectly with scaled / rotated elements with issues.
48
- const elementRect = element.getBoundingClientRect();
49
- const nonInitialContainingBlockRect = nonInitialContainingBlock.getBoundingClientRect();
50
- return {
51
- top: elementRect.top - nonInitialContainingBlockRect.top,
52
- height: elementRect.height,
53
- left: elementRect.left - nonInitialContainingBlockRect.left,
54
- width: elementRect.width
55
- };
56
76
  }
57
- } else {
58
- return element.getBoundingClientRect();
77
+
78
+ const elementRect = element.getBoundingClientRect();
79
+ const nonInitialContainingBlockRect = nonInitialContainingBlock.getBoundingClientRect();
80
+ return {
81
+ top: elementRect.top - nonInitialContainingBlockRect.top,
82
+ height: elementRect.height,
83
+ left: elementRect.left - nonInitialContainingBlockRect.left,
84
+ width: elementRect.width,
85
+ };
59
86
  }
87
+
88
+ return element.getBoundingClientRect();
60
89
  }
@@ -1,6 +1,6 @@
1
1
  import { isDocumentFragment, isShadowRoot } from './dom-helpers.js';
2
2
 
3
- export default function getParent (element: Element): Element | null {
3
+ export function getParent(element: Element): Element | null {
4
4
  if (element.parentElement) {
5
5
  return element.parentElement;
6
6
  }
@@ -0,0 +1,85 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+ import { JSDOM } from 'jsdom';
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], {
48
+ include: ['.include'],
49
+ exclude: ['.exclude'],
50
+ });
51
+ const innerExclude = document.querySelector('#inner-exclude')!;
52
+ const innerInclude = document.querySelector('#inner-include')!;
53
+
54
+ assert.deepEqual(scanContext, {
55
+ include: [mutatedNode, innerInclude],
56
+ exclude: [innerExclude],
57
+ });
58
+ });
59
+
60
+ test('when mutated node is within exclude, context elements within it are still included', () => {
61
+ const dom = new JSDOM(`<div>
62
+ <div class="exclude" id="outer-exclude">
63
+ <div id="mutated-node">
64
+ <div class="exclude" id="inner-exclude">
65
+ <div class="include" id="inner-include"></div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </div>`);
70
+ const { document } = dom.window;
71
+ global.document = document;
72
+ const mutatedNode = document.querySelector('#mutated-node')!;
73
+ const scanContext = getScanContext([mutatedNode], {
74
+ include: ['.include'],
75
+ exclude: ['.exclude'],
76
+ });
77
+ const innerExclude = document.querySelector('#inner-exclude')!;
78
+ const innerInclude = document.querySelector('#inner-include')!;
79
+
80
+ assert.deepEqual(scanContext, {
81
+ include: [innerInclude],
82
+ exclude: [innerExclude],
83
+ });
84
+ });
85
+ });
@@ -0,0 +1,36 @@
1
+ import type { Context, ScanContext } from '../types.ts';
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 function getScanContext(nodes: Array<Node>, context: Context): ScanContext {
8
+ const { include: contextInclude, exclude: contextExclude } = normalizeContext(context);
9
+
10
+ // Filter only nodes that are included by context (see isNodeInContext above).
11
+ const nodesInContext = nodes.filter((node) =>
12
+ isNodeInScanContext(node, {
13
+ include: contextInclude,
14
+ exclude: contextExclude,
15
+ }),
16
+ );
17
+
18
+ const include: Array<Node> = [];
19
+ const exclude: Array<Node> = [];
20
+
21
+ // Adds all nodesInContext to the include array.
22
+ include.push(...nodesInContext);
23
+
24
+ // Now add any included and excluded context nodes that are contained by any of the original nodes.
25
+ for (const node of nodes) {
26
+ const includeDescendants = contextInclude.filter((item) => contains(node, item));
27
+ include.push(...includeDescendants);
28
+ const excludeDescendants = contextExclude.filter((item) => contains(node, item));
29
+ exclude.push(...excludeDescendants);
30
+ }
31
+
32
+ return {
33
+ include: deduplicateNodes(include),
34
+ exclude: deduplicateNodes(exclude),
35
+ };
36
+ }
@@ -1,19 +1,22 @@
1
- import getParent from './get-parent.js';
1
+ import { getParent } from './get-parent.js';
2
2
 
3
3
  const scrollableOverflowValues = new Set(['auto', 'scroll', 'hidden']);
4
4
 
5
- export default function getScrollableAncestors (element: Element, win: Window) {
5
+ export function getScrollableAncestors(element: Element, win: Window) {
6
6
  let currentElement: Element | null = element;
7
- let scrollableAncestors = new Set<Element>();
7
+ const scrollableAncestors = new Set<Element>();
8
8
  while (true) {
9
9
  currentElement = getParent(currentElement);
10
10
  if (!currentElement) {
11
11
  break;
12
12
  }
13
13
  const computedStyle = win.getComputedStyle(currentElement);
14
- if (scrollableOverflowValues.has(computedStyle.overflowX) || scrollableOverflowValues.has(computedStyle.overflowY)) {
14
+ if (
15
+ scrollableOverflowValues.has(computedStyle.overflowX) ||
16
+ scrollableOverflowValues.has(computedStyle.overflowY)
17
+ ) {
15
18
  scrollableAncestors.add(currentElement);
16
19
  }
17
20
  }
18
21
  return scrollableAncestors;
19
- };
22
+ }
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+ import { JSDOM } from 'jsdom';
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.ts';
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 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,3 @@
1
+ export function isNonEmpty<T>(arr: T[]): arr is [T, ...T[]] {
2
+ return arr.length > 0;
3
+ }
@@ -0,0 +1,105 @@
1
+ import assert from 'node:assert/strict';
2
+ import { suite, test } from 'node:test';
3
+ import { JSDOM } from 'jsdom';
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,65 @@
1
+ import type { Context, ContextProp, ScanContext, Selector } from '../types.ts';
2
+ import { deduplicateNodes } from './deduplicate-nodes.js';
3
+ import { isNode, isNodeList } from './dom-helpers.js';
4
+ import { isNonEmpty } from './is-non-empty.js';
5
+
6
+ function recursiveSelectAll(
7
+ selectors: [string, ...string[]],
8
+ root: Document | ShadowRoot,
9
+ ): Array<Node> {
10
+ const nodesOnCurrentLevel = root.querySelectorAll(selectors[0]);
11
+ if (selectors.length === 1) {
12
+ return Array.from(nodesOnCurrentLevel);
13
+ }
14
+ const restSelectors: Array<string> = selectors.slice(1);
15
+ if (!isNonEmpty(restSelectors)) {
16
+ throw new Error('Error: the restSelectors array must not be empty.');
17
+ }
18
+ const selected = [];
19
+ for (const node of nodesOnCurrentLevel) {
20
+ if (node.shadowRoot) {
21
+ selected.push(...recursiveSelectAll(restSelectors, node.shadowRoot));
22
+ }
23
+ }
24
+ return selected;
25
+ }
26
+
27
+ function selectorToNodes(selector: Selector): Array<Node> {
28
+ if (typeof selector === 'string') {
29
+ return recursiveSelectAll([selector], document);
30
+ }
31
+ if (isNode(selector)) {
32
+ return [selector];
33
+ }
34
+ return recursiveSelectAll(selector.fromShadowDom, document);
35
+ }
36
+
37
+ function contextPropToNodes(contextProp: ContextProp): Array<Node> {
38
+ let nodes: Array<Node> = [];
39
+ if (typeof contextProp === 'object' && (Array.isArray(contextProp) || isNodeList(contextProp))) {
40
+ nodes = Array.from(contextProp).flatMap((item) => selectorToNodes(item));
41
+ } else {
42
+ nodes = selectorToNodes(contextProp);
43
+ }
44
+ return deduplicateNodes(nodes);
45
+ }
46
+
47
+ export function normalizeContext(context: Context): ScanContext {
48
+ let contextInclude: Array<Node> = [];
49
+ let contextExclude: Array<Node> = [];
50
+ if (typeof context === 'object' && ('include' in context || 'exclude' in context)) {
51
+ if (context.include !== undefined) {
52
+ contextInclude = contextPropToNodes(context.include);
53
+ }
54
+ if (context.exclude !== undefined) {
55
+ contextExclude = contextPropToNodes(context.exclude);
56
+ }
57
+ } else {
58
+ contextInclude = contextPropToNodes(context);
59
+ }
60
+
61
+ return {
62
+ include: contextInclude,
63
+ exclude: contextExclude,
64
+ };
65
+ }
@@ -1,11 +1,11 @@
1
1
  import { batch } from '@preact/signals-core';
2
+ import { logAndRethrow } from '../log-and-rethrow.js';
2
3
  import { extendedElementsWithIssues } from '../state.js';
3
- import getElementPosition from './get-element-position.js';
4
- import logAndRethrow from '../log-and-rethrow.js';
4
+ import { getElementPosition } from './get-element-position.js';
5
5
 
6
6
  let frameRequested = false;
7
7
 
8
- export default function recalculatePositions() {
8
+ export function recalculatePositions() {
9
9
  if (frameRequested) {
10
10
  return;
11
11
  }
@@ -14,11 +14,11 @@ export default function recalculatePositions() {
14
14
  try {
15
15
  frameRequested = false;
16
16
  batch(() => {
17
- extendedElementsWithIssues.value.forEach(({ element, position, visible }) => {
17
+ for (const { element, position, visible } of extendedElementsWithIssues.value) {
18
18
  if (visible.value && element.isConnected) {
19
19
  position.value = getElementPosition(element, window);
20
20
  }
21
- });
21
+ }
22
22
  });
23
23
  } catch (error) {
24
24
  logAndRethrow(error);
@@ -1,13 +1,13 @@
1
1
  import { batch } from '@preact/signals-core';
2
2
  import { extendedElementsWithIssues } from '../state.js';
3
- import getScrollableAncestors from './get-scrollable-ancestors.js';
3
+ import { getScrollableAncestors } from './get-scrollable-ancestors.js';
4
4
 
5
- export default function recalculateScrollableAncestors() {
5
+ export function recalculateScrollableAncestors() {
6
6
  batch(() => {
7
- extendedElementsWithIssues.value.forEach(({ element, scrollableAncestors }) => {
7
+ for (const { element, scrollableAncestors } of extendedElementsWithIssues.value) {
8
8
  if (element.isConnected) {
9
9
  scrollableAncestors.value = getScrollableAncestors(element, window);
10
10
  }
11
- });
11
+ }
12
12
  });
13
13
  }
@@ -1,7 +1,7 @@
1
- import { isElement, isDocument, isDocumentFragment } from './dom-helpers.js';
2
1
  import { getAccentedElementNames } from '../constants.js';
2
+ import { isDocument, isDocumentFragment, isElement } from './dom-helpers.js';
3
3
 
4
- export default function createShadowDOMAwareMutationObserver (name: string, callback: MutationCallback) {
4
+ export function createShadowDOMAwareMutationObserver(name: string, callback: MutationCallback) {
5
5
  class ShadowDOMAwareMutationObserver extends MutationObserver {
6
6
  #shadowRoots = new Set();
7
7
 
@@ -10,22 +10,19 @@ export default function createShadowDOMAwareMutationObserver (name: string, call
10
10
  constructor(callback: MutationCallback) {
11
11
  super((mutations, observer) => {
12
12
  const accentedElementNames = getAccentedElementNames(name);
13
- const childListMutations = mutations
14
- .filter(mutation => mutation.type === 'childList')
13
+ const childListMutations = mutations.filter((mutation) => mutation.type === 'childList');
15
14
 
16
15
  const newElements = childListMutations
17
- .map(mutation => [...mutation.addedNodes])
18
- .flat()
19
- .filter(node => isElement(node))
20
- .filter(node => !accentedElementNames.includes(node.nodeName.toLowerCase()));
16
+ .flatMap((mutation) => [...mutation.addedNodes])
17
+ .filter((node) => isElement(node))
18
+ .filter((node) => !accentedElementNames.includes(node.nodeName.toLowerCase()));
21
19
 
22
20
  this.#observeShadowRoots(newElements);
23
21
 
24
22
  const removedElements = childListMutations
25
- .map(mutation => [...mutation.removedNodes])
26
- .flat()
27
- .filter(node => isElement(node))
28
- .filter(node => !accentedElementNames.includes(node.nodeName.toLowerCase()));
23
+ .flatMap((mutation) => [...mutation.removedNodes])
24
+ .filter((node) => isElement(node))
25
+ .filter((node) => !accentedElementNames.includes(node.nodeName.toLowerCase()));
29
26
 
30
27
  // Mutation observer has no "unobserve" method, so we're simply deleting
31
28
  // the elements from the set of shadow roots.
@@ -50,28 +47,28 @@ export default function createShadowDOMAwareMutationObserver (name: string, call
50
47
 
51
48
  #observeShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
52
49
  const shadowRoots = elements
53
- .map(element => [...element.querySelectorAll('*')])
54
- .flat()
55
- .filter(element => element.shadowRoot)
56
- .map(element => element.shadowRoot!);
50
+ .flatMap((element) => [...element.querySelectorAll('*')])
51
+ .filter((element) => element.shadowRoot)
52
+ .map((element) => element.shadowRoot);
57
53
 
58
54
  for (const shadowRoot of shadowRoots) {
59
- this.#shadowRoots.add(shadowRoot);
60
- this.observe(shadowRoot, this.#options);
55
+ if (shadowRoot) {
56
+ this.#shadowRoots.add(shadowRoot);
57
+ this.observe(shadowRoot, this.#options);
58
+ }
61
59
  }
62
- }
60
+ };
63
61
 
64
62
  #deleteShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
65
63
  const shadowRoots = elements
66
- .map(element => [...element.querySelectorAll('*')])
67
- .flat()
68
- .filter(element => element.shadowRoot)
69
- .map(element => element.shadowRoot!);
64
+ .flatMap((element) => [...element.querySelectorAll('*')])
65
+ .filter((element) => element.shadowRoot)
66
+ .map((element) => element.shadowRoot);
70
67
 
71
68
  for (const shadowRoot of shadowRoots) {
72
69
  this.#shadowRoots.delete(shadowRoot);
73
70
  }
74
- }
71
+ };
75
72
  }
76
73
 
77
74
  return new ShadowDOMAwareMutationObserver(callback);