accented 0.0.1-dev.4 → 0.0.2
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 +214 -0
- package/dist/accented.d.ts +28 -7
- package/dist/accented.d.ts.map +1 -1
- package/dist/accented.js +100 -42
- package/dist/accented.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +3 -0
- package/dist/constants.js.map +1 -0
- package/dist/dom-updater.d.ts +1 -6
- package/dist/dom-updater.d.ts.map +1 -1
- package/dist/dom-updater.js +94 -20
- package/dist/dom-updater.js.map +1 -1
- package/dist/elements/accented-dialog.d.ts +356 -0
- package/dist/elements/accented-dialog.d.ts.map +1 -0
- package/dist/elements/accented-dialog.js +361 -0
- package/dist/elements/accented-dialog.js.map +1 -0
- package/dist/elements/accented-trigger.d.ts +359 -0
- package/dist/elements/accented-trigger.d.ts.map +1 -0
- package/dist/elements/accented-trigger.js +159 -0
- package/dist/elements/accented-trigger.js.map +1 -0
- package/dist/intersection-observer.d.ts +5 -0
- package/dist/intersection-observer.d.ts.map +1 -0
- package/dist/intersection-observer.js +28 -0
- package/dist/intersection-observer.js.map +1 -0
- package/dist/log-and-rethrow.d.ts +2 -0
- package/dist/log-and-rethrow.d.ts.map +1 -0
- package/dist/log-and-rethrow.js +7 -0
- package/dist/log-and-rethrow.js.map +1 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +25 -0
- package/dist/logger.js.map +1 -0
- package/dist/register-elements.d.ts +2 -0
- package/dist/register-elements.d.ts.map +1 -0
- package/dist/register-elements.js +21 -0
- package/dist/register-elements.js.map +1 -0
- package/dist/resize-listener.d.ts +2 -0
- package/dist/resize-listener.d.ts.map +1 -0
- package/dist/resize-listener.js +18 -0
- package/dist/resize-listener.js.map +1 -0
- package/dist/scanner.d.ts +3 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +120 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scroll-listeners.d.ts +2 -0
- package/dist/scroll-listeners.d.ts.map +1 -0
- package/dist/scroll-listeners.js +38 -0
- package/dist/scroll-listeners.js.map +1 -0
- package/dist/state.d.ts +6 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +14 -0
- package/dist/state.js.map +1 -0
- package/dist/task-queue.d.ts +3 -4
- package/dist/task-queue.d.ts.map +1 -1
- package/dist/task-queue.js +27 -23
- package/dist/task-queue.js.map +1 -1
- package/dist/types.d.ts +136 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/are-issue-sets-equal.d.ts +3 -0
- package/dist/utils/are-issue-sets-equal.d.ts.map +1 -0
- package/dist/utils/are-issue-sets-equal.js +6 -0
- package/dist/utils/are-issue-sets-equal.js.map +1 -0
- package/dist/utils/deep-merge.d.ts +4 -0
- package/dist/utils/deep-merge.d.ts.map +1 -0
- package/dist/utils/deep-merge.js +18 -0
- package/dist/utils/deep-merge.js.map +1 -0
- package/dist/utils/get-element-html.d.ts +2 -0
- package/dist/utils/get-element-html.d.ts.map +1 -0
- package/dist/utils/get-element-html.js +14 -0
- package/dist/utils/get-element-html.js.map +1 -0
- package/dist/utils/get-element-position.d.ts +3 -0
- package/dist/utils/get-element-position.d.ts.map +1 -0
- package/dist/utils/get-element-position.js +22 -0
- package/dist/utils/get-element-position.js.map +1 -0
- package/dist/utils/get-scrollable-ancestors.d.ts +2 -0
- package/dist/utils/get-scrollable-ancestors.d.ts.map +1 -0
- package/dist/utils/get-scrollable-ancestors.js +15 -0
- package/dist/utils/get-scrollable-ancestors.js.map +1 -0
- package/dist/utils/recalculate-positions.d.ts +2 -0
- package/dist/utils/recalculate-positions.d.ts.map +1 -0
- package/dist/utils/recalculate-positions.js +27 -0
- package/dist/utils/recalculate-positions.js.map +1 -0
- package/dist/utils/recalculate-scrollable-ancestors.d.ts +2 -0
- package/dist/utils/recalculate-scrollable-ancestors.d.ts.map +1 -0
- package/dist/utils/recalculate-scrollable-ancestors.js +13 -0
- package/dist/utils/recalculate-scrollable-ancestors.js.map +1 -0
- package/dist/utils/supports-anchor-positioning.d.ts +6 -0
- package/dist/utils/supports-anchor-positioning.d.ts.map +1 -0
- package/dist/utils/supports-anchor-positioning.js +4 -0
- package/dist/utils/supports-anchor-positioning.js.map +1 -0
- package/dist/utils/transform-violations.d.ts +4 -0
- package/dist/utils/transform-violations.d.ts.map +1 -0
- package/dist/utils/transform-violations.js +48 -0
- package/dist/utils/transform-violations.js.map +1 -0
- package/dist/utils/update-elements-with-issues.d.ts +7 -0
- package/dist/utils/update-elements-with-issues.d.ts.map +1 -0
- package/dist/utils/update-elements-with-issues.js +64 -0
- package/dist/utils/update-elements-with-issues.js.map +1 -0
- package/dist/validate-options.d.ts +3 -0
- package/dist/validate-options.d.ts.map +1 -0
- package/dist/validate-options.js +42 -0
- package/dist/validate-options.js.map +1 -0
- package/package.json +9 -4
- package/src/accented.test.ts +24 -0
- package/src/accented.ts +119 -0
- package/src/constants.ts +2 -0
- package/src/dom-updater.ts +112 -0
- package/src/elements/accented-dialog.ts +384 -0
- package/src/elements/accented-trigger.ts +179 -0
- package/src/intersection-observer.ts +28 -0
- package/src/log-and-rethrow.ts +9 -0
- package/src/logger.ts +26 -0
- package/src/register-elements.ts +21 -0
- package/src/resize-listener.ts +17 -0
- package/src/scanner.ts +139 -0
- package/src/scroll-listeners.ts +37 -0
- package/src/state.ts +24 -0
- package/src/task-queue.test.ts +135 -0
- package/src/task-queue.ts +59 -0
- package/src/types.ts +155 -0
- package/src/utils/are-issue-sets-equal.test.ts +49 -0
- package/src/utils/are-issue-sets-equal.ts +10 -0
- package/src/utils/deep-merge.test.ts +34 -0
- package/src/utils/deep-merge.ts +18 -0
- package/src/utils/get-element-html.ts +13 -0
- package/src/utils/get-element-position.ts +21 -0
- package/src/utils/get-scrollable-ancestors.ts +14 -0
- package/src/utils/recalculate-positions.ts +27 -0
- package/src/utils/recalculate-scrollable-ancestors.ts +13 -0
- package/src/utils/supports-anchor-positioning.ts +7 -0
- package/src/utils/transform-violations.test.ts +124 -0
- package/src/utils/transform-violations.ts +56 -0
- package/src/utils/update-elements-with-issues.test.ts +283 -0
- package/src/utils/update-elements-with-issues.ts +75 -0
- package/src/validate-options.ts +44 -0
- package/dist/utils/issuesToElements.d.ts +0 -3
- package/dist/utils/issuesToElements.d.ts.map +0 -1
- package/dist/utils/issuesToElements.js +0 -16
- package/dist/utils/issuesToElements.js.map +0 -1
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { issuesUrl } from './constants.js';
|
|
2
|
+
|
|
3
|
+
export default function logAndRethrow(error: unknown) {
|
|
4
|
+
console.error(
|
|
5
|
+
`Accented threw an error (see below). Try updating your browser to the latest version. ` +
|
|
6
|
+
`If you’re still seeing the error, file an issue at ${issuesUrl}.`
|
|
7
|
+
);
|
|
8
|
+
throw error;
|
|
9
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { effect } from '@preact/signals-core';
|
|
2
|
+
import { elementsWithIssues, enabled } from './state.js';
|
|
3
|
+
import { accentedUrl } from './constants.js';
|
|
4
|
+
|
|
5
|
+
export default function createLogger() {
|
|
6
|
+
|
|
7
|
+
let firstRun = true;
|
|
8
|
+
|
|
9
|
+
return effect(() => {
|
|
10
|
+
if (!enabled.value) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const elementCount = elementsWithIssues.value.length;
|
|
15
|
+
if (elementCount > 0) {
|
|
16
|
+
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);
|
|
18
|
+
} else {
|
|
19
|
+
if (firstRun) {
|
|
20
|
+
firstRun = false;
|
|
21
|
+
} else {
|
|
22
|
+
console.log(`No accessibility issues found (Accented, ${accentedUrl}).`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import getAccentedTrigger from './elements/accented-trigger.js';
|
|
2
|
+
import getAccentedDialog from './elements/accented-dialog.js';
|
|
3
|
+
|
|
4
|
+
export default function registerElements(name: string): void {
|
|
5
|
+
const elements = [
|
|
6
|
+
{
|
|
7
|
+
elementName: `${name}-trigger`,
|
|
8
|
+
Component: getAccentedTrigger(name)
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
elementName: `${name}-dialog`,
|
|
12
|
+
Component: getAccentedDialog()
|
|
13
|
+
}
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
for (const { elementName, Component } of elements) {
|
|
17
|
+
if (!customElements.get(elementName)) {
|
|
18
|
+
customElements.define(elementName, Component);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
@@ -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('resize', () => {
|
|
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/scanner.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import axe from 'axe-core';
|
|
2
|
+
import TaskQueue from './task-queue.js';
|
|
3
|
+
import { elementsWithIssues, enabled, extendedElementsWithIssues } from './state.js';
|
|
4
|
+
import type { AxeOptions, Throttle, Callback, AxeContext } from './types';
|
|
5
|
+
import updateElementsWithIssues from './utils/update-elements-with-issues.js';
|
|
6
|
+
import recalculatePositions from './utils/recalculate-positions.js';
|
|
7
|
+
import recalculateScrollableAncestors from './utils/recalculate-scrollable-ancestors.js';
|
|
8
|
+
import supportsAnchorPositioning from './utils/supports-anchor-positioning.js';
|
|
9
|
+
import { issuesUrl } from './constants.js';
|
|
10
|
+
import logAndRethrow from './log-and-rethrow.js';
|
|
11
|
+
|
|
12
|
+
export default function createScanner(name: string, axeContext: AxeContext, axeOptions: AxeOptions, throttle: Required<Throttle>, callback: Callback) {
|
|
13
|
+
const axeRunningWindowProp = `__${name}_axe_running__`;
|
|
14
|
+
const win: Record<string, any> = window;
|
|
15
|
+
const taskQueue = new TaskQueue<Node>(async () => {
|
|
16
|
+
// We may see errors coming from axe-core when Accented is toggled off and on in qiuck succession,
|
|
17
|
+
// which I've seen happen with hot reloading of a React application.
|
|
18
|
+
// This window property serves as a circuit breaker for that particular case.
|
|
19
|
+
if (win[axeRunningWindowProp]) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
|
|
25
|
+
performance.mark('axe-start');
|
|
26
|
+
|
|
27
|
+
win[axeRunningWindowProp] = true;
|
|
28
|
+
|
|
29
|
+
let result;
|
|
30
|
+
|
|
31
|
+
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, {
|
|
35
|
+
elementRef: true,
|
|
36
|
+
// Although axe-core can perform iframe scanning, I haven't succeeded in it,
|
|
37
|
+
// and the docs suggest that the axe-core script should be explicitly included
|
|
38
|
+
// in each of the iframed documents anyway.
|
|
39
|
+
// It seems preferable to disallow iframe scanning and not report issues in elements within iframes
|
|
40
|
+
// in the case that such issues are for some reason reported by axe-core.
|
|
41
|
+
// A consumer of Accented can instead scan the iframed document by calling Accented initialization from that document.
|
|
42
|
+
iframes: false,
|
|
43
|
+
resultTypes: ['violations'],
|
|
44
|
+
...axeOptions
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(
|
|
48
|
+
'Accented: axe-core (the accessibility testing engine) threw an error. ' +
|
|
49
|
+
'Check the `axeOptions` property that you’re passing to Accented. ' +
|
|
50
|
+
`If you still think it’s a bug in Accented, file an issue at ${issuesUrl}.\n`,
|
|
51
|
+
error
|
|
52
|
+
);
|
|
53
|
+
result = { violations: [] };
|
|
54
|
+
}
|
|
55
|
+
win[axeRunningWindowProp] = false;
|
|
56
|
+
|
|
57
|
+
const axeMeasure = performance.measure('axe', 'axe-start');
|
|
58
|
+
|
|
59
|
+
if (!enabled.value) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
updateElementsWithIssues(extendedElementsWithIssues, result.violations, window, name);
|
|
64
|
+
|
|
65
|
+
callback({
|
|
66
|
+
elementsWithIssues: elementsWithIssues.value,
|
|
67
|
+
scanDuration: Math.round(axeMeasure.duration)
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
win[axeRunningWindowProp] = false;
|
|
71
|
+
logAndRethrow(error);
|
|
72
|
+
}
|
|
73
|
+
}, throttle);
|
|
74
|
+
|
|
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
|
+
taskQueue.add(document);
|
|
79
|
+
|
|
80
|
+
const accentedElementNames = [`${name}-trigger`, `${name}-dialog`];
|
|
81
|
+
const mutationObserver = new MutationObserver(mutationList => {
|
|
82
|
+
try {
|
|
83
|
+
// We're not interested in mutations that are caused exclusively by the custom elements
|
|
84
|
+
// introduced by Accented.
|
|
85
|
+
const listWithoutAccentedElements = mutationList.filter(mutationRecord => {
|
|
86
|
+
const onlyAccentedElementsAddedOrRemoved = mutationRecord.type === 'childList' &&
|
|
87
|
+
[...mutationRecord.addedNodes].every(node => accentedElementNames.includes(node.nodeName.toLowerCase())) &&
|
|
88
|
+
[...mutationRecord.removedNodes].every(node => accentedElementNames.includes(node.nodeName.toLowerCase()));
|
|
89
|
+
const accentedElementChanged = mutationRecord.type === 'attributes' &&
|
|
90
|
+
accentedElementNames.includes(mutationRecord.target.nodeName.toLowerCase());
|
|
91
|
+
return !(onlyAccentedElementsAddedOrRemoved || accentedElementChanged);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (listWithoutAccentedElements.length !== 0 && !supportsAnchorPositioning(window)) {
|
|
95
|
+
// Something has changed in the DOM, so we need to realign all triggers with respective elements.
|
|
96
|
+
recalculatePositions();
|
|
97
|
+
|
|
98
|
+
// Elements' scrollable ancestors only change when styles change
|
|
99
|
+
// (specifically when the `display` prop on one of the ancestors changes),
|
|
100
|
+
// so a good place to recalculate the scrollable ancestors for elements is here.
|
|
101
|
+
// In future, we could further optimize this by only recalculating scrollable ancestors for elements that have changed.
|
|
102
|
+
recalculateScrollableAncestors();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Exclude all mutations on elements that got the accented attribute added or removed.
|
|
106
|
+
// If we simply exclude all mutations where attributeName = `data-${name}`,
|
|
107
|
+
// we may miss other mutations on those same elements caused by Accented,
|
|
108
|
+
// leading to extra runs of the mutation observer.
|
|
109
|
+
const elementsWithAccentedAttributeChanges = listWithoutAccentedElements.reduce((nodes, mutationRecord) => {
|
|
110
|
+
if (mutationRecord.type === 'attributes' && mutationRecord.attributeName === `data-${name}`) {
|
|
111
|
+
nodes.add(mutationRecord.target);
|
|
112
|
+
}
|
|
113
|
+
return nodes;
|
|
114
|
+
}, new Set<Node>());
|
|
115
|
+
|
|
116
|
+
const filteredMutationList = listWithoutAccentedElements.filter(mutationRecord => {
|
|
117
|
+
return !elementsWithAccentedAttributeChanges.has(mutationRecord.target);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
taskQueue.addMultiple(filteredMutationList.map(mutationRecord => mutationRecord.target));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logAndRethrow(error);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
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
|
+
mutationObserver.observe(document, {
|
|
130
|
+
subtree: true,
|
|
131
|
+
childList: true,
|
|
132
|
+
attributes: true,
|
|
133
|
+
characterData: true
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
mutationObserver.disconnect();
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { effect } from '@preact/signals-core';
|
|
2
|
+
import recalculatePositions from './utils/recalculate-positions.js';
|
|
3
|
+
import { scrollableAncestors } from './state.js';
|
|
4
|
+
import logAndRethrow from './log-and-rethrow.js';
|
|
5
|
+
|
|
6
|
+
export default function setupScrollListeners() {
|
|
7
|
+
const documentAbortController = new AbortController();
|
|
8
|
+
document.addEventListener('scroll', () => {
|
|
9
|
+
try {
|
|
10
|
+
recalculatePositions();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
logAndRethrow(error);
|
|
13
|
+
}
|
|
14
|
+
}, { signal: documentAbortController.signal });
|
|
15
|
+
|
|
16
|
+
const disposeOfEffect = effect(() => {
|
|
17
|
+
// TODO: optimize performance, issue #81
|
|
18
|
+
const elementAbortController = new AbortController();
|
|
19
|
+
for (const scrollableAncestor of scrollableAncestors.value) {
|
|
20
|
+
scrollableAncestor.addEventListener('scroll', () => {
|
|
21
|
+
try {
|
|
22
|
+
recalculatePositions();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
logAndRethrow(error);
|
|
25
|
+
}
|
|
26
|
+
}, { signal: elementAbortController.signal });
|
|
27
|
+
}
|
|
28
|
+
return () => {
|
|
29
|
+
elementAbortController.abort();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
documentAbortController.abort();
|
|
35
|
+
disposeOfEffect();
|
|
36
|
+
};
|
|
37
|
+
};
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { signal, computed } from '@preact/signals-core';
|
|
2
|
+
|
|
3
|
+
import type { ElementWithIssues, ExtendedElementWithIssues } from './types';
|
|
4
|
+
|
|
5
|
+
export const enabled = signal(false);
|
|
6
|
+
|
|
7
|
+
export const extendedElementsWithIssues = signal<Array<ExtendedElementWithIssues>>([]);
|
|
8
|
+
|
|
9
|
+
export const elementsWithIssues = computed<Array<ElementWithIssues>>(() => extendedElementsWithIssues.value.map(extendedElementWithIssues => ({
|
|
10
|
+
element: extendedElementWithIssues.element,
|
|
11
|
+
issues: extendedElementWithIssues.issues.value
|
|
12
|
+
})));
|
|
13
|
+
|
|
14
|
+
export const scrollableAncestors = computed<Set<HTMLElement>>(() =>
|
|
15
|
+
extendedElementsWithIssues.value.reduce(
|
|
16
|
+
(scrollableAncestors, extendedElementWithIssues) => {
|
|
17
|
+
for (const scrollableAncestor of extendedElementWithIssues.scrollableAncestors.value) {
|
|
18
|
+
scrollableAncestors.add(scrollableAncestor);
|
|
19
|
+
}
|
|
20
|
+
return scrollableAncestors;
|
|
21
|
+
},
|
|
22
|
+
new Set<HTMLElement>()
|
|
23
|
+
)
|
|
24
|
+
);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {mock, suite, test} from 'node:test';
|
|
3
|
+
|
|
4
|
+
import TaskQueue from './task-queue.js';
|
|
5
|
+
|
|
6
|
+
const wait = (duration: number) => new Promise(resolve => setTimeout(resolve, duration));
|
|
7
|
+
|
|
8
|
+
const createAsyncCallback = (duration: number) => mock.fn(() => new Promise(resolve => setTimeout(resolve, duration)));
|
|
9
|
+
|
|
10
|
+
suite('TaskQueue', () => {
|
|
11
|
+
test('callback is not called after a TaskQueue is created, even after a timeout', async () => {
|
|
12
|
+
const asyncCallback = createAsyncCallback(0);
|
|
13
|
+
new TaskQueue(asyncCallback, { wait: 50, leading: true });
|
|
14
|
+
await wait(100);
|
|
15
|
+
assert.equal(asyncCallback.mock.callCount(), 0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('callback is not called if addMultiple is called with an empty array', async () => {
|
|
19
|
+
const asyncCallback = createAsyncCallback(0);
|
|
20
|
+
const taskQueue = new TaskQueue(asyncCallback, { wait: 50, leading: true });
|
|
21
|
+
taskQueue.addMultiple([]);
|
|
22
|
+
await wait(100);
|
|
23
|
+
assert.equal(asyncCallback.mock.callCount(), 0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('callback is called once if multiple items are added before the first delay has elapsed', async () => {
|
|
27
|
+
const asyncCallback = createAsyncCallback(0);
|
|
28
|
+
const taskQueue = new TaskQueue<string>(asyncCallback, { wait: 100, leading: false });
|
|
29
|
+
taskQueue.add('one');
|
|
30
|
+
// Adding the second item synchronously
|
|
31
|
+
taskQueue.add('two');
|
|
32
|
+
await wait(50);
|
|
33
|
+
// Adding the third item asynchronously
|
|
34
|
+
taskQueue.add('three');
|
|
35
|
+
assert.equal(asyncCallback.mock.callCount(), 0);
|
|
36
|
+
await wait(100);
|
|
37
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('callback is called according to expected schedule', async () => {
|
|
41
|
+
const asyncCallback = createAsyncCallback(100);
|
|
42
|
+
const taskQueue = new TaskQueue<string>(asyncCallback, { wait: 100, leading: false });
|
|
43
|
+
|
|
44
|
+
// 0 ms: Add "one"
|
|
45
|
+
taskQueue.add('one');
|
|
46
|
+
|
|
47
|
+
await wait(50);
|
|
48
|
+
|
|
49
|
+
// 50 ms: First measurement, callback not called
|
|
50
|
+
assert.equal(asyncCallback.mock.callCount(), 0);
|
|
51
|
+
|
|
52
|
+
// 100 ms: Callback called for the first time
|
|
53
|
+
// 200 ms: First callback completes
|
|
54
|
+
|
|
55
|
+
await wait(200);
|
|
56
|
+
|
|
57
|
+
// 250 ms: Second measurement, callback called once
|
|
58
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
59
|
+
|
|
60
|
+
// 250 ms: Add "two"
|
|
61
|
+
taskQueue.add('two');
|
|
62
|
+
|
|
63
|
+
// 350 ms: Callback called for the second time
|
|
64
|
+
|
|
65
|
+
await wait(150);
|
|
66
|
+
|
|
67
|
+
// 400 ms: Third measurement, callback called twice
|
|
68
|
+
assert.equal(asyncCallback.mock.callCount(), 2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('callback is called according to expected schedule when second task is added before the first callback completes', async () => {
|
|
72
|
+
const asyncCallback = createAsyncCallback(100);
|
|
73
|
+
const taskQueue = new TaskQueue<string>(asyncCallback, { wait: 100, leading: false });
|
|
74
|
+
|
|
75
|
+
// 0 ms: Add "one"
|
|
76
|
+
taskQueue.add('one');
|
|
77
|
+
|
|
78
|
+
await wait(50);
|
|
79
|
+
|
|
80
|
+
// 50 ms: First measurement, callback not called
|
|
81
|
+
assert.equal(asyncCallback.mock.callCount(), 0);
|
|
82
|
+
|
|
83
|
+
// 100 ms: Callback called for the first time
|
|
84
|
+
|
|
85
|
+
await wait(100);
|
|
86
|
+
|
|
87
|
+
// 150 ms: Second measurement, callback called once
|
|
88
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
89
|
+
|
|
90
|
+
// 150 ms: Add "two"
|
|
91
|
+
taskQueue.add('two');
|
|
92
|
+
|
|
93
|
+
// 200 ms: First callback completes
|
|
94
|
+
|
|
95
|
+
await wait(100);
|
|
96
|
+
|
|
97
|
+
// 250 ms: Third measurement, callback still called only once
|
|
98
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
99
|
+
|
|
100
|
+
// 300 ms: Callback called for the second time
|
|
101
|
+
|
|
102
|
+
await wait(100);
|
|
103
|
+
|
|
104
|
+
// 350 ms: Fourth measurement, callback called twice
|
|
105
|
+
assert.equal(asyncCallback.mock.callCount(), 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('callback is called according to expected schedule with leading: true', async () => {
|
|
109
|
+
const asyncCallback = createAsyncCallback(100);
|
|
110
|
+
const taskQueue = new TaskQueue<string>(asyncCallback, { wait: 100, leading: true });
|
|
111
|
+
|
|
112
|
+
// 0 ms: Add "one"
|
|
113
|
+
taskQueue.add('one');
|
|
114
|
+
|
|
115
|
+
await wait(50);
|
|
116
|
+
|
|
117
|
+
// 50 ms: Add "two"
|
|
118
|
+
taskQueue.add('two');
|
|
119
|
+
|
|
120
|
+
// 50 ms: First measurement, callback called
|
|
121
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
122
|
+
|
|
123
|
+
// 100 ms: First callback completes
|
|
124
|
+
|
|
125
|
+
await wait(100);
|
|
126
|
+
|
|
127
|
+
// 150 ms: Second measurement, callback still called once
|
|
128
|
+
assert.equal(asyncCallback.mock.callCount(), 1);
|
|
129
|
+
|
|
130
|
+
await wait(100);
|
|
131
|
+
|
|
132
|
+
// 250 ms: Third measurement, callback called twice
|
|
133
|
+
assert.equal(asyncCallback.mock.callCount(), 2);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Throttle } from './types';
|
|
2
|
+
|
|
3
|
+
type TaskCallback = () => void;
|
|
4
|
+
|
|
5
|
+
export default class TaskQueue<T> {
|
|
6
|
+
#throttle: Throttle;
|
|
7
|
+
#asyncCallback: TaskCallback | null = null;
|
|
8
|
+
|
|
9
|
+
#items = new Set<T>();
|
|
10
|
+
#inRunLoop = false;
|
|
11
|
+
|
|
12
|
+
constructor(asyncCallback: TaskCallback, throttle: Required<Throttle>) {
|
|
13
|
+
this.#asyncCallback = asyncCallback;
|
|
14
|
+
this.#throttle = throttle;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async #preRun() {
|
|
18
|
+
if (this.#inRunLoop) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
this.#inRunLoop = true;
|
|
22
|
+
|
|
23
|
+
if (!this.#throttle.leading) {
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, this.#throttle.wait));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await this.#run();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async #run() {
|
|
31
|
+
if (this.#items.size === 0) {
|
|
32
|
+
this.#inRunLoop = false;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.#items.clear();
|
|
37
|
+
|
|
38
|
+
if (this.#asyncCallback) {
|
|
39
|
+
await this.#asyncCallback();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, this.#throttle.wait));
|
|
43
|
+
|
|
44
|
+
await this.#run();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
addMultiple(items: Array<T>) {
|
|
48
|
+
for (const item of items) {
|
|
49
|
+
this.#items.add(item);
|
|
50
|
+
}
|
|
51
|
+
if (this.#items.size > 0) {
|
|
52
|
+
this.#preRun();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
add(item: T) {
|
|
57
|
+
this.addMultiple([item]);
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type axe from 'axe-core';
|
|
2
|
+
import type { Signal } from '@preact/signals-core';
|
|
3
|
+
import type { AccentedTrigger } from './elements/accented-trigger';
|
|
4
|
+
|
|
5
|
+
export type Throttle = {
|
|
6
|
+
/**
|
|
7
|
+
* The minimal time between scans.
|
|
8
|
+
*
|
|
9
|
+
* Default: `1000`.
|
|
10
|
+
* */
|
|
11
|
+
wait?: number,
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* When to run the scan on Accented initialization or on a mutation.
|
|
15
|
+
*
|
|
16
|
+
* If `true`, the scan will run immediately. If `false`, the scan will run after the first throttle delay.
|
|
17
|
+
*
|
|
18
|
+
* Default: `true`.
|
|
19
|
+
* */
|
|
20
|
+
leading?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Output = {
|
|
24
|
+
/**
|
|
25
|
+
* Whether to output the issues to the console.
|
|
26
|
+
*
|
|
27
|
+
* Default: `true`.
|
|
28
|
+
* */
|
|
29
|
+
console?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AxeContext = axe.ElementContext;
|
|
33
|
+
|
|
34
|
+
export const allowedAxeOptions = ['rules', 'runOnly'] as const;
|
|
35
|
+
|
|
36
|
+
export type AxeOptions = Pick<axe.RunOptions, typeof allowedAxeOptions[number]>;
|
|
37
|
+
|
|
38
|
+
type CallbackParams = {
|
|
39
|
+
/**
|
|
40
|
+
* The most current array of elements with issues.
|
|
41
|
+
* */
|
|
42
|
+
elementsWithIssues: Array<ElementWithIssues>,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How long the scan took in milliseconds.
|
|
46
|
+
* */
|
|
47
|
+
scanDuration: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Callback = (params: CallbackParams) => void;
|
|
51
|
+
|
|
52
|
+
export type AccentedOptions = {
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The `context` parameter for `axe.run()`.
|
|
56
|
+
*
|
|
57
|
+
* Determines what element(s) to scan for accessibility issues.
|
|
58
|
+
*
|
|
59
|
+
* Accepts a variety of shapes:
|
|
60
|
+
* * an element reference;
|
|
61
|
+
* * a selector;
|
|
62
|
+
* * a `NodeList`;
|
|
63
|
+
* * an include / exclude object;
|
|
64
|
+
* * and more.
|
|
65
|
+
*
|
|
66
|
+
* See documentation: https://www.deque.com/axe/core-documentation/api-documentation/#context-parameter
|
|
67
|
+
*
|
|
68
|
+
* Default: `document`.
|
|
69
|
+
*/
|
|
70
|
+
axeContext?: AxeContext,
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The `options` parameter for `axe.run()`.
|
|
74
|
+
*
|
|
75
|
+
* Accented only supports two keys of the `options` object:
|
|
76
|
+
* * `rules`;
|
|
77
|
+
* * `runOnly`.
|
|
78
|
+
*
|
|
79
|
+
* Both properties are optional, and both control
|
|
80
|
+
* which accessibility rules your page is tested against.
|
|
81
|
+
*
|
|
82
|
+
* See documentation: https://www.deque.com/axe/core-documentation/api-documentation/#options-parameter
|
|
83
|
+
*
|
|
84
|
+
* Default: `{}`.
|
|
85
|
+
*/
|
|
86
|
+
axeOptions?: AxeOptions,
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The character sequence that’s used in various elements, attributes and stylesheets that Accented adds to the page.
|
|
90
|
+
* * The data attribute that’s added to elements with issues (default: `data-accented`).
|
|
91
|
+
* * The custom elements for the button and the dialog that get created for each element with issues
|
|
92
|
+
* (default: `accented-trigger`, `accented-dialog`).
|
|
93
|
+
* * The CSS cascade layer containing page-wide Accented-specific styles (default: `accented`).
|
|
94
|
+
* * The prefix for some of the CSS custom properties used by Accented (default: `--accented-`).
|
|
95
|
+
* * The window property that’s used to prevent multiple axe-core scans from running simultaneously
|
|
96
|
+
* (default: `__accented_axe_running__`).
|
|
97
|
+
*
|
|
98
|
+
* Only lowercase alphanumeric characters and dashes (-) are allowed in the name,
|
|
99
|
+
* and it must start with a lowercase letter.
|
|
100
|
+
*
|
|
101
|
+
* Default: `accented`.
|
|
102
|
+
*/
|
|
103
|
+
name?: string,
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Output options object.
|
|
107
|
+
* */
|
|
108
|
+
output?: Output,
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Scan throttling options object.
|
|
112
|
+
* */
|
|
113
|
+
throttle?: Throttle,
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A callback that will be called after each scan.
|
|
117
|
+
*
|
|
118
|
+
* Default: `() => {}`.
|
|
119
|
+
* */
|
|
120
|
+
callback?: Callback
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A function that fully disables Accented,
|
|
125
|
+
* stopping the scanning and removing all highlights from the page.
|
|
126
|
+
*/
|
|
127
|
+
export type DisableAccented = () => void;
|
|
128
|
+
|
|
129
|
+
export type Position = {
|
|
130
|
+
inlineEndLeft: number,
|
|
131
|
+
blockStartTop: number,
|
|
132
|
+
direction: 'ltr' | 'rtl'
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export type Issue = {
|
|
136
|
+
id: string,
|
|
137
|
+
title: string,
|
|
138
|
+
description: string,
|
|
139
|
+
url: string,
|
|
140
|
+
impact: axe.ImpactValue
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export type ElementWithIssues = {
|
|
144
|
+
element: HTMLElement,
|
|
145
|
+
issues: Array<Issue>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type ExtendedElementWithIssues = Omit<ElementWithIssues, 'issues'> & {
|
|
149
|
+
issues: Signal<ElementWithIssues['issues']>,
|
|
150
|
+
visible: Signal<boolean>,
|
|
151
|
+
trigger: AccentedTrigger,
|
|
152
|
+
position: Signal<Position>,
|
|
153
|
+
scrollableAncestors: Signal<Set<HTMLElement>>
|
|
154
|
+
id: number
|
|
155
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {suite, test} from 'node:test';
|
|
3
|
+
import areIssueSetsEqual from './are-issue-sets-equal.js';
|
|
4
|
+
import type { Issue } from '../types';
|
|
5
|
+
|
|
6
|
+
const issue1: Issue = {
|
|
7
|
+
id: 'id1',
|
|
8
|
+
title: 'title1',
|
|
9
|
+
description: 'description1',
|
|
10
|
+
url: 'http://example.com',
|
|
11
|
+
impact: 'serious'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const issue2: Issue = {
|
|
15
|
+
id: 'id2',
|
|
16
|
+
title: 'title2',
|
|
17
|
+
description: 'description2',
|
|
18
|
+
url: 'http://example.com',
|
|
19
|
+
impact: 'serious'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// @ts-expect-error
|
|
23
|
+
const issue2Clone: Issue = Object.keys(issue2).reduce((obj, key) => { obj[key] = issue2[key]; return obj; }, {});
|
|
24
|
+
|
|
25
|
+
const issue3: Issue = {
|
|
26
|
+
id: 'id3',
|
|
27
|
+
title: 'title3',
|
|
28
|
+
description: 'description3',
|
|
29
|
+
url: 'http://example.com',
|
|
30
|
+
impact: 'serious'
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
suite('areIssueSetsEqual', () => {
|
|
34
|
+
test('returns true when both sets are empty', () => {
|
|
35
|
+
assert.ok(areIssueSetsEqual([], []));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns true when both sets have equal elements, even when the order in the arrays doesn’t match', () => {
|
|
39
|
+
assert.ok(areIssueSetsEqual([issue1, issue2], [issue2Clone, issue1]));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('returns false when sets have different length', () => {
|
|
43
|
+
assert.equal(areIssueSetsEqual([issue1, issue2], [issue1]), false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('returns false when sets have the same length, but one element differs', () => {
|
|
47
|
+
assert.equal(areIssueSetsEqual([issue1, issue2], [issue1, issue3]), false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Issue } from '../types';
|
|
2
|
+
|
|
3
|
+
const issueProps: Array<keyof Issue> = ['id', 'title', 'description', 'url', 'impact'];
|
|
4
|
+
|
|
5
|
+
export default function areIssueSetsEqual(issues1: Array<Issue>, issues2: Array<Issue>) {
|
|
6
|
+
return issues1.length === issues2.length &&
|
|
7
|
+
issues1.every(issue1 => Boolean(issues2.find(issue2 =>
|
|
8
|
+
issueProps.every(prop => issue2[prop] === issue1[prop])
|
|
9
|
+
)));
|
|
10
|
+
}
|