accented 0.0.0-20250404114312 → 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.
- package/README.md +3 -1
- package/dist/accented.d.ts.map +1 -1
- package/dist/accented.js +5 -3
- package/dist/accented.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/dom-updater.d.ts.map +1 -1
- package/dist/dom-updater.js +29 -3
- package/dist/dom-updater.js.map +1 -1
- package/dist/elements/accented-dialog.d.ts +11 -7
- package/dist/elements/accented-dialog.d.ts.map +1 -1
- package/dist/elements/accented-dialog.js +35 -64
- package/dist/elements/accented-dialog.js.map +1 -1
- package/dist/elements/accented-trigger.d.ts +9 -5
- package/dist/elements/accented-trigger.d.ts.map +1 -1
- package/dist/elements/accented-trigger.js +6 -5
- package/dist/elements/accented-trigger.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +4 -1
- package/dist/logger.js.map +1 -1
- package/dist/scanner.d.ts +2 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +19 -14
- package/dist/scanner.js.map +1 -1
- package/dist/task-queue.d.ts +2 -2
- package/dist/task-queue.d.ts.map +1 -1
- package/dist/task-queue.js +2 -1
- package/dist/task-queue.js.map +1 -1
- package/dist/types.d.ts +25 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/containing-blocks.d.ts +3 -0
- package/dist/utils/containing-blocks.d.ts.map +1 -0
- package/dist/utils/containing-blocks.js +46 -0
- package/dist/utils/containing-blocks.js.map +1 -0
- package/dist/utils/contains.d.ts +2 -0
- package/dist/utils/contains.d.ts.map +1 -0
- package/dist/utils/contains.js +19 -0
- package/dist/utils/contains.js.map +1 -0
- package/dist/utils/deduplicate-nodes.d.ts +2 -0
- package/dist/utils/deduplicate-nodes.d.ts.map +1 -0
- package/dist/utils/deduplicate-nodes.js +5 -0
- package/dist/utils/deduplicate-nodes.js.map +1 -0
- package/dist/utils/dom-helpers.d.ts +3 -0
- package/dist/utils/dom-helpers.d.ts.map +1 -1
- package/dist/utils/dom-helpers.js +13 -0
- package/dist/utils/dom-helpers.js.map +1 -1
- package/dist/utils/ensure-non-empty.d.ts +2 -0
- package/dist/utils/ensure-non-empty.d.ts.map +1 -0
- package/dist/utils/ensure-non-empty.js +7 -0
- package/dist/utils/ensure-non-empty.js.map +1 -0
- package/dist/utils/get-element-position.d.ts +8 -0
- package/dist/utils/get-element-position.d.ts.map +1 -1
- package/dist/utils/get-element-position.js +22 -7
- package/dist/utils/get-element-position.js.map +1 -1
- package/dist/utils/get-scan-context.d.ts +3 -0
- package/dist/utils/get-scan-context.d.ts.map +1 -0
- package/dist/utils/get-scan-context.js +28 -0
- package/dist/utils/get-scan-context.js.map +1 -0
- package/dist/utils/is-node-in-scan-context.d.ts +3 -0
- package/dist/utils/is-node-in-scan-context.d.ts.map +1 -0
- package/dist/utils/is-node-in-scan-context.js +26 -0
- package/dist/utils/is-node-in-scan-context.js.map +1 -0
- package/dist/utils/normalize-context.d.ts +3 -0
- package/dist/utils/normalize-context.d.ts.map +1 -0
- package/dist/utils/normalize-context.js +57 -0
- package/dist/utils/normalize-context.js.map +1 -0
- package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -1
- package/dist/utils/update-elements-with-issues.d.ts +10 -4
- package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
- package/dist/utils/update-elements-with-issues.js +25 -2
- package/dist/utils/update-elements-with-issues.js.map +1 -1
- package/dist/validate-options.d.ts.map +1 -1
- package/dist/validate-options.js +86 -0
- package/dist/validate-options.js.map +1 -1
- package/package.json +7 -3
- package/src/accented.ts +5 -3
- package/src/dom-updater.ts +33 -3
- package/src/elements/accented-dialog.ts +38 -68
- package/src/elements/accented-trigger.ts +6 -5
- package/src/logger.ts +9 -1
- package/src/scanner.ts +21 -15
- package/src/task-queue.ts +6 -4
- package/src/types.ts +38 -5
- package/src/utils/containing-blocks.ts +57 -0
- package/src/utils/contains.test.ts +55 -0
- package/src/utils/contains.ts +19 -0
- package/src/utils/deduplicate-nodes.ts +3 -0
- package/src/utils/dom-helpers.ts +16 -0
- package/src/utils/ensure-non-empty.ts +6 -0
- package/src/utils/get-element-position.ts +23 -7
- package/src/utils/get-scan-context.test.ts +79 -0
- package/src/utils/get-scan-context.ts +39 -0
- package/src/utils/is-node-in-scan-context.test.ts +70 -0
- package/src/utils/is-node-in-scan-context.ts +29 -0
- package/src/utils/normalize-context.test.ts +105 -0
- package/src/utils/normalize-context.ts +58 -0
- package/src/utils/update-elements-with-issues.test.ts +61 -8
- package/src/utils/update-elements-with-issues.ts +42 -3
- package/src/validate-options.ts +88 -1
package/src/scanner.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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,
|
|
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';
|
|
@@ -9,11 +9,12 @@ import supportsAnchorPositioning from './utils/supports-anchor-positioning.js';
|
|
|
9
9
|
import { getAccentedElementNames, issuesUrl } from './constants.js';
|
|
10
10
|
import logAndRethrow from './log-and-rethrow.js';
|
|
11
11
|
import createShadowDOMAwareMutationObserver from './utils/shadow-dom-aware-mutation-observer.js';
|
|
12
|
+
import getScanContext from './utils/get-scan-context.js';
|
|
12
13
|
|
|
13
|
-
export default function createScanner(name: string,
|
|
14
|
+
export default function createScanner(name: string, context: Context, axeOptions: AxeOptions, throttle: Required<Throttle>, callback: Callback) {
|
|
14
15
|
const axeRunningWindowProp = `__${name}_axe_running__`;
|
|
15
16
|
const win: Record<string, any> = window;
|
|
16
|
-
const taskQueue = new TaskQueue<Node>(async () => {
|
|
17
|
+
const taskQueue = new TaskQueue<Node>(async (nodes) => {
|
|
17
18
|
// We may see errors coming from axe-core when Accented is toggled off and on in qiuck succession,
|
|
18
19
|
// which I've seen happen with hot reloading of a React application.
|
|
19
20
|
// This window property serves as a circuit breaker for that particular case.
|
|
@@ -27,12 +28,12 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
|
|
|
27
28
|
|
|
28
29
|
win[axeRunningWindowProp] = true;
|
|
29
30
|
|
|
31
|
+
const scanContext = getScanContext(nodes, context);
|
|
32
|
+
|
|
30
33
|
let result;
|
|
31
34
|
|
|
32
35
|
try {
|
|
33
|
-
|
|
34
|
-
// only run Axe on what's changed, not on the whole axeContext
|
|
35
|
-
result = await axe.run(axeContext, {
|
|
36
|
+
result = await axe.run(scanContext, {
|
|
36
37
|
elementRef: true,
|
|
37
38
|
// Although axe-core can perform iframe scanning, I haven't succeeded in it,
|
|
38
39
|
// and the docs suggest that the axe-core script should be explicitly included
|
|
@@ -64,7 +65,13 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
|
|
|
64
65
|
|
|
65
66
|
performance.mark('dom-update-start');
|
|
66
67
|
|
|
67
|
-
updateElementsWithIssues(
|
|
68
|
+
updateElementsWithIssues({
|
|
69
|
+
extendedElementsWithIssues,
|
|
70
|
+
scanContext,
|
|
71
|
+
violations: result.violations,
|
|
72
|
+
win: window,
|
|
73
|
+
name
|
|
74
|
+
});
|
|
68
75
|
|
|
69
76
|
const domUpdateMeasure = performance.measure('dom-update', 'dom-update-start');
|
|
70
77
|
const domUpdateDuration = Math.round(domUpdateMeasure.duration);
|
|
@@ -74,7 +81,11 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
|
|
|
74
81
|
performance: {
|
|
75
82
|
totalBlockingTime: scanDuration + domUpdateDuration,
|
|
76
83
|
scan: scanDuration,
|
|
77
|
-
domUpdate: domUpdateDuration
|
|
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
|
|
78
89
|
}
|
|
79
90
|
});
|
|
80
91
|
} catch (error) {
|
|
@@ -83,9 +94,6 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
|
|
|
83
94
|
}
|
|
84
95
|
}, throttle);
|
|
85
96
|
|
|
86
|
-
// TODO (https://github.com/pomerantsev/accented/issues/102):
|
|
87
|
-
// limit to what's in axeContext,
|
|
88
|
-
// if that's an element or array of elements (not a selector).
|
|
89
97
|
taskQueue.add(document);
|
|
90
98
|
|
|
91
99
|
const accentedElementNames = getAccentedElementNames(name);
|
|
@@ -128,15 +136,13 @@ export default function createScanner(name: string, axeContext: AxeContext, axeO
|
|
|
128
136
|
return !elementsWithAccentedAttributeChanges.has(mutationRecord.target);
|
|
129
137
|
});
|
|
130
138
|
|
|
131
|
-
|
|
139
|
+
const nodes = filteredMutationList.map(mutationRecord => mutationRecord.target);
|
|
140
|
+
taskQueue.addMultiple(nodes);
|
|
132
141
|
} catch (error) {
|
|
133
142
|
logAndRethrow(error);
|
|
134
143
|
}
|
|
135
144
|
});
|
|
136
145
|
|
|
137
|
-
// TODO (https://github.com/pomerantsev/accented/issues/102):
|
|
138
|
-
// possibly limit the observer to what's in axeContext,
|
|
139
|
-
// if that's an element or array of elements (not a selector).
|
|
140
146
|
mutationObserver.observe(document, {
|
|
141
147
|
subtree: true,
|
|
142
148
|
childList: true,
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -47,11 +71,14 @@ type CallbackParams = {
|
|
|
47
71
|
* It’s further divided into the `scan` and `domUpdate` phases.
|
|
48
72
|
* * `scan`: how long the `scan` phase took, in milliseconds.
|
|
49
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).
|
|
50
76
|
* */
|
|
51
77
|
performance: {
|
|
52
78
|
totalBlockingTime: number,
|
|
53
79
|
scan: number,
|
|
54
|
-
domUpdate: number
|
|
80
|
+
domUpdate: number,
|
|
81
|
+
scanContext: ScanContext | Array<Node>
|
|
55
82
|
}
|
|
56
83
|
}
|
|
57
84
|
|
|
@@ -75,7 +102,7 @@ export type AccentedOptions = {
|
|
|
75
102
|
*
|
|
76
103
|
* Default: `document`.
|
|
77
104
|
*/
|
|
78
|
-
|
|
105
|
+
context?: Context,
|
|
79
106
|
|
|
80
107
|
/**
|
|
81
108
|
* The `options` parameter for `axe.run()`.
|
|
@@ -150,11 +177,11 @@ export type Issue = {
|
|
|
150
177
|
};
|
|
151
178
|
|
|
152
179
|
export type BaseElementWithIssues = {
|
|
153
|
-
element: HTMLElement,
|
|
180
|
+
element: HTMLElement | SVGElement,
|
|
154
181
|
rootNode: Node
|
|
155
182
|
};
|
|
156
183
|
|
|
157
|
-
export type ElementWithIssues = BaseElementWithIssues &{
|
|
184
|
+
export type ElementWithIssues = BaseElementWithIssues & {
|
|
158
185
|
issues: Array<Issue>
|
|
159
186
|
};
|
|
160
187
|
|
|
@@ -163,7 +190,13 @@ export type ExtendedElementWithIssues = BaseElementWithIssues & {
|
|
|
163
190
|
visible: Signal<boolean>,
|
|
164
191
|
trigger: AccentedTrigger,
|
|
165
192
|
position: Signal<Position>,
|
|
193
|
+
skipRender: boolean,
|
|
166
194
|
anchorNameValue: string,
|
|
167
195
|
scrollableAncestors: Signal<Set<Element>>
|
|
168
196
|
id: number
|
|
169
197
|
};
|
|
198
|
+
|
|
199
|
+
export type ScanContext = {
|
|
200
|
+
include: Array<Node>,
|
|
201
|
+
exclude: Array<Node>
|
|
202
|
+
};
|
|
@@ -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
|
+
}
|
package/src/utils/dom-helpers.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
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
|
+
|
|
1
10
|
export function isElement(node: Node): node is Element {
|
|
2
11
|
return typeof Node !== 'undefined' && node.nodeType === Node.ELEMENT_NODE;
|
|
3
12
|
}
|
|
@@ -20,3 +29,10 @@ export function isHtmlElement(element: Element): element is HTMLElement {
|
|
|
20
29
|
// This heuristic seems to be the most robust and fastest that I could think of.
|
|
21
30
|
return element.constructor.name.startsWith('HTML');
|
|
22
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
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import type { Position } from '../types';
|
|
2
2
|
import { isHtmlElement } from './dom-helpers.js';
|
|
3
3
|
import getParent from './get-parent.js';
|
|
4
|
+
import { createsContainingBlock } from './containing-blocks.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
|
-
|
|
10
|
-
|
|
9
|
+
const { transform, perspective, contain, contentVisibility, containerType, filter, backdropFilter, willChange } = style;
|
|
10
|
+
const containItems = contain.split(' ');
|
|
11
|
+
const willChangeItems = willChange.split(/\s*,\s*/);
|
|
12
|
+
|
|
11
13
|
return transform !== 'none'
|
|
12
|
-
|| 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));
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
function getNonInitialContainingBlock(element: Element, win: Window): Element | null {
|
|
@@ -23,10 +31,20 @@ function getNonInitialContainingBlock(element: Element, win: Window): Element |
|
|
|
23
31
|
return null;
|
|
24
32
|
}
|
|
25
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
|
+
*/
|
|
26
42
|
export default function getElementPosition(element: Element, win: Window): Position {
|
|
27
43
|
const nonInitialContainingBlock = getNonInitialContainingBlock(element, win);
|
|
28
|
-
// If an element has
|
|
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),
|
|
29
46
|
// fixed positioning works differently.
|
|
47
|
+
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#effects_of_the_containing_block
|
|
30
48
|
// https://achrafkassioui.com/blog/position-fixed-and-CSS-transforms/
|
|
31
49
|
if (nonInitialContainingBlock) {
|
|
32
50
|
if (isHtmlElement(element)) {
|
|
@@ -43,8 +61,6 @@ export default function getElementPosition(element: Element, win: Window): Posit
|
|
|
43
61
|
}
|
|
44
62
|
return { top, left, width, height };
|
|
45
63
|
} 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
64
|
const elementRect = element.getBoundingClientRect();
|
|
49
65
|
const nonInitialContainingBlockRect = nonInitialContainingBlock.getBoundingClientRect();
|
|
50
66
|
return {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { suite, test } from 'node:test';
|
|
4
|
+
import getScanContext from './get-scan-context';
|
|
5
|
+
|
|
6
|
+
suite('getScanContext', () => {
|
|
7
|
+
test('when context doesn’t overlap with nodes, the result is empty', () => {
|
|
8
|
+
const dom = new JSDOM('<div><div id="context"></div><div id="mutated-node"></div></div>');
|
|
9
|
+
const { document } = dom.window;
|
|
10
|
+
global.document = document;
|
|
11
|
+
const mutatedNode = document.querySelector('#mutated-node')!;
|
|
12
|
+
const scanContext = getScanContext([mutatedNode], '#context');
|
|
13
|
+
|
|
14
|
+
assert.deepEqual(scanContext, {
|
|
15
|
+
include: [],
|
|
16
|
+
exclude: []
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('when context is within the mutated node, the result is the context', () => {
|
|
21
|
+
const dom = new JSDOM('<div><div id="mutated-node"><div id="context"></div></div></div>');
|
|
22
|
+
const { document } = dom.window;
|
|
23
|
+
global.document = document;
|
|
24
|
+
const mutatedNode = document.querySelector('#mutated-node')!;
|
|
25
|
+
const scanContext = getScanContext([mutatedNode], '#context');
|
|
26
|
+
const contextNode = document.querySelector('#context')!;
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(scanContext, {
|
|
29
|
+
include: [contextNode],
|
|
30
|
+
exclude: []
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('when mutated node is within context, the mutated node and the context nodes within it are correctly included / excluded', () => {
|
|
35
|
+
const dom = new JSDOM(`<div>
|
|
36
|
+
<div class="include" id="outer-include">
|
|
37
|
+
<div id="mutated-node">
|
|
38
|
+
<div class="exclude" id="inner-exclude">
|
|
39
|
+
<div class="include" id="inner-include"></div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>`);
|
|
44
|
+
const { document } = dom.window;
|
|
45
|
+
global.document = document;
|
|
46
|
+
const mutatedNode = document.querySelector('#mutated-node')!;
|
|
47
|
+
const scanContext = getScanContext([mutatedNode], {include: ['.include'], exclude: ['.exclude']});
|
|
48
|
+
const innerExclude = document.querySelector('#inner-exclude')!;
|
|
49
|
+
const innerInclude = document.querySelector('#inner-include')!;
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(scanContext, {
|
|
52
|
+
include: [mutatedNode, innerInclude],
|
|
53
|
+
exclude: [innerExclude]
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('when mutated node is within exclude, context elements within it are still included', () => {
|
|
58
|
+
const dom = new JSDOM(`<div>
|
|
59
|
+
<div class="exclude" id="outer-exclude">
|
|
60
|
+
<div id="mutated-node">
|
|
61
|
+
<div class="exclude" id="inner-exclude">
|
|
62
|
+
<div class="include" id="inner-include"></div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>`);
|
|
67
|
+
const { document } = dom.window;
|
|
68
|
+
global.document = document;
|
|
69
|
+
const mutatedNode = document.querySelector('#mutated-node')!;
|
|
70
|
+
const scanContext = getScanContext([mutatedNode], {include: ['.include'], exclude: ['.exclude']});
|
|
71
|
+
const innerExclude = document.querySelector('#inner-exclude')!;
|
|
72
|
+
const innerInclude = document.querySelector('#inner-include')!;
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(scanContext, {
|
|
75
|
+
include: [innerInclude],
|
|
76
|
+
exclude: [innerExclude]
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Context, ScanContext } from '../types';
|
|
2
|
+
import contains from './contains.js';
|
|
3
|
+
import { deduplicateNodes } from './deduplicate-nodes.js';
|
|
4
|
+
import isNodeInScanContext from './is-node-in-scan-context.js';
|
|
5
|
+
import normalizeContext from './normalize-context.js';
|
|
6
|
+
|
|
7
|
+
export default function getScanContext(nodes: Array<Node>, context: Context): ScanContext {
|
|
8
|
+
const {
|
|
9
|
+
include: contextInclude,
|
|
10
|
+
exclude: contextExclude
|
|
11
|
+
} = normalizeContext(context);
|
|
12
|
+
|
|
13
|
+
// Filter only nodes that are included by context (see isNodeInContext above).
|
|
14
|
+
const nodesInContext = nodes.filter(node =>
|
|
15
|
+
isNodeInScanContext(node, {
|
|
16
|
+
include: contextInclude,
|
|
17
|
+
exclude: contextExclude
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const include: Array<Node> = [];
|
|
22
|
+
const exclude: Array<Node> = [];
|
|
23
|
+
|
|
24
|
+
// Adds all nodesInContext to the include array.
|
|
25
|
+
include.push(...nodesInContext);
|
|
26
|
+
|
|
27
|
+
// Now add any included and excluded context nodes that are contained by any of the original nodes.
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
const includeDescendants = contextInclude.filter(item => contains(node, item));
|
|
30
|
+
include.push(...includeDescendants);
|
|
31
|
+
const excludeDescendants = contextExclude.filter(item => contains(node, item));
|
|
32
|
+
exclude.push(...excludeDescendants);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
include: deduplicateNodes(include),
|
|
37
|
+
exclude: deduplicateNodes(exclude)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {suite, test} from 'node:test';
|
|
4
|
+
import isNodeInScanContext from './is-node-in-scan-context';
|
|
5
|
+
|
|
6
|
+
suite('isNodeInScanContext', () => {
|
|
7
|
+
test('doesn’t include an element if scan context is empty', () => {
|
|
8
|
+
const dom = new JSDOM('<div id="test"></div>');
|
|
9
|
+
const { document } = dom.window;
|
|
10
|
+
const node = document.querySelector('#test')!;
|
|
11
|
+
|
|
12
|
+
const scanContext = { include: [], exclude: [] };
|
|
13
|
+
|
|
14
|
+
assert.equal(isNodeInScanContext(node, scanContext), false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('includes an element whose ancestor is included', () => {
|
|
18
|
+
const dom = new JSDOM('<div id="test"></div>');
|
|
19
|
+
const { document } = dom.window;
|
|
20
|
+
const node = document.querySelector('#test')!;
|
|
21
|
+
const body = document.body;
|
|
22
|
+
|
|
23
|
+
const scanContext = { include: [body], exclude: [] };
|
|
24
|
+
|
|
25
|
+
assert.ok(isNodeInScanContext(node, scanContext));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('does not include an element whose closest ancestor is excluded', () => {
|
|
29
|
+
const dom = new JSDOM('<div id="excluded"><div id="test"></div></div>');
|
|
30
|
+
const { document } = dom.window;
|
|
31
|
+
const node = document.querySelector('#test')!;
|
|
32
|
+
const body = document.body;
|
|
33
|
+
const excludedAncestor = document.querySelector('#excluded')!;
|
|
34
|
+
|
|
35
|
+
const scanContext = { include: [body], exclude: [excludedAncestor] };
|
|
36
|
+
|
|
37
|
+
assert.equal(isNodeInScanContext(node, scanContext), false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('includes an element if it itself is included', () => {
|
|
41
|
+
const dom = new JSDOM('<div id="test"></div>');
|
|
42
|
+
const { document } = dom.window;
|
|
43
|
+
const node = document.querySelector('#test')!;
|
|
44
|
+
|
|
45
|
+
const scanContext = { include: [node], exclude: [] };
|
|
46
|
+
|
|
47
|
+
assert.ok(isNodeInScanContext(node, scanContext));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('doesn’t include an element if it itself is excluded', () => {
|
|
51
|
+
const dom = new JSDOM('<div id="test"></div>');
|
|
52
|
+
const { document } = dom.window;
|
|
53
|
+
const node = document.querySelector('#test')!;
|
|
54
|
+
const body = document.body;
|
|
55
|
+
|
|
56
|
+
const scanContext = { include: [body], exclude: [node] };
|
|
57
|
+
|
|
58
|
+
assert.equal(isNodeInScanContext(node, scanContext), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('includes an element if it’s both included and excluded (include takes precedence)', () => {
|
|
62
|
+
const dom = new JSDOM('<div id="test"></div>');
|
|
63
|
+
const { document } = dom.window;
|
|
64
|
+
const node = document.querySelector('#test')!;
|
|
65
|
+
|
|
66
|
+
const scanContext = { include: [node], exclude: [node] };
|
|
67
|
+
|
|
68
|
+
assert.ok(isNodeInScanContext(node, scanContext));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* Adapted from https://github.com/dequelabs/axe-core/blob/fd6239bfc97ebc904044f93f68d7e49137f744ad/lib/core/utils/is-node-in-context.js */
|
|
2
|
+
|
|
3
|
+
import type { ScanContext } from '../types';
|
|
4
|
+
import contains from './contains.js';
|
|
5
|
+
import ensureNonEmpty from './ensure-non-empty.js';
|
|
6
|
+
|
|
7
|
+
function getDeepest(nodes: [Node, ...Node[]]): Node {
|
|
8
|
+
let deepest = nodes[0];
|
|
9
|
+
for (const node of nodes.slice(1)) {
|
|
10
|
+
if (!contains(node, deepest)) {
|
|
11
|
+
deepest = node;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return deepest;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function isNodeInScanContext(node: Node, { include, exclude }: ScanContext): boolean {
|
|
18
|
+
const filteredInclude = include.filter(includeNode => contains(includeNode, node));
|
|
19
|
+
if (filteredInclude.length === 0) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const filteredExclude = exclude.filter(excludeNode => contains(excludeNode, node));
|
|
23
|
+
if (filteredExclude.length === 0) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const deepestInclude = getDeepest(ensureNonEmpty(filteredInclude));
|
|
27
|
+
const deepestExclude = getDeepest(ensureNonEmpty(filteredExclude));
|
|
28
|
+
return contains(deepestExclude, deepestInclude);
|
|
29
|
+
}
|