accented 0.0.0-20250124142030
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/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/accented.d.ts +27 -0
- package/dist/accented.d.ts.map +1 -0
- package/dist/accented.js +85 -0
- package/dist/accented.js.map +1 -0
- package/dist/dom-updater.d.ts +2 -0
- package/dist/dom-updater.d.ts.map +1 -0
- package/dist/dom-updater.js +96 -0
- package/dist/dom-updater.js.map +1 -0
- package/dist/elements/accented-container.d.ts +350 -0
- package/dist/elements/accented-container.d.ts.map +1 -0
- package/dist/elements/accented-container.js +131 -0
- package/dist/elements/accented-container.js.map +1 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +20 -0
- package/dist/logger.js.map +1 -0
- package/dist/scanner.d.ts +3 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +56 -0
- package/dist/scanner.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +8 -0
- package/dist/state.js.map +1 -0
- package/dist/task-queue.d.ts +10 -0
- package/dist/task-queue.d.ts.map +1 -0
- package/dist/task-queue.js +44 -0
- package/dist/task-queue.js.map +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -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/transform-violations.d.ts +4 -0
- package/dist/utils/transform-violations.d.ts.map +1 -0
- package/dist/utils/transform-violations.js +39 -0
- package/dist/utils/transform-violations.js.map +1 -0
- package/dist/utils/update-elements-with-issues.d.ts +5 -0
- package/dist/utils/update-elements-with-issues.d.ts.map +1 -0
- package/dist/utils/update-elements-with-issues.js +46 -0
- package/dist/utils/update-elements-with-issues.js.map +1 -0
- package/package.json +38 -0
- package/src/accented.test.ts +24 -0
- package/src/accented.ts +99 -0
- package/src/dom-updater.ts +104 -0
- package/src/elements/accented-container.ts +147 -0
- package/src/logger.ts +21 -0
- package/src/scanner.ts +68 -0
- package/src/state.ts +12 -0
- package/src/task-queue.test.ts +135 -0
- package/src/task-queue.ts +59 -0
- package/src/types.ts +97 -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 +27 -0
- package/src/utils/deep-merge.ts +18 -0
- package/src/utils/transform-violations.test.ts +124 -0
- package/src/utils/transform-violations.ts +45 -0
- package/src/utils/update-elements-with-issues.test.ts +209 -0
- package/src/utils/update-elements-with-issues.ts +55 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Issue } from '../types';
|
|
2
|
+
import type { Signal } from '@preact/signals-core';
|
|
3
|
+
import { effect } from '@preact/signals-core';
|
|
4
|
+
|
|
5
|
+
export interface AccentedContainer extends HTMLElement {
|
|
6
|
+
issues: Signal<Array<Issue>> | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// We want Accented to not throw an error in Node, and use static imports,
|
|
10
|
+
// so we can't export `class extends HTMLElement` because HTMLElement is not available in Node.
|
|
11
|
+
export default (name: string) => {
|
|
12
|
+
const containerTemplate = document.createElement('template');
|
|
13
|
+
containerTemplate.innerHTML = `
|
|
14
|
+
<button id="trigger">⚠</button>
|
|
15
|
+
<dialog dir="ltr" aria-labelledby="title">
|
|
16
|
+
<h2 id="title">Issues</h2>
|
|
17
|
+
<ul id="issues"></ul>
|
|
18
|
+
</dialog>
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const issueTemplate = document.createElement('template');
|
|
22
|
+
issueTemplate.innerHTML = `
|
|
23
|
+
<li>
|
|
24
|
+
<a></a>
|
|
25
|
+
<div></div>
|
|
26
|
+
</li>
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const descriptionTemplate = document.createElement('template');
|
|
30
|
+
descriptionTemplate.innerHTML = `
|
|
31
|
+
<span></span>
|
|
32
|
+
<ul></ul>
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const stylesheet = new CSSStyleSheet();
|
|
36
|
+
stylesheet.replaceSync(`
|
|
37
|
+
:host {
|
|
38
|
+
position: absolute;
|
|
39
|
+
inset-inline-end: anchor(end);
|
|
40
|
+
inset-block-end: anchor(end);
|
|
41
|
+
|
|
42
|
+
/* Popover-specific stuff */
|
|
43
|
+
border: none;
|
|
44
|
+
padding: 0;
|
|
45
|
+
margin-inline-end: 0;
|
|
46
|
+
margin-block-end: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#trigger {
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
font-size: 1rem;
|
|
52
|
+
inline-size: max(32px, 2rem);
|
|
53
|
+
block-size: max(32px, 2rem);
|
|
54
|
+
|
|
55
|
+
/* Make it look better in forced-colors mode, */
|
|
56
|
+
border: 2px solid transparent;
|
|
57
|
+
|
|
58
|
+
background-color: var(--${name}-primary-color);
|
|
59
|
+
color: var(--${name}-secondary-color);
|
|
60
|
+
|
|
61
|
+
outline-offset: -4px;
|
|
62
|
+
outline-color: var(--${name}-secondary-color);
|
|
63
|
+
|
|
64
|
+
&:focus-visible {
|
|
65
|
+
outline-width: 2px;
|
|
66
|
+
outline-style: solid;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
&:hover:not(:focus-visible) {
|
|
70
|
+
outline-width: 2px;
|
|
71
|
+
outline-style: dashed;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
return class AccentedContainerLocal extends HTMLElement implements AccentedContainer {
|
|
77
|
+
#abortController: AbortController | undefined;
|
|
78
|
+
|
|
79
|
+
#disposeOfEffect: (() => void) | undefined;
|
|
80
|
+
|
|
81
|
+
issues: Signal<Array<Issue>> | undefined;
|
|
82
|
+
|
|
83
|
+
constructor() {
|
|
84
|
+
super();
|
|
85
|
+
this.attachShadow({ mode: 'open' });
|
|
86
|
+
const content = containerTemplate.content.cloneNode(true);
|
|
87
|
+
if (this.shadowRoot) {
|
|
88
|
+
this.shadowRoot.adoptedStyleSheets.push(stylesheet);
|
|
89
|
+
this.shadowRoot.append(content);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
connectedCallback() {
|
|
94
|
+
if (this.shadowRoot) {
|
|
95
|
+
const { shadowRoot } = this;
|
|
96
|
+
const trigger = shadowRoot.getElementById('trigger');
|
|
97
|
+
const dialog = shadowRoot.querySelector('dialog');
|
|
98
|
+
this.#abortController = new AbortController();
|
|
99
|
+
trigger?.addEventListener('click', () => {
|
|
100
|
+
dialog?.showModal();
|
|
101
|
+
}, { signal: this.#abortController.signal });
|
|
102
|
+
|
|
103
|
+
this.#disposeOfEffect = effect(() => {
|
|
104
|
+
if (this.issues) {
|
|
105
|
+
const issues = this.issues.value;
|
|
106
|
+
const issuesList = shadowRoot.getElementById('issues');
|
|
107
|
+
if (issuesList) {
|
|
108
|
+
issuesList.innerHTML = '';
|
|
109
|
+
for (const issue of issues) {
|
|
110
|
+
const issueContent = issueTemplate.content.cloneNode(true) as Element;
|
|
111
|
+
const a = issueContent.querySelector('a');
|
|
112
|
+
const div = issueContent.querySelector('div');
|
|
113
|
+
if (a && div) {
|
|
114
|
+
a.textContent = issue.title;
|
|
115
|
+
a.href = issue.url;
|
|
116
|
+
const descriptionItems = issue.description.split(/\n\s*/);
|
|
117
|
+
const descriptionContent = descriptionTemplate.content.cloneNode(true) as Element;
|
|
118
|
+
const descriptionTitle = descriptionContent.querySelector('span');
|
|
119
|
+
const descriptionList = descriptionContent.querySelector('ul');
|
|
120
|
+
if (descriptionTitle && descriptionList && descriptionItems.length > 1) {
|
|
121
|
+
descriptionTitle.textContent = descriptionItems[0]!;
|
|
122
|
+
for (const descriptionItem of descriptionItems.slice(1)) {
|
|
123
|
+
const li = document.createElement('li');
|
|
124
|
+
li.textContent = descriptionItem;
|
|
125
|
+
descriptionList.appendChild(li);
|
|
126
|
+
}
|
|
127
|
+
div.appendChild(descriptionContent);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
issuesList.appendChild(issueContent);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
disconnectedCallback() {
|
|
139
|
+
if (this.#abortController) {
|
|
140
|
+
this.#abortController.abort();
|
|
141
|
+
}
|
|
142
|
+
if (this.#disposeOfEffect) {
|
|
143
|
+
this.#disposeOfEffect();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
};
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { effect } from '@preact/signals-core';
|
|
2
|
+
import { elementsWithIssues } from './state.js';
|
|
3
|
+
|
|
4
|
+
const accentedUrl = 'https://www.npmjs.com/package/accented';
|
|
5
|
+
|
|
6
|
+
export default function createLogger() {
|
|
7
|
+
|
|
8
|
+
let firstRun = true;
|
|
9
|
+
|
|
10
|
+
return effect(() => {
|
|
11
|
+
if (elementsWithIssues.value.length > 0) {
|
|
12
|
+
console.log(`Elements with accessibility issues, identified by Accented (${accentedUrl}):\n`, elementsWithIssues.value);
|
|
13
|
+
} else {
|
|
14
|
+
if (firstRun) {
|
|
15
|
+
firstRun = false;
|
|
16
|
+
} else {
|
|
17
|
+
console.log(`No elements with accessibility issues identified by Accented (${accentedUrl}).`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import axe from 'axe-core';
|
|
2
|
+
import TaskQueue from './task-queue.js';
|
|
3
|
+
import { elementsWithIssues, enabled, extendedElementsWithIssues } from './state.js';
|
|
4
|
+
import type { Throttle, Callback } from './types';
|
|
5
|
+
import updateElementsWithIssues from './utils/update-elements-with-issues.js';
|
|
6
|
+
|
|
7
|
+
export default function createScanner(name: string, throttle: Required<Throttle>, callback: Callback) {
|
|
8
|
+
const taskQueue = new TaskQueue<Node>(async () => {
|
|
9
|
+
performance.mark('axe-start');
|
|
10
|
+
const result = await axe.run({
|
|
11
|
+
elementRef: true,
|
|
12
|
+
// Although axe-core can perform iframe scanning, I haven't succeeded in it,
|
|
13
|
+
// and the docs suggest that the axe-core script should be explicitly included
|
|
14
|
+
// in each of the iframed documents anyway.
|
|
15
|
+
// It seems preferable to disallow iframe scanning and not report issues in elements within iframes
|
|
16
|
+
// in the case that such issues are for some reason reported by axe-core.
|
|
17
|
+
// A consumer of Accented can instead scan the iframed document by calling Accented initialization from that document.
|
|
18
|
+
iframes: false
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const axeMeasure = performance.measure('axe', 'axe-start');
|
|
22
|
+
|
|
23
|
+
if (!enabled.value) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
updateElementsWithIssues(extendedElementsWithIssues, result.violations, window, name);
|
|
28
|
+
|
|
29
|
+
callback({
|
|
30
|
+
elementsWithIssues: elementsWithIssues.value,
|
|
31
|
+
scanDuration: Math.round(axeMeasure.duration)
|
|
32
|
+
});
|
|
33
|
+
}, throttle);
|
|
34
|
+
|
|
35
|
+
taskQueue.add(document);
|
|
36
|
+
|
|
37
|
+
const mutationObserver = new MutationObserver(mutationList => {
|
|
38
|
+
const listWithoutAccentedContainers = mutationList.filter(mutationRecord => {
|
|
39
|
+
return !(mutationRecord.type === 'childList' &&
|
|
40
|
+
[...mutationRecord.addedNodes].every(node => node.nodeName === `${name}-container`.toUpperCase()) &&
|
|
41
|
+
[...mutationRecord.removedNodes].every(node => node.nodeName === `${name}-container`.toUpperCase()));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const elementsWithAccentedAttributeChanges = listWithoutAccentedContainers.reduce((nodes, mutationRecord) => {
|
|
45
|
+
if (mutationRecord.type === 'attributes' && mutationRecord.attributeName === `data-${name}`) {
|
|
46
|
+
nodes.add(mutationRecord.target);
|
|
47
|
+
}
|
|
48
|
+
return nodes;
|
|
49
|
+
}, new Set<Node>());
|
|
50
|
+
|
|
51
|
+
const filteredMutationList = listWithoutAccentedContainers.filter(mutationRecord => {
|
|
52
|
+
return !elementsWithAccentedAttributeChanges.has(mutationRecord.target);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
taskQueue.addMultiple(filteredMutationList.map(mutationRecord => mutationRecord.target));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
mutationObserver.observe(document, {
|
|
59
|
+
subtree: true,
|
|
60
|
+
childList: true,
|
|
61
|
+
attributes: true,
|
|
62
|
+
characterData: true
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
mutationObserver.disconnect();
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
})));
|
|
@@ -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,97 @@
|
|
|
1
|
+
import type axe from 'axe-core';
|
|
2
|
+
import type { Signal } from '@preact/signals-core';
|
|
3
|
+
import type { AccentedContainer } from './elements/accented-container';
|
|
4
|
+
|
|
5
|
+
export type DeepRequired<T> = T extends object ? {
|
|
6
|
+
[P in keyof T]-? : DeepRequired<T[P]>
|
|
7
|
+
} : T;
|
|
8
|
+
|
|
9
|
+
export type Throttle = {
|
|
10
|
+
/**
|
|
11
|
+
* The minimal time between scans.
|
|
12
|
+
*
|
|
13
|
+
* Default: 1000.
|
|
14
|
+
* */
|
|
15
|
+
wait?: number,
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* When to run the scan on Accented initialization or on a mutation.
|
|
19
|
+
*
|
|
20
|
+
* If true, the scan will run immediately. If false, the scan will run after the first throttle delay.
|
|
21
|
+
*
|
|
22
|
+
* Default: true.
|
|
23
|
+
* */
|
|
24
|
+
leading?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type CallbackParams = {
|
|
28
|
+
/**
|
|
29
|
+
* The most current array of elements with issues.
|
|
30
|
+
* */
|
|
31
|
+
elementsWithIssues: Array<ElementWithIssues>,
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long the scan took in milliseconds.
|
|
35
|
+
* */
|
|
36
|
+
scanDuration: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Callback = (params: CallbackParams) => void;
|
|
40
|
+
|
|
41
|
+
export type AccentedOptions = {
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The character sequence that’s used in various elements, attributes and stylesheets that Accented adds to the page.
|
|
45
|
+
* * The data attribute that’s added to elements with issues (default: "data-accented").
|
|
46
|
+
* * The custom element that encapsulates the button and dialog attached to each element with issues (default: "accented-container").
|
|
47
|
+
* * The CSS cascade layer containing page-wide Accented-specific styles (default: "accented").
|
|
48
|
+
* * The prefix for some of the CSS custom properties used by Accented (default: "--accented-").
|
|
49
|
+
*
|
|
50
|
+
* Default: "accented".
|
|
51
|
+
*/
|
|
52
|
+
name?: string,
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether to output the issues to the console.
|
|
56
|
+
*
|
|
57
|
+
* Default: true.
|
|
58
|
+
* */
|
|
59
|
+
outputToConsole?: boolean,
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Scan throttling options object.
|
|
63
|
+
* */
|
|
64
|
+
throttle?: Throttle,
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A callback that will be called after each scan.
|
|
68
|
+
*
|
|
69
|
+
* Default: () => {}.
|
|
70
|
+
* */
|
|
71
|
+
callback?: Callback
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A function that fully disables Accented,
|
|
76
|
+
* stopping the scanning and removing all highlights from the page.
|
|
77
|
+
*/
|
|
78
|
+
export type DisableAccented = () => void;
|
|
79
|
+
|
|
80
|
+
export type Issue = {
|
|
81
|
+
id: string,
|
|
82
|
+
title: string,
|
|
83
|
+
description: string,
|
|
84
|
+
url: string,
|
|
85
|
+
impact: axe.ImpactValue
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type ElementWithIssues = {
|
|
89
|
+
element: HTMLElement,
|
|
90
|
+
issues: Array<Issue>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ExtendedElementWithIssues = Omit<ElementWithIssues, 'issues'> & {
|
|
94
|
+
issues: Signal<ElementWithIssues['issues']>,
|
|
95
|
+
accentedContainer: AccentedContainer,
|
|
96
|
+
id: number
|
|
97
|
+
};
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { suite, test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import deepMerge from './deep-merge';
|
|
5
|
+
|
|
6
|
+
suite('deepMerge', () => {
|
|
7
|
+
test('merges two objects with overlapping keys', () => {
|
|
8
|
+
const target = { a: 1, b: 2 };
|
|
9
|
+
const source = { b: 3, c: 4 };
|
|
10
|
+
const result = deepMerge(target, source);
|
|
11
|
+
assert.deepEqual(result, { a: 1, b: 3, c: 4 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('deeply merges nested objects', () => {
|
|
15
|
+
const target = { a: 1, b: { x: 1, y: 2 } };
|
|
16
|
+
const source = { b: { y: 3, z: 4 }, c: 5 };
|
|
17
|
+
const result = deepMerge(target, source);
|
|
18
|
+
assert.deepEqual(result, { a: 1, b: { x: 1, y: 3, z: 4 }, c: 5 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('handles null values in a logical way', () => {
|
|
22
|
+
const target = { a: 1 };
|
|
23
|
+
const source = { a: null };
|
|
24
|
+
const result = deepMerge(target, source);
|
|
25
|
+
assert.deepEqual(result, { a: null });
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type AnyObject = Record<string, any>;
|
|
2
|
+
|
|
3
|
+
export default function deepMerge(target: AnyObject, source: AnyObject): AnyObject {
|
|
4
|
+
const output = {...target};
|
|
5
|
+
for (const key of Object.keys(source)) {
|
|
6
|
+
if (typeof source[key] === 'object' && source[key] !== null) {
|
|
7
|
+
if (!(key in target)) {
|
|
8
|
+
output[key] = source[key];
|
|
9
|
+
} else {
|
|
10
|
+
output[key] = deepMerge(target[key], source[key]);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
output[key] = source[key];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return output;
|
|
18
|
+
}
|