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,17 @@
1
+ import logAndRethrow from './log-and-rethrow.js';
2
+ import recalculatePositions from './utils/recalculate-positions.js';
3
+
4
+ export default function setupResizeListener() {
5
+ const abortController = new AbortController();
6
+ window.addEventListener('fullscreenchange', () => {
7
+ try {
8
+ recalculatePositions();
9
+ } catch (error) {
10
+ logAndRethrow(error);
11
+ }
12
+ }, { signal: abortController.signal });
13
+
14
+ return () => {
15
+ abortController.abort();
16
+ };
17
+ };
package/src/logger.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { effect } from '@preact/signals-core';
2
2
  import { elementsWithIssues, enabled } from './state.js';
3
3
  import { accentedUrl } from './constants.js';
4
+ import type { ElementWithIssues } from './types';
5
+
6
+ function filterPropsForOutput(elements: Array<ElementWithIssues>) {
7
+ return elements.map(({ element, issues }) => ({ element, issues }));
8
+ }
4
9
 
5
10
  export default function createLogger() {
6
11
 
@@ -14,7 +19,10 @@ export default function createLogger() {
14
19
  const elementCount = elementsWithIssues.value.length;
15
20
  if (elementCount > 0) {
16
21
  const issueCount = elementsWithIssues.value.reduce((acc, { issues }) => acc + issues.length, 0);
17
- console.log(`${issueCount} accessibility issue${issueCount === 1 ? '' : 's'} found in ${elementCount} element${issueCount === 1 ? '' : 's'} (Accented, ${accentedUrl}):\n`, elementsWithIssues.value);
22
+ console.log(
23
+ `${issueCount} accessibility issue${issueCount === 1 ? '' : 's'} found in ${elementCount} element${issueCount === 1 ? '' : 's'} (Accented, ${accentedUrl}):\n`,
24
+ filterPropsForOutput(elementsWithIssues.value)
25
+ );
18
26
  } else {
19
27
  if (firstRun) {
20
28
  firstRun = false;
package/src/scanner.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  import axe from 'axe-core';
2
2
  import TaskQueue from './task-queue.js';
3
3
  import { elementsWithIssues, enabled, extendedElementsWithIssues } from './state.js';
4
- import type { AxeOptions, Throttle, Callback, AxeContext } from './types';
4
+ import type { AxeOptions, Throttle, Callback, Context } from './types';
5
5
  import updateElementsWithIssues from './utils/update-elements-with-issues.js';
6
6
  import recalculatePositions from './utils/recalculate-positions.js';
7
7
  import recalculateScrollableAncestors from './utils/recalculate-scrollable-ancestors.js';
8
8
  import supportsAnchorPositioning from './utils/supports-anchor-positioning.js';
9
- import { issuesUrl } from './constants.js';
9
+ import { getAccentedElementNames, issuesUrl } from './constants.js';
10
10
  import logAndRethrow from './log-and-rethrow.js';
11
+ import createShadowDOMAwareMutationObserver from './utils/shadow-dom-aware-mutation-observer.js';
12
+ import getScanContext from './utils/get-scan-context.js';
11
13
 
12
- export default function createScanner(name: string, axeContext: AxeContext, axeOptions: AxeOptions, throttle: Required<Throttle>, callback: Callback) {
14
+ export default function createScanner(name: string, context: Context, axeOptions: AxeOptions, throttle: Required<Throttle>, callback: Callback) {
13
15
  const axeRunningWindowProp = `__${name}_axe_running__`;
14
16
  const win: Record<string, any> = window;
15
- const taskQueue = new TaskQueue<Node>(async () => {
17
+ const taskQueue = new TaskQueue<Node>(async (nodes) => {
16
18
  // We may see errors coming from axe-core when Accented is toggled off and on in qiuck succession,
17
19
  // which I've seen happen with hot reloading of a React application.
18
20
  // This window property serves as a circuit breaker for that particular case.
@@ -22,16 +24,16 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
22
24
 
23
25
  try {
24
26
 
25
- performance.mark('axe-start');
27
+ performance.mark('scan-start');
26
28
 
27
29
  win[axeRunningWindowProp] = true;
28
30
 
31
+ const scanContext = getScanContext(nodes, context);
32
+
29
33
  let result;
30
34
 
31
35
  try {
32
- // TODO (https://github.com/pomerantsev/accented/issues/102):
33
- // only run Axe on what's changed, not on the whole axeContext
34
- result = await axe.run(axeContext, {
36
+ result = await axe.run(scanContext, {
35
37
  elementRef: true,
36
38
  // Although axe-core can perform iframe scanning, I haven't succeeded in it,
37
39
  // and the docs suggest that the axe-core script should be explicitly included
@@ -54,17 +56,37 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
54
56
  }
55
57
  win[axeRunningWindowProp] = false;
56
58
 
57
- const axeMeasure = performance.measure('axe', 'axe-start');
59
+ const scanMeasure = performance.measure('scan', 'scan-start');
60
+ const scanDuration = Math.round(scanMeasure.duration);
58
61
 
59
62
  if (!enabled.value) {
60
63
  return;
61
64
  }
62
65
 
63
- updateElementsWithIssues(extendedElementsWithIssues, result.violations, window, name);
66
+ performance.mark('dom-update-start');
67
+
68
+ updateElementsWithIssues({
69
+ extendedElementsWithIssues,
70
+ scanContext,
71
+ violations: result.violations,
72
+ win: window,
73
+ name
74
+ });
75
+
76
+ const domUpdateMeasure = performance.measure('dom-update', 'dom-update-start');
77
+ const domUpdateDuration = Math.round(domUpdateMeasure.duration);
64
78
 
65
79
  callback({
66
80
  elementsWithIssues: elementsWithIssues.value,
67
- scanDuration: Math.round(axeMeasure.duration)
81
+ performance: {
82
+ totalBlockingTime: scanDuration + domUpdateDuration,
83
+ scan: scanDuration,
84
+ domUpdate: domUpdateDuration,
85
+ // Assuming that the {include, exclude} shape of the context object will be used less often
86
+ // than other variants, we'll output just the `include` array in case nothing is excluded
87
+ // in the scan.
88
+ scanContext: scanContext.exclude.length > 0 ? scanContext : scanContext.include
89
+ }
68
90
  });
69
91
  } catch (error) {
70
92
  win[axeRunningWindowProp] = false;
@@ -72,13 +94,10 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
72
94
  }
73
95
  }, throttle);
74
96
 
75
- // TODO (https://github.com/pomerantsev/accented/issues/102):
76
- // limit to what's in axeContext,
77
- // if that's an element or array of elements (not a selector).
78
97
  taskQueue.add(document);
79
98
 
80
- const accentedElementNames = [`${name}-trigger`, `${name}-dialog`];
81
- const mutationObserver = new MutationObserver(mutationList => {
99
+ const accentedElementNames = getAccentedElementNames(name);
100
+ const mutationObserver = createShadowDOMAwareMutationObserver(name, mutationList => {
82
101
  try {
83
102
  // We're not interested in mutations that are caused exclusively by the custom elements
84
103
  // introduced by Accented.
@@ -117,15 +136,13 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
117
136
  return !elementsWithAccentedAttributeChanges.has(mutationRecord.target);
118
137
  });
119
138
 
120
- taskQueue.addMultiple(filteredMutationList.map(mutationRecord => mutationRecord.target));
139
+ const nodes = filteredMutationList.map(mutationRecord => mutationRecord.target);
140
+ taskQueue.addMultiple(nodes);
121
141
  } catch (error) {
122
142
  logAndRethrow(error);
123
143
  }
124
144
  });
125
145
 
126
- // TODO (https://github.com/pomerantsev/accented/issues/102):
127
- // possibly limit the observer to what's in axeContext,
128
- // if that's an element or array of elements (not a selector).
129
146
  mutationObserver.observe(document, {
130
147
  subtree: true,
131
148
  childList: true,
package/src/state.ts CHANGED
@@ -8,10 +8,18 @@ export const extendedElementsWithIssues = signal<Array<ExtendedElementWithIssues
8
8
 
9
9
  export const elementsWithIssues = computed<Array<ElementWithIssues>>(() => extendedElementsWithIssues.value.map(extendedElementWithIssues => ({
10
10
  element: extendedElementWithIssues.element,
11
+ rootNode: extendedElementWithIssues.rootNode,
11
12
  issues: extendedElementWithIssues.issues.value
12
13
  })));
13
14
 
14
- export const scrollableAncestors = computed<Set<HTMLElement>>(() =>
15
+ export const rootNodes = computed<Set<Node>>(() =>
16
+ new Set(
17
+ (enabled.value ? [document as Node] : [])
18
+ .concat(...(extendedElementsWithIssues.value.map(extendedElementWithIssues => extendedElementWithIssues.rootNode)))
19
+ )
20
+ );
21
+
22
+ export const scrollableAncestors = computed<Set<Element>>(() =>
15
23
  extendedElementsWithIssues.value.reduce(
16
24
  (scrollableAncestors, extendedElementWithIssues) => {
17
25
  for (const scrollableAncestor of extendedElementWithIssues.scrollableAncestors.value) {
@@ -19,6 +27,6 @@ export const scrollableAncestors = computed<Set<HTMLElement>>(() =>
19
27
  }
20
28
  return scrollableAncestors;
21
29
  },
22
- new Set<HTMLElement>()
30
+ new Set<Element>()
23
31
  )
24
32
  );
package/src/task-queue.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import type { Throttle } from './types';
2
2
 
3
- type TaskCallback = () => void;
3
+ type TaskCallback<T> = (items: Array<T>) => void;
4
4
 
5
5
  export default class TaskQueue<T> {
6
6
  #throttle: Throttle;
7
- #asyncCallback: TaskCallback | null = null;
7
+ #asyncCallback: TaskCallback<T> | null = null;
8
8
 
9
9
  #items = new Set<T>();
10
10
  #inRunLoop = false;
11
11
 
12
- constructor(asyncCallback: TaskCallback, throttle: Required<Throttle>) {
12
+ constructor(asyncCallback: TaskCallback<T>, throttle: Required<Throttle>) {
13
13
  this.#asyncCallback = asyncCallback;
14
14
  this.#throttle = throttle;
15
15
  }
@@ -33,10 +33,12 @@ export default class TaskQueue<T> {
33
33
  return;
34
34
  }
35
35
 
36
+ const items = Array.from(this.#items);
37
+
36
38
  this.#items.clear();
37
39
 
38
40
  if (this.#asyncCallback) {
39
- await this.#asyncCallback();
41
+ await this.#asyncCallback(items);
40
42
  }
41
43
 
42
44
  await new Promise((resolve) => setTimeout(resolve, this.#throttle.wait));
package/src/types.ts CHANGED
@@ -29,7 +29,31 @@ export type Output = {
29
29
  console?: boolean
30
30
  }
31
31
 
32
- export type AxeContext = axe.ElementContext;
32
+ /**
33
+ * Model context type based on axe.ElementContext,
34
+ * excluding frame selectors (since we don't support scanning iframes).
35
+ */
36
+
37
+ export type Selector = Exclude<axe.Selector, axe.LabelledFramesSelector>;
38
+
39
+ // axe.SelectorList also can have FrameSelector elements in the array.
40
+ // We're not allowing that.
41
+ export type SelectorList = Array<Selector> | NodeList;
42
+
43
+ // The rest of the type is structured the same as in axe-core.
44
+ export type ContextProp = Selector | SelectorList;
45
+
46
+ export type ContextObject = {
47
+ include: ContextProp;
48
+ exclude?: ContextProp;
49
+ } | {
50
+ exclude: ContextProp;
51
+ include?: ContextProp;
52
+ };
53
+
54
+ export type Context = ContextProp | ContextObject;
55
+
56
+
33
57
 
34
58
  export const allowedAxeOptions = ['rules', 'runOnly'] as const;
35
59
 
@@ -42,9 +66,20 @@ type CallbackParams = {
42
66
  elementsWithIssues: Array<ElementWithIssues>,
43
67
 
44
68
  /**
45
- * How long the scan took in milliseconds.
69
+ * * `performance`: runtime performance of the last scan. An object:
70
+ * * `totalBlockingTime`: how long the main thread was blocked by Accented during the last scan, in milliseconds.
71
+ * It’s further divided into the `scan` and `domUpdate` phases.
72
+ * * `scan`: how long the `scan` phase took, in milliseconds.
73
+ * * `domUpdate`: how long the `domUpdate` phase took, in milliseconds.
74
+ * * `scanContext`: nodes that got scanned. Either an array of nodes,
75
+ * or an object with `include` and `exclude` properties (if any nodes were excluded).
46
76
  * */
47
- scanDuration: number
77
+ performance: {
78
+ totalBlockingTime: number,
79
+ scan: number,
80
+ domUpdate: number,
81
+ scanContext: ScanContext | Array<Node>
82
+ }
48
83
  }
49
84
 
50
85
  export type Callback = (params: CallbackParams) => void;
@@ -67,7 +102,7 @@ export type AccentedOptions = {
67
102
  *
68
103
  * Default: `document`.
69
104
  */
70
- axeContext?: AxeContext,
105
+ context?: Context,
71
106
 
72
107
  /**
73
108
  * The `options` parameter for `axe.run()`.
@@ -141,16 +176,27 @@ export type Issue = {
141
176
  impact: axe.ImpactValue
142
177
  };
143
178
 
144
- export type ElementWithIssues = {
145
- element: HTMLElement,
179
+ export type BaseElementWithIssues = {
180
+ element: HTMLElement | SVGElement,
181
+ rootNode: Node
182
+ };
183
+
184
+ export type ElementWithIssues = BaseElementWithIssues & {
146
185
  issues: Array<Issue>
147
- }
186
+ };
148
187
 
149
- export type ExtendedElementWithIssues = Omit<ElementWithIssues, 'issues'> & {
188
+ export type ExtendedElementWithIssues = BaseElementWithIssues & {
150
189
  issues: Signal<ElementWithIssues['issues']>,
151
190
  visible: Signal<boolean>,
152
191
  trigger: AccentedTrigger,
153
192
  position: Signal<Position>,
154
- scrollableAncestors: Signal<Set<HTMLElement>>
193
+ skipRender: boolean,
194
+ anchorNameValue: string,
195
+ scrollableAncestors: Signal<Set<Element>>
155
196
  id: number
156
197
  };
198
+
199
+ export type ScanContext = {
200
+ include: Array<Node>,
201
+ exclude: Array<Node>
202
+ };
@@ -0,0 +1,9 @@
1
+ import type { BaseElementWithIssues } from "../types";
2
+
3
+ export default function areElementsWithIssuesEqual(
4
+ elementWithIssues1: BaseElementWithIssues,
5
+ elementWithIssues2: BaseElementWithIssues
6
+ ) {
7
+ return elementWithIssues1.element === elementWithIssues2.element
8
+ && elementWithIssues1.rootNode === elementWithIssues2.rootNode;
9
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tests whether a particular combination of CSS property and value on an element
3
+ * makes that element a containing block.
4
+ * https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block
5
+ *
6
+ * The function is meant to be run with properties that behave inconsistently across browsers.
7
+ *
8
+ * It's only meant to be used during initialization.
9
+ */
10
+ function testContainingBlockCreation<T extends keyof CSSStyleDeclaration>(prop: T, value: CSSStyleDeclaration[T]) {
11
+ const container = document.createElement('div');
12
+ container.style[prop] = value;
13
+ container.style.position = 'fixed';
14
+ container.style.insetInlineStart = '10px';
15
+ container.style.insetBlockStart = '10px';
16
+
17
+ const element = document.createElement('div');
18
+ element.style.position = 'fixed';
19
+ element.style.insetInlineStart = '0';
20
+ element.style.insetBlockStart = '0';
21
+
22
+ container.appendChild(element);
23
+ document.body.appendChild(container);
24
+ const containerRect = container.getBoundingClientRect();
25
+ const elementRect = element.getBoundingClientRect();
26
+
27
+ container.remove();
28
+
29
+ return containerRect.top === elementRect.top && containerRect.left === elementRect.left;
30
+ }
31
+
32
+ // This is the set we'll use to store the properties that _may_ create containing blocks
33
+ // (the behavior of the ones that we'll be checking is inconsistent across browsers
34
+ // at the time of writing this comment).
35
+ const propsAffectingContainingBlocks = new Set<keyof CSSStyleDeclaration>();
36
+
37
+ export function createsContainingBlock(prop: keyof CSSStyleDeclaration) {
38
+ return propsAffectingContainingBlocks.has(prop);
39
+ }
40
+
41
+ export function initializeContainingBlockSupportSet() {
42
+ type StyleEntry<T extends keyof CSSStyleDeclaration> = {
43
+ [K in T]: { prop: K; value: CSSStyleDeclaration[K] }
44
+ }[T];
45
+
46
+ const propsToTest: Array<StyleEntry<'filter' | 'backdropFilter' | 'containerType'>> = [
47
+ { prop: 'filter', value: 'blur(1px)' },
48
+ { prop: 'backdropFilter', value: 'blur(1px)' },
49
+ { prop: 'containerType', value: 'size' }
50
+ ];
51
+
52
+ for (const { prop, value } of propsToTest) {
53
+ if (testContainingBlockCreation(prop, value)) {
54
+ propsAffectingContainingBlocks.add(prop);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,55 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import assert from 'node:assert/strict';
3
+ import { suite, test } from 'node:test';
4
+ import contains from './contains';
5
+
6
+ suite('contains', () => {
7
+ test('an element contains itself', () => {
8
+ const dom = new JSDOM('<div id="test"></div>');
9
+ const { document } = dom.window;
10
+ const element = document.querySelector('#test')!;
11
+
12
+ assert.equal(contains(element, element), true);
13
+ });
14
+
15
+ test('an element does not contain its sibling', () => {
16
+ const dom = new JSDOM('<div><div id="sibling1"></div><div id="sibling2"></div></div>');
17
+ const { document } = dom.window;
18
+ const sibling1 = document.querySelector('#sibling1')!;
19
+ const sibling2 = document.querySelector('#sibling2')!;
20
+
21
+ assert.equal(contains(sibling1, sibling2), false);
22
+ });
23
+
24
+ test('an element contain its descendant', () => {
25
+ const dom = new JSDOM('<div id="ancestor"><div id="descendant"></div></div>');
26
+ const { document } = dom.window;
27
+ const ancestor = document.querySelector('#ancestor')!;
28
+ const descendant = document.querySelector('#descendant')!;
29
+
30
+ assert.equal(contains(ancestor, descendant), true);
31
+ });
32
+
33
+ test('an element contain its descendant', () => {
34
+ const dom = new JSDOM('<div id="ancestor"><div id="descendant"></div></div>');
35
+ const { document } = dom.window;
36
+ const ancestor = document.querySelector('#ancestor')!;
37
+ const descendant = document.querySelector('#descendant')!;
38
+
39
+ assert.equal(contains(descendant, ancestor), false);
40
+ });
41
+
42
+ test('an element contain its descendant if the descendant is in a shadow DOM', () => {
43
+ const dom = new JSDOM('<div id="ancestor"><div id="host"></div></div>');
44
+ global.Node = dom.window.Node;
45
+ const { document } = dom.window;
46
+ const ancestor = document.querySelector('#ancestor')!;
47
+ const host = document.querySelector('#host')!;
48
+ const shadowRoot = host.attachShadow({ mode: 'open' });
49
+ shadowRoot.innerHTML = '<div id="descendant"></div>';
50
+ const descendant = shadowRoot.querySelector('#descendant')!;
51
+ console.log(descendant);
52
+
53
+ assert.equal(contains(ancestor, descendant), true);
54
+ });
55
+ });
@@ -0,0 +1,19 @@
1
+ import { isDocumentFragment, isShadowRoot } from './dom-helpers.js';
2
+
3
+ export default function contains(ancestor: Node, descendant: Node): boolean {
4
+ if (ancestor.contains(descendant)) {
5
+ return true;
6
+ }
7
+ let rootNode = descendant.getRootNode();
8
+ while (rootNode) {
9
+ if (!(isDocumentFragment(rootNode) && isShadowRoot(rootNode))) {
10
+ return false;
11
+ }
12
+ const host = rootNode.host;
13
+ if (ancestor.contains(host)) {
14
+ return true;
15
+ }
16
+ rootNode = host.getRootNode();
17
+ }
18
+ return false;
19
+ }
@@ -0,0 +1,3 @@
1
+ export function deduplicateNodes(nodes: Array<Node>): Array<Node> {
2
+ return [...new Set(nodes)];;
3
+ }
@@ -0,0 +1,38 @@
1
+ export function isNode(obj: object): obj is Node {
2
+ return 'nodeType' in obj && typeof obj.nodeType === 'number' &&
3
+ 'nodeName' in obj && typeof obj.nodeName === 'string';
4
+ }
5
+
6
+ export function isNodeList(obj: object): obj is NodeList {
7
+ return Object.prototype.toString.call(obj) === '[object NodeList]';
8
+ }
9
+
10
+ export function isElement(node: Node): node is Element {
11
+ return typeof Node !== 'undefined' && node.nodeType === Node.ELEMENT_NODE;
12
+ }
13
+
14
+ export function isDocument(node: Node): node is Document {
15
+ return typeof Node !== 'undefined' && node.nodeType === Node.DOCUMENT_NODE;
16
+ }
17
+
18
+ export function isDocumentFragment(node: Node): node is DocumentFragment {
19
+ return typeof Node !== 'undefined' && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
20
+ }
21
+
22
+ export function isShadowRoot(documentFragment: DocumentFragment): documentFragment is ShadowRoot {
23
+ return 'host' in documentFragment;
24
+ }
25
+
26
+ export function isHtmlElement(element: Element): element is HTMLElement {
27
+ // We can't use instanceof because it may not work across contexts
28
+ // (such as when an element is moved from an iframe).
29
+ // This heuristic seems to be the most robust and fastest that I could think of.
30
+ return element.constructor.name.startsWith('HTML');
31
+ }
32
+
33
+ export function isSvgElement(element: Element): element is SVGElement {
34
+ // We can't use instanceof because it may not work across contexts
35
+ // (such as when an element is moved from an iframe).
36
+ // This heuristic seems to be the most robust and fastest that I could think of.
37
+ return element.constructor.name.startsWith('SVG');
38
+ }
@@ -0,0 +1,6 @@
1
+ export default function ensureNonEmpty<T>(arr: T[]): [T, ...T[]] {
2
+ if (arr.length === 0) {
3
+ throw new Error("Array must not be empty");
4
+ }
5
+ return arr as [T, ...T[]];
6
+ }
@@ -1,31 +1,50 @@
1
1
  import type { Position } from '../types';
2
- import isHtmlElement from './is-html-element.js';
2
+ import { isHtmlElement } from './dom-helpers.js';
3
+ import getParent from './get-parent.js';
4
+ import { createsContainingBlock } from './containing-blocks.js';
3
5
 
4
6
  // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#identifying_the_containing_block
5
7
  function isContainingBlock(element: Element, win: Window): boolean {
6
8
  const style = win.getComputedStyle(element);
7
- const { transform, perspective } = style;
8
- // TODO: https://github.com/pomerantsev/accented/issues/119
9
- // Support other types of containing blocks
9
+ const { transform, perspective, contain, contentVisibility, containerType, filter, backdropFilter, willChange } = style;
10
+ const containItems = contain.split(' ');
11
+ const willChangeItems = willChange.split(/\s*,\s*/);
12
+
10
13
  return transform !== 'none'
11
- || perspective !== 'none';
14
+ || perspective !== 'none'
15
+ || containItems.some((item) => ['layout', 'paint', 'strict', 'content'].includes(item))
16
+ || contentVisibility === 'auto'
17
+ || (createsContainingBlock('containerType') && containerType !== 'normal')
18
+ || (createsContainingBlock('filter') && filter !== 'none')
19
+ || (createsContainingBlock('backdropFilter') && backdropFilter !== 'none')
20
+ || willChangeItems.some((item) => ['transform', 'perspective', 'contain', 'filter', 'backdrop-filter'].includes(item));
12
21
  }
13
22
 
14
23
  function getNonInitialContainingBlock(element: Element, win: Window): Element | null {
15
24
  let currentElement: Element | null = element;
16
- while (currentElement?.parentElement) {
17
- currentElement = currentElement.parentElement;
18
- if (isContainingBlock(currentElement, win)) {
25
+ while (currentElement) {
26
+ currentElement = getParent(currentElement);
27
+ if (currentElement && isContainingBlock(currentElement, win)) {
19
28
  return currentElement;
20
29
  }
21
30
  }
22
31
  return null;
23
32
  }
24
33
 
34
+ /**
35
+ * https://github.com/pomerantsev/accented/issues/116
36
+ *
37
+ * This calculation leads to incorrectly positioned Accented triggers when all of the following are true:
38
+ * * The element is an SVG element.
39
+ * * The element itself, or one of the element's ancestors has a scale or rotate transform.
40
+ * * The browser doesn't support anchor positioning.
41
+ */
25
42
  export default function getElementPosition(element: Element, win: Window): Position {
26
43
  const nonInitialContainingBlock = getNonInitialContainingBlock(element, win);
27
- // If an element has an ancestor whose transform is not 'none',
44
+ // If an element has a containing block as an ancestor,
45
+ // and that containing block is not the <html> element (the initial containing block),
28
46
  // fixed positioning works differently.
47
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#effects_of_the_containing_block
29
48
  // https://achrafkassioui.com/blog/position-fixed-and-CSS-transforms/
30
49
  if (nonInitialContainingBlock) {
31
50
  if (isHtmlElement(element)) {
@@ -42,8 +61,6 @@ export default function getElementPosition(element: Element, win: Window): Posit
42
61
  }
43
62
  return { top, left, width, height };
44
63
  } else {
45
- // TODO: https://github.com/pomerantsev/accented/issues/116
46
- // This is half-baked. It works incorrectly with scaled / rotated elements with issues.
47
64
  const elementRect = element.getBoundingClientRect();
48
65
  const nonInitialContainingBlockRect = nonInitialContainingBlock.getBoundingClientRect();
49
66
  return {
@@ -0,0 +1,14 @@
1
+ import { isDocumentFragment, isShadowRoot } from './dom-helpers.js';
2
+
3
+ export default function getParent (element: Element): Element | null {
4
+ if (element.parentElement) {
5
+ return element.parentElement;
6
+ }
7
+
8
+ const rootNode = element.getRootNode();
9
+ if (isDocumentFragment(rootNode) && isShadowRoot(rootNode)) {
10
+ return rootNode.host;
11
+ }
12
+
13
+ return null;
14
+ }