accented 0.0.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +14 -0
- package/README.md +44 -187
- package/dist/accented.d.ts +8 -8
- package/dist/accented.d.ts.map +1 -1
- package/dist/accented.js +37 -30
- package/dist/accented.js.map +1 -1
- package/dist/common/tokens.d.ts +7 -0
- package/dist/common/tokens.d.ts.map +1 -0
- package/dist/common/tokens.js +8 -0
- package/dist/common/tokens.js.map +1 -0
- package/dist/constants.d.ts +2 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/dom-updater.d.ts +1 -1
- package/dist/dom-updater.d.ts.map +1 -1
- package/dist/dom-updater.js +73 -31
- package/dist/dom-updater.js.map +1 -1
- package/dist/elements/accented-dialog.d.ts +13 -10
- package/dist/elements/accented-dialog.d.ts.map +1 -1
- package/dist/elements/accented-dialog.js +110 -94
- package/dist/elements/accented-dialog.js.map +1 -1
- package/dist/elements/accented-trigger.d.ts +14 -9
- package/dist/elements/accented-trigger.d.ts.map +1 -1
- package/dist/elements/accented-trigger.js +77 -22
- package/dist/elements/accented-trigger.js.map +1 -1
- package/dist/fullscreen-listener.d.ts +2 -0
- package/dist/fullscreen-listener.d.ts.map +1 -0
- package/dist/fullscreen-listener.js +17 -0
- package/dist/fullscreen-listener.js.map +1 -0
- package/dist/intersection-observer.d.ts +1 -1
- package/dist/intersection-observer.d.ts.map +1 -1
- package/dist/intersection-observer.js +12 -6
- package/dist/intersection-observer.js.map +1 -1
- package/dist/log-and-rethrow.d.ts +1 -1
- package/dist/log-and-rethrow.d.ts.map +1 -1
- package/dist/log-and-rethrow.js +2 -3
- package/dist/log-and-rethrow.js.map +1 -1
- package/dist/logger.d.ts +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +6 -3
- package/dist/logger.js.map +1 -1
- package/dist/register-elements.d.ts +1 -1
- package/dist/register-elements.d.ts.map +1 -1
- package/dist/register-elements.js +6 -7
- package/dist/register-elements.js.map +1 -1
- package/dist/resize-listener.d.ts +1 -1
- package/dist/resize-listener.d.ts.map +1 -1
- package/dist/resize-listener.js +3 -4
- package/dist/resize-listener.js.map +1 -1
- package/dist/scanner.d.ts +2 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +76 -43
- package/dist/scanner.js.map +1 -1
- package/dist/scroll-listeners.d.ts +1 -1
- package/dist/scroll-listeners.d.ts.map +1 -1
- package/dist/scroll-listeners.js +3 -4
- package/dist/scroll-listeners.js.map +1 -1
- package/dist/state.d.ts +3 -2
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +5 -3
- package/dist/state.js.map +1 -1
- package/dist/task-queue.d.ts +4 -4
- package/dist/task-queue.d.ts.map +1 -1
- package/dist/task-queue.js +3 -2
- package/dist/task-queue.js.map +1 -1
- package/dist/types.d.ts +140 -49
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/are-elements-with-issues-equal.d.ts +3 -0
- package/dist/utils/are-elements-with-issues-equal.d.ts.map +1 -0
- package/dist/utils/are-elements-with-issues-equal.js +5 -0
- package/dist/utils/are-elements-with-issues-equal.js.map +1 -0
- package/dist/utils/are-issue-sets-equal.d.ts +2 -2
- package/dist/utils/are-issue-sets-equal.d.ts.map +1 -1
- package/dist/utils/are-issue-sets-equal.js +3 -3
- package/dist/utils/are-issue-sets-equal.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 +4 -0
- package/dist/utils/deduplicate-nodes.js.map +1 -0
- package/dist/utils/deep-merge.d.ts +1 -1
- package/dist/utils/deep-merge.d.ts.map +1 -1
- package/dist/utils/deep-merge.js +8 -5
- package/dist/utils/deep-merge.js.map +1 -1
- package/dist/utils/dom-helpers.d.ts +9 -0
- package/dist/utils/dom-helpers.d.ts.map +1 -0
- package/dist/utils/dom-helpers.js +34 -0
- package/dist/utils/dom-helpers.js.map +1 -0
- 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-html.d.ts +1 -1
- package/dist/utils/get-element-html.d.ts.map +1 -1
- package/dist/utils/get-element-html.js +4 -2
- package/dist/utils/get-element-html.js.map +1 -1
- package/dist/utils/get-element-position.d.ts +10 -2
- package/dist/utils/get-element-position.d.ts.map +1 -1
- package/dist/utils/get-element-position.js +64 -16
- package/dist/utils/get-element-position.js.map +1 -1
- package/dist/utils/get-parent.d.ts +2 -0
- package/dist/utils/get-parent.d.ts.map +1 -0
- package/dist/utils/get-parent.js +12 -0
- package/dist/utils/get-parent.js.map +1 -0
- 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/get-scrollable-ancestors.d.ts +1 -1
- package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -1
- package/dist/utils/get-scrollable-ancestors.js +10 -6
- package/dist/utils/get-scrollable-ancestors.js.map +1 -1
- 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/is-non-empty.d.ts +2 -0
- package/dist/utils/is-non-empty.d.ts.map +1 -0
- package/dist/utils/is-non-empty.js +4 -0
- package/dist/utils/is-non-empty.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 +59 -0
- package/dist/utils/normalize-context.js.map +1 -0
- package/dist/utils/recalculate-positions.d.ts +1 -1
- package/dist/utils/recalculate-positions.d.ts.map +1 -1
- package/dist/utils/recalculate-positions.js +5 -5
- package/dist/utils/recalculate-positions.js.map +1 -1
- package/dist/utils/recalculate-scrollable-ancestors.d.ts +1 -1
- package/dist/utils/recalculate-scrollable-ancestors.d.ts.map +1 -1
- package/dist/utils/recalculate-scrollable-ancestors.js +4 -4
- package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -1
- package/dist/utils/shadow-dom-aware-mutation-observer.d.ts +10 -0
- package/dist/utils/shadow-dom-aware-mutation-observer.d.ts.map +1 -0
- package/dist/utils/shadow-dom-aware-mutation-observer.js +61 -0
- package/dist/utils/shadow-dom-aware-mutation-observer.js.map +1 -0
- package/dist/utils/supports-anchor-positioning.d.ts +1 -1
- package/dist/utils/supports-anchor-positioning.d.ts.map +1 -1
- package/dist/utils/supports-anchor-positioning.js +1 -1
- package/dist/utils/supports-anchor-positioning.js.map +1 -1
- package/dist/utils/transform-violations.d.ts +2 -2
- package/dist/utils/transform-violations.d.ts.map +1 -1
- package/dist/utils/transform-violations.js +23 -10
- package/dist/utils/transform-violations.js.map +1 -1
- package/dist/utils/update-elements-with-issues.d.ts +11 -5
- package/dist/utils/update-elements-with-issues.d.ts.map +1 -1
- package/dist/utils/update-elements-with-issues.js +56 -24
- package/dist/utils/update-elements-with-issues.js.map +1 -1
- package/dist/validate-options.d.ts +2 -2
- package/dist/validate-options.d.ts.map +1 -1
- package/dist/validate-options.js +91 -4
- package/dist/validate-options.js.map +1 -1
- package/package.json +15 -7
- package/src/accented.test.ts +2 -2
- package/src/accented.ts +45 -34
- package/src/common/tokens.ts +10 -0
- package/src/constants.ts +2 -1
- package/src/dom-updater.ts +87 -34
- package/src/elements/accented-dialog.ts +157 -122
- package/src/elements/accented-trigger.ts +119 -47
- package/src/fullscreen-listener.ts +21 -0
- package/src/intersection-observer.ts +27 -16
- package/src/log-and-rethrow.ts +2 -3
- package/src/logger.ts +14 -4
- package/src/register-elements.ts +7 -7
- package/src/resize-listener.ts +15 -11
- package/src/scanner.ts +113 -57
- package/src/scroll-listeners.ts +27 -19
- package/src/state.ts +27 -16
- package/src/task-queue.test.ts +5 -4
- package/src/task-queue.ts +8 -6
- package/src/types.ts +179 -76
- package/src/utils/are-elements-with-issues-equal.ts +11 -0
- package/src/utils/are-issue-sets-equal.test.ts +10 -6
- package/src/utils/are-issue-sets-equal.ts +8 -6
- package/src/utils/containing-blocks.ts +60 -0
- package/src/utils/contains.test.ts +54 -0
- package/src/utils/contains.ts +19 -0
- package/src/utils/deduplicate-nodes.ts +3 -0
- package/src/utils/deep-merge.test.ts +8 -1
- package/src/utils/deep-merge.ts +14 -8
- package/src/utils/dom-helpers.ts +42 -0
- package/src/utils/ensure-non-empty.ts +6 -0
- package/src/utils/get-element-html.ts +4 -2
- package/src/utils/get-element-position.ts +84 -16
- package/src/utils/get-parent.ts +14 -0
- package/src/utils/get-scan-context.test.ts +85 -0
- package/src/utils/get-scan-context.ts +36 -0
- package/src/utils/get-scrollable-ancestors.ts +15 -7
- 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/is-non-empty.ts +3 -0
- package/src/utils/normalize-context.test.ts +105 -0
- package/src/utils/normalize-context.ts +65 -0
- package/src/utils/recalculate-positions.ts +5 -5
- package/src/utils/recalculate-scrollable-ancestors.ts +4 -4
- package/src/utils/shadow-dom-aware-mutation-observer.ts +75 -0
- package/src/utils/supports-anchor-positioning.ts +3 -3
- package/src/utils/transform-violations.test.ts +28 -24
- package/src/utils/transform-violations.ts +30 -12
- package/src/utils/update-elements-with-issues.test.ts +139 -51
- package/src/utils/update-elements-with-issues.ts +123 -54
- package/src/validate-options.ts +154 -14
|
@@ -1,21 +1,89 @@
|
|
|
1
|
-
import type { Position } from '../types';
|
|
1
|
+
import type { Position } from '../types.ts';
|
|
2
|
+
import { createsContainingBlock } from './containing-blocks.js';
|
|
3
|
+
import { isHtmlElement } from './dom-helpers.js';
|
|
4
|
+
import { getParent } from './get-parent.js';
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#identifying_the_containing_block
|
|
7
|
+
function isContainingBlock(element: Element, win: Window): boolean {
|
|
8
|
+
const style = win.getComputedStyle(element);
|
|
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
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getNonInitialContainingBlock(element: Element, win: Window): Element | null {
|
|
37
|
+
let currentElement: Element | null = element;
|
|
38
|
+
while (currentElement) {
|
|
39
|
+
currentElement = getParent(currentElement);
|
|
40
|
+
if (currentElement && isContainingBlock(currentElement, win)) {
|
|
41
|
+
return currentElement;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
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 {
|
|
56
|
+
const nonInitialContainingBlock = getNonInitialContainingBlock(element, win);
|
|
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),
|
|
59
|
+
// fixed positioning works differently.
|
|
60
|
+
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_display/Containing_block#effects_of_the_containing_block
|
|
61
|
+
// https://achrafkassioui.com/blog/position-fixed-and-CSS-transforms/
|
|
62
|
+
if (nonInitialContainingBlock) {
|
|
63
|
+
if (isHtmlElement(element)) {
|
|
64
|
+
const width = element.offsetWidth;
|
|
65
|
+
const height = element.offsetHeight;
|
|
66
|
+
let left = element.offsetLeft;
|
|
67
|
+
let top = element.offsetTop;
|
|
68
|
+
let currentElement = element.offsetParent as HTMLElement | null;
|
|
69
|
+
// Non-initial containing block may not be an offset parent, we have to account for that as well.
|
|
70
|
+
while (currentElement && currentElement !== nonInitialContainingBlock) {
|
|
71
|
+
left += currentElement.offsetLeft;
|
|
72
|
+
top += currentElement.offsetTop;
|
|
73
|
+
currentElement = currentElement.offsetParent as HTMLElement | null;
|
|
74
|
+
}
|
|
75
|
+
return { top, left, width, height };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const elementRect = element.getBoundingClientRect();
|
|
79
|
+
const nonInitialContainingBlockRect = nonInitialContainingBlock.getBoundingClientRect();
|
|
13
80
|
return {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
81
|
+
top: elementRect.top - nonInitialContainingBlockRect.top,
|
|
82
|
+
height: elementRect.height,
|
|
83
|
+
left: elementRect.left - nonInitialContainingBlockRect.left,
|
|
84
|
+
width: elementRect.width,
|
|
17
85
|
};
|
|
18
|
-
} else {
|
|
19
|
-
throw new Error(`The element ${element} has a direction "${direction}", which is not supported.`);
|
|
20
86
|
}
|
|
87
|
+
|
|
88
|
+
return element.getBoundingClientRect();
|
|
21
89
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isDocumentFragment, isShadowRoot } from './dom-helpers.js';
|
|
2
|
+
|
|
3
|
+
export 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
|
+
}
|
|
@@ -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,14 +1,22 @@
|
|
|
1
|
+
import { getParent } from './get-parent.js';
|
|
2
|
+
|
|
1
3
|
const scrollableOverflowValues = new Set(['auto', 'scroll', 'hidden']);
|
|
2
4
|
|
|
3
|
-
export
|
|
4
|
-
let currentElement = element;
|
|
5
|
-
|
|
6
|
-
while (
|
|
7
|
-
currentElement = currentElement
|
|
5
|
+
export function getScrollableAncestors(element: Element, win: Window) {
|
|
6
|
+
let currentElement: Element | null = element;
|
|
7
|
+
const scrollableAncestors = new Set<Element>();
|
|
8
|
+
while (true) {
|
|
9
|
+
currentElement = getParent(currentElement);
|
|
10
|
+
if (!currentElement) {
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
8
13
|
const computedStyle = win.getComputedStyle(currentElement);
|
|
9
|
-
if (
|
|
14
|
+
if (
|
|
15
|
+
scrollableOverflowValues.has(computedStyle.overflowX) ||
|
|
16
|
+
scrollableOverflowValues.has(computedStyle.overflowY)
|
|
17
|
+
) {
|
|
10
18
|
scrollableAncestors.add(currentElement);
|
|
11
19
|
}
|
|
12
20
|
}
|
|
13
21
|
return scrollableAncestors;
|
|
14
|
-
}
|
|
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,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
|
|
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
|
-
|
|
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
|
|
5
|
+
export function recalculateScrollableAncestors() {
|
|
6
6
|
batch(() => {
|
|
7
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getAccentedElementNames } from '../constants.js';
|
|
2
|
+
import { isDocument, isDocumentFragment, isElement } from './dom-helpers.js';
|
|
3
|
+
|
|
4
|
+
export function createShadowDOMAwareMutationObserver(name: string, callback: MutationCallback) {
|
|
5
|
+
class ShadowDOMAwareMutationObserver extends MutationObserver {
|
|
6
|
+
#shadowRoots = new Set();
|
|
7
|
+
|
|
8
|
+
#options: MutationObserverInit | undefined;
|
|
9
|
+
|
|
10
|
+
constructor(callback: MutationCallback) {
|
|
11
|
+
super((mutations, observer) => {
|
|
12
|
+
const accentedElementNames = getAccentedElementNames(name);
|
|
13
|
+
const childListMutations = mutations.filter((mutation) => mutation.type === 'childList');
|
|
14
|
+
|
|
15
|
+
const newElements = childListMutations
|
|
16
|
+
.flatMap((mutation) => [...mutation.addedNodes])
|
|
17
|
+
.filter((node) => isElement(node))
|
|
18
|
+
.filter((node) => !accentedElementNames.includes(node.nodeName.toLowerCase()));
|
|
19
|
+
|
|
20
|
+
this.#observeShadowRoots(newElements);
|
|
21
|
+
|
|
22
|
+
const removedElements = childListMutations
|
|
23
|
+
.flatMap((mutation) => [...mutation.removedNodes])
|
|
24
|
+
.filter((node) => isElement(node))
|
|
25
|
+
.filter((node) => !accentedElementNames.includes(node.nodeName.toLowerCase()));
|
|
26
|
+
|
|
27
|
+
// Mutation observer has no "unobserve" method, so we're simply deleting
|
|
28
|
+
// the elements from the set of shadow roots.
|
|
29
|
+
this.#deleteShadowRoots(removedElements);
|
|
30
|
+
|
|
31
|
+
callback(mutations, observer);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override observe(target: Node, options?: MutationObserverInit): void {
|
|
36
|
+
this.#options ??= options;
|
|
37
|
+
if (isElement(target) || isDocument(target) || isDocumentFragment(target)) {
|
|
38
|
+
this.#observeShadowRoots([target]);
|
|
39
|
+
}
|
|
40
|
+
super.observe(target, options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override disconnect(): void {
|
|
44
|
+
this.#shadowRoots.clear();
|
|
45
|
+
super.disconnect();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#observeShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
|
|
49
|
+
const shadowRoots = elements
|
|
50
|
+
.flatMap((element) => [...element.querySelectorAll('*')])
|
|
51
|
+
.filter((element) => element.shadowRoot)
|
|
52
|
+
.map((element) => element.shadowRoot);
|
|
53
|
+
|
|
54
|
+
for (const shadowRoot of shadowRoots) {
|
|
55
|
+
if (shadowRoot) {
|
|
56
|
+
this.#shadowRoots.add(shadowRoot);
|
|
57
|
+
this.observe(shadowRoot, this.#options);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
#deleteShadowRoots = (elements: Array<Element | Document | DocumentFragment>) => {
|
|
63
|
+
const shadowRoots = elements
|
|
64
|
+
.flatMap((element) => [...element.querySelectorAll('*')])
|
|
65
|
+
.filter((element) => element.shadowRoot)
|
|
66
|
+
.map((element) => element.shadowRoot);
|
|
67
|
+
|
|
68
|
+
for (const shadowRoot of shadowRoots) {
|
|
69
|
+
this.#shadowRoots.delete(shadowRoot);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return new ShadowDOMAwareMutationObserver(callback);
|
|
75
|
+
}
|