pour-engine 1.5.0 → 1.6.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/config/project.config.js +1 -1
- package/engine/README.md +1 -1
- package/engine/lib/accessible-name.js +63 -21
- package/engine/lib/roles.js +87 -46
- package/engine/rules/best-practice/landmark-unique.js +6 -12
- package/engine/rules/wcag/1.3.1-form-label.js +3 -6
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +4 -1
- package/engine/rules/wcag/1.4.3-control-contrast.js +4 -1
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +3 -1
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -10
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +1 -1
- package/engine/rules/wcag/4.1.2-dialog-name.js +3 -9
- package/engine/rules/wcag/4.1.2-nested-interactive.js +1 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
|
|
|
108
108
|
|
|
109
109
|
## Honest current state
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
74 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
112
112
|
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
113
113
|
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
114
114
|
the full accname spec (ours is a close approximation) and the language
|
|
@@ -2,19 +2,45 @@
|
|
|
2
2
|
// the ARIA accname spec: aria-labelledby → aria-label → native labelling
|
|
3
3
|
// (alt, <label>, text content, value) → title/placeholder fallbacks.
|
|
4
4
|
//
|
|
5
|
-
// Deliberately simpler than the full spec
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// Deliberately simpler than the full spec. Good enough for name-presence
|
|
6
|
+
// rules; grow it as rules need more.
|
|
7
|
+
|
|
8
|
+
/** Input types whose `.value` is user-entered text rather than a state. */
|
|
9
|
+
const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
|
|
10
|
+
'datetime-local', 'month', 'time', 'week', '']);
|
|
8
11
|
|
|
9
12
|
export function accessibleName(element) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
return computeName(element, false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The name aria-labelledby contributes, resolved the way accname requires:
|
|
17
|
+
* each referenced element's NAME, not its raw text. A reference whose target
|
|
18
|
+
* is named by an img alt, an aria-label or a control value names the referrer
|
|
19
|
+
* too, and reading textContent instead reports those elements as nameless.
|
|
20
|
+
* Rules that only need "is it labelled?" call this directly. */
|
|
21
|
+
export function labelledByName(element) {
|
|
22
|
+
const refs = element.getAttribute?.('aria-labelledby');
|
|
23
|
+
if (!refs) return '';
|
|
24
|
+
const root = element.getRootNode();
|
|
25
|
+
return refs
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map((id) => {
|
|
29
|
+
const target = root.getElementById?.(id);
|
|
30
|
+
return target ? computeName(target, true) : '';
|
|
31
|
+
})
|
|
32
|
+
.join(' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computeName(element, inLabelledBy) {
|
|
38
|
+
// accname step 2B: a referenced element's own aria-labelledby is not
|
|
39
|
+
// followed a second time. That is also what stops <p id="a"
|
|
40
|
+
// aria-labelledby="a"> recursing forever.
|
|
41
|
+
if (!inLabelledBy) {
|
|
42
|
+
const fromLabelledBy = labelledByName(element);
|
|
43
|
+
if (fromLabelledBy) return fromLabelledBy;
|
|
18
44
|
}
|
|
19
45
|
|
|
20
46
|
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
@@ -42,6 +68,14 @@ export function accessibleName(element) {
|
|
|
42
68
|
const alt = element.getAttribute('alt')?.trim();
|
|
43
69
|
if (alt) return alt;
|
|
44
70
|
}
|
|
71
|
+
// accname step 2E: an embedded control contributes its value, so
|
|
72
|
+
// aria-labelledby pointing at a filled text box names the referrer with
|
|
73
|
+
// what the user typed. Text-like fields only: a checkbox's `.value` is
|
|
74
|
+
// the string "on", which is a state, not a name.
|
|
75
|
+
if (inLabelledBy && (tag === 'textarea' || TEXT_INPUT.has(element.type))) {
|
|
76
|
+
const value = (element.value ?? '').trim();
|
|
77
|
+
if (value) return value;
|
|
78
|
+
}
|
|
45
79
|
// HTML-AAM: value-less submit/reset buttons get a UA-default name —
|
|
46
80
|
// browsers announce "Submit"/"Reset button" without any author text.
|
|
47
81
|
if (element.type === 'submit') return 'Submit';
|
|
@@ -51,18 +85,21 @@ export function accessibleName(element) {
|
|
|
51
85
|
// Buttons, links, headings etc. — name from contents, including image
|
|
52
86
|
// alts, but EXCLUDING hidden content: text inside aria-hidden or
|
|
53
87
|
// display:none/visibility:hidden subtrees does not name an element
|
|
54
|
-
// (per the accname spec).
|
|
55
|
-
|
|
88
|
+
// (per the accname spec). The one exception is a subtree reached THROUGH
|
|
89
|
+
// aria-labelledby, where accname ignores the hidden state.
|
|
90
|
+
const fromContents = visibleContentText(element, inLabelledBy).replace(/\s+/g, ' ').trim();
|
|
56
91
|
if (fromContents) return fromContents;
|
|
57
92
|
|
|
58
93
|
return (element.getAttribute('title') ?? element.getAttribute('placeholder') ?? '').trim();
|
|
59
94
|
}
|
|
60
95
|
|
|
61
|
-
function visibleContentText(element) {
|
|
96
|
+
function visibleContentText(element, includeHidden) {
|
|
62
97
|
// A custom element with a shadow root renders its shadow content — name
|
|
63
98
|
// from contents follows the FLAT tree (slots inside pull light DOM back).
|
|
64
99
|
const nodes = element.shadowRoot ? element.shadowRoot.childNodes : element.childNodes;
|
|
65
|
-
return generatedContent(element, '::before')
|
|
100
|
+
return generatedContent(element, '::before')
|
|
101
|
+
+ textFromNodes(nodes, includeHidden)
|
|
102
|
+
+ generatedContent(element, '::after');
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
/** CSS generated content contributes to name-from-contents per the accname
|
|
@@ -75,7 +112,10 @@ function generatedContent(element, pseudo) {
|
|
|
75
112
|
return match ? match[1].replace(/\\(.)/g, '$1') : '';
|
|
76
113
|
}
|
|
77
114
|
|
|
78
|
-
|
|
115
|
+
/** `includeHidden` is set while resolving an aria-labelledby target: accname
|
|
116
|
+
* ignores the hidden state of referenced content, which is what makes the
|
|
117
|
+
* visually-hidden label span (and the display:none one) work in browsers. */
|
|
118
|
+
function textFromNodes(nodes, includeHidden) {
|
|
79
119
|
let text = '';
|
|
80
120
|
for (const node of nodes) {
|
|
81
121
|
if (node.nodeType === 3 /* TEXT_NODE */) {
|
|
@@ -89,15 +129,17 @@ function textFromNodes(nodes) {
|
|
|
89
129
|
// on, and sites can override the UA's display:none (seen in the wild
|
|
90
130
|
// with lazy-load <noscript> fallbacks leaking "<img …>" into names).
|
|
91
131
|
if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
132
|
+
if (!includeHidden) {
|
|
133
|
+
if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
|
|
134
|
+
const style = getComputedStyle(node);
|
|
135
|
+
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
136
|
+
}
|
|
95
137
|
// A slot renders its assigned light-DOM nodes (fallback children when
|
|
96
138
|
// nothing is slotted) — a shadow <button><slot></slot></button> is named
|
|
97
139
|
// by the text the page slots in.
|
|
98
140
|
if (tag === 'slot') {
|
|
99
141
|
const assigned = node.assignedNodes?.() ?? [];
|
|
100
|
-
text += textFromNodes(assigned.length ? assigned : node.childNodes);
|
|
142
|
+
text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden);
|
|
101
143
|
continue;
|
|
102
144
|
}
|
|
103
145
|
if (tag === 'img' || tag === 'area') {
|
|
@@ -111,7 +153,7 @@ function textFromNodes(nodes) {
|
|
|
111
153
|
}
|
|
112
154
|
// Flat-tree descent: shadow content renders in place of a host's
|
|
113
155
|
// light children (archive.org-style nested web components).
|
|
114
|
-
const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes);
|
|
156
|
+
const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes, includeHidden);
|
|
115
157
|
if (fromSubtree.trim()) {
|
|
116
158
|
text += fromSubtree;
|
|
117
159
|
continue;
|
package/engine/lib/roles.js
CHANGED
|
@@ -24,57 +24,98 @@ export const GLOBAL_ARIA = new Set([
|
|
|
24
24
|
'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
|
-
/** Attributes each role supports IN ADDITION to the globals.
|
|
27
|
+
/** Attributes each role supports IN ADDITION to the globals.
|
|
28
|
+
*
|
|
29
|
+
* Includes the four attributes ARIA 1.2 removed from the GLOBAL set
|
|
30
|
+
* (aria-disabled, aria-errormessage, aria-haspopup, aria-invalid). The spec
|
|
31
|
+
* still lists them on every role that used to inherit them, marked deprecated
|
|
32
|
+
* on that role. Deprecated is not forbidden: the markup conforms and browsers
|
|
33
|
+
* map it, so rejecting it reports a fault on a correct page. Leaving them out
|
|
34
|
+
* cost 74 of the 81 roles here at least one false rejection. If deprecated
|
|
35
|
+
* usage is ever worth surfacing, it belongs in best-practice, not in a
|
|
36
|
+
* critical 4.1.2 failure. */
|
|
28
37
|
export const ROLE_ARIA = {
|
|
29
|
-
link: ['disabled', 'expanded', 'haspopup'],
|
|
30
|
-
button: ['disabled', 'expanded', 'haspopup', 'pressed'],
|
|
31
|
-
checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
|
|
32
|
-
switch: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
|
|
33
|
-
radio: ['checked', 'disabled', 'posinset', 'setsize'],
|
|
34
|
-
option: ['
|
|
35
|
-
tab: ['
|
|
36
|
-
menuitem: ['disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
|
|
37
|
-
menuitemcheckbox: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
|
|
38
|
-
menuitemradio: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
|
|
38
|
+
link: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
|
|
39
|
+
button: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'pressed'],
|
|
40
|
+
checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
|
|
41
|
+
switch: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
|
|
42
|
+
radio: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
|
|
43
|
+
option: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
|
|
44
|
+
tab: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
|
|
45
|
+
menuitem: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
|
|
46
|
+
menuitemcheckbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
|
|
47
|
+
menuitemradio: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
|
|
39
48
|
textbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
|
|
40
49
|
searchbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
|
|
41
50
|
combobox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
|
|
42
|
-
listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
|
|
51
|
+
listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
|
|
43
52
|
slider: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
44
|
-
spinbutton: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
45
|
-
progressbar: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
46
|
-
meter: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
47
|
-
scrollbar: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
48
|
-
heading: ['level'],
|
|
49
|
-
list: [],
|
|
50
|
-
listitem: ['level', 'posinset', 'setsize'],
|
|
51
|
-
row: ['colindex', 'disabled', 'expanded', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
|
|
52
|
-
rowgroup: [],
|
|
53
|
-
cell: ['colindex', 'colspan', 'rowindex', 'rowspan'],
|
|
54
|
-
gridcell: ['colindex', 'colspan', '
|
|
55
|
-
columnheader: ['colindex', 'colspan', '
|
|
56
|
-
rowheader: ['colindex', 'colspan', '
|
|
57
|
-
table: ['colcount', 'rowcount'],
|
|
58
|
-
grid: ['activedescendant', 'colcount', 'disabled', 'multiselectable', 'readonly', 'rowcount'],
|
|
59
|
-
treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
|
|
60
|
-
tablist: ['activedescendant', 'disabled', 'multiselectable', 'orientation'],
|
|
61
|
-
menu: ['activedescendant', 'disabled', 'orientation'],
|
|
62
|
-
menubar: ['activedescendant', 'disabled', 'orientation'],
|
|
63
|
-
tree: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'required'],
|
|
64
|
-
treeitem: ['checked', 'disabled', 'expanded', 'haspopup', 'level', 'posinset', 'selected', 'setsize'],
|
|
65
|
-
radiogroup: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'orientation', 'readonly', 'required'],
|
|
66
|
-
group: ['activedescendant', 'disabled'],
|
|
67
|
-
separator: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
68
|
-
toolbar: ['activedescendant', 'disabled', 'orientation'],
|
|
69
|
-
dialog: ['modal'],
|
|
70
|
-
alertdialog: ['modal'],
|
|
71
|
-
application: ['activedescendant', 'disabled', 'expanded', 'haspopup'],
|
|
72
|
-
article: ['posinset', 'setsize'],
|
|
73
|
-
img: [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
53
|
+
spinbutton: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
54
|
+
progressbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
55
|
+
meter: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
56
|
+
scrollbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
57
|
+
heading: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level'],
|
|
58
|
+
list: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
59
|
+
listitem: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level', 'posinset', 'setsize'],
|
|
60
|
+
row: ['activedescendant', 'colindex', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
|
|
61
|
+
rowgroup: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
62
|
+
cell: ['colindex', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowspan'],
|
|
63
|
+
gridcell: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected'],
|
|
64
|
+
columnheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
|
|
65
|
+
rowheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
|
|
66
|
+
table: ['colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowcount'],
|
|
67
|
+
grid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'readonly', 'rowcount'],
|
|
68
|
+
treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
|
|
69
|
+
tablist: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation'],
|
|
70
|
+
menu: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
|
|
71
|
+
menubar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
|
|
72
|
+
tree: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'required'],
|
|
73
|
+
treeitem: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'selected', 'setsize'],
|
|
74
|
+
radiogroup: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'required'],
|
|
75
|
+
group: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
76
|
+
separator: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
|
|
77
|
+
toolbar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
|
|
78
|
+
dialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
|
|
79
|
+
alertdialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
|
|
80
|
+
application: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
|
|
81
|
+
article: ['disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
|
|
82
|
+
img: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
83
|
+
figure: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
84
|
+
document: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
85
|
+
feed: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
86
|
+
math: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
87
|
+
note: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
88
|
+
presentation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
89
|
+
none: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
90
|
+
banner: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
91
|
+
complementary: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
92
|
+
contentinfo: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
93
|
+
form: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
94
|
+
main: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
95
|
+
navigation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
96
|
+
region: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
97
|
+
search: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
98
|
+
alert: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
99
|
+
log: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
100
|
+
marquee: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
101
|
+
status: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
102
|
+
timer: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
103
|
+
tabpanel: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
104
|
+
tooltip: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
105
|
+
definition: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
106
|
+
term: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
107
|
+
paragraph: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
108
|
+
generic: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
109
|
+
blockquote: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
110
|
+
caption: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
111
|
+
code: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
112
|
+
emphasis: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
113
|
+
strong: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
114
|
+
time: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
115
|
+
deletion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
116
|
+
insertion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
117
|
+
subscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
118
|
+
superscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
|
|
78
119
|
};
|
|
79
120
|
|
|
80
121
|
const INPUT_ROLES = {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// labels, and banner / contentinfo / main must be unique per page outright
|
|
3
3
|
// (two "banners" are ambiguous however well they're labelled).
|
|
4
4
|
import { implicitRole } from '../../lib/roles.js';
|
|
5
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
5
6
|
|
|
6
7
|
const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
|
|
7
8
|
|
|
@@ -13,18 +14,11 @@ const landmarkRole = (element) =>
|
|
|
13
14
|
|
|
14
15
|
// Landmarks are not name-from-content roles: only author-provided labels
|
|
15
16
|
// distinguish them (the text inside a nav is its content, not its name).
|
|
16
|
-
const landmarkName = (element) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
.map((id) => element.ownerDocument.getElementById(id)?.textContent ?? '')
|
|
22
|
-
.join(' ')
|
|
23
|
-
.trim();
|
|
24
|
-
if (text) return text;
|
|
25
|
-
}
|
|
26
|
-
return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
|
|
27
|
-
};
|
|
17
|
+
const landmarkName = (element) =>
|
|
18
|
+
labelledByName(element)
|
|
19
|
+
|| element.getAttribute('aria-label')?.trim()
|
|
20
|
+
|| element.getAttribute('title')?.trim()
|
|
21
|
+
|| '';
|
|
28
22
|
|
|
29
23
|
export default {
|
|
30
24
|
id: 'landmark-unique',
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
|
|
3
3
|
// 3.3.2 mapping: a field with no label at all, or only a vanishing
|
|
4
4
|
// placeholder, is failure F82 — the classic 3.3.2 pattern.
|
|
5
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
6
|
+
|
|
5
7
|
export default {
|
|
6
8
|
id: 'form-label',
|
|
7
9
|
impact: 'critical',
|
|
@@ -30,12 +32,7 @@ export default {
|
|
|
30
32
|
.join(' ')
|
|
31
33
|
.trim();
|
|
32
34
|
const ariaLabel = element.getAttribute('aria-label')?.trim();
|
|
33
|
-
const labelledby = (element
|
|
34
|
-
.split(/\s+/)
|
|
35
|
-
.filter(Boolean)
|
|
36
|
-
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
37
|
-
.join(' ')
|
|
38
|
-
.trim();
|
|
35
|
+
const labelledby = labelledByName(element);
|
|
39
36
|
if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
|
|
40
37
|
|
|
41
38
|
const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
|
|
@@ -22,7 +22,10 @@ export default {
|
|
|
22
22
|
visibility: 'visual',
|
|
23
23
|
evaluate(element) {
|
|
24
24
|
// Inactive controls are exempt (same carve-out 1.4.3 makes).
|
|
25
|
-
|
|
25
|
+
// `:disabled`, not `element.disabled`: the property misses a control
|
|
26
|
+
// disabled by an ancestor <fieldset disabled>, which 1.4.11 exempts as an
|
|
27
|
+
// inactive user interface component.
|
|
28
|
+
if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
|
|
26
29
|
// Zero-area controls (JS-widget hidden duplicates) paint no boundary
|
|
27
30
|
// to judge.
|
|
28
31
|
const ownRect = element.getBoundingClientRect();
|
|
@@ -20,7 +20,10 @@ export default {
|
|
|
20
20
|
visibility: 'visual',
|
|
21
21
|
evaluate(element) {
|
|
22
22
|
// 1.4.3 exempts inactive controls.
|
|
23
|
-
|
|
23
|
+
// `:disabled`, not `element.disabled`: the property reflects only the
|
|
24
|
+
// element's own attribute, so a control inside <fieldset disabled> reads
|
|
25
|
+
// false while the browser has it inactive and 1.4.3 exempts it.
|
|
26
|
+
if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
|
|
24
27
|
// Zero-area controls (JS-widget hidden duplicates) present no text.
|
|
25
28
|
const rect = element.getBoundingClientRect();
|
|
26
29
|
if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
|
|
@@ -76,7 +76,9 @@ export default {
|
|
|
76
76
|
&& rect.top >= overlay.top && rect.bottom <= overlay.bottom;
|
|
77
77
|
|
|
78
78
|
return elements.map((element) => {
|
|
79
|
-
|
|
79
|
+
// `:disabled` catches controls disabled by an ancestor fieldset, which
|
|
80
|
+
// the `disabled` property does not reflect — those never take focus.
|
|
81
|
+
if (element.matches(':disabled')) return { status: 'pass' };
|
|
80
82
|
const rect = element.getBoundingClientRect();
|
|
81
83
|
if (!rect.width || !rect.height) return { status: 'pass' };
|
|
82
84
|
// Only in-viewport geometry is trustworthy (or cheap).
|
|
@@ -5,20 +5,16 @@
|
|
|
5
5
|
// Spec note (accname): input-like roles are NOT "name from content" — the
|
|
6
6
|
// text inside a listbox/textbox does not name it, only author-provided
|
|
7
7
|
// naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
|
|
8
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
9
|
+
|
|
8
10
|
const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
|
|
9
11
|
const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
|
|
10
12
|
|
|
11
13
|
function authorName(element) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
17
|
-
.join(' ')
|
|
18
|
-
.trim();
|
|
19
|
-
if (text) return text;
|
|
20
|
-
}
|
|
21
|
-
return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
|
|
14
|
+
return labelledByName(element)
|
|
15
|
+
|| element.getAttribute('aria-label')?.trim()
|
|
16
|
+
|| element.getAttribute('title')?.trim()
|
|
17
|
+
|| '';
|
|
22
18
|
}
|
|
23
19
|
|
|
24
20
|
export default {
|
|
@@ -23,7 +23,7 @@ export default {
|
|
|
23
23
|
// property, so a faded WRAPPER (the standard off-canvas drawer) leaves
|
|
24
24
|
// every control inside it computing opacity: 1 while none of them paint.
|
|
25
25
|
const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
|
|
26
|
-
(el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
|
|
26
|
+
(el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.matches(':disabled')
|
|
27
27
|
// `inert` removes focusability outright — aria-hidden + inert is
|
|
28
28
|
// the CORRECT modern pattern for off-canvas content, not a defect.
|
|
29
29
|
&& !el.closest('[inert]')
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// name. An open, unnamed modal announces as just "dialog", leaving
|
|
5
5
|
// screen-reader users with no idea what interrupted them (cookie-consent
|
|
6
6
|
// modals are the canonical offender).
|
|
7
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
8
|
+
|
|
7
9
|
export default {
|
|
8
10
|
id: 'dialog-name',
|
|
9
11
|
impact: 'serious',
|
|
@@ -14,15 +16,7 @@ export default {
|
|
|
14
16
|
// dialogs the user can actually encounter are judged.
|
|
15
17
|
selector: 'dialog, [role="dialog"], [role="alertdialog"]',
|
|
16
18
|
evaluate(element) {
|
|
17
|
-
|
|
18
|
-
if (labelledby) {
|
|
19
|
-
const text = labelledby
|
|
20
|
-
.split(/\s+/)
|
|
21
|
-
.map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
|
|
22
|
-
.join(' ')
|
|
23
|
-
.trim();
|
|
24
|
-
if (text) return { status: 'pass' };
|
|
25
|
-
}
|
|
19
|
+
if (labelledByName(element)) return { status: 'pass' };
|
|
26
20
|
if (element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim()) {
|
|
27
21
|
return { status: 'pass' };
|
|
28
22
|
}
|
|
@@ -18,7 +18,7 @@ export default {
|
|
|
18
18
|
// disabled controls, display:none templates, and tabindex="-1" nodes
|
|
19
19
|
// inside a control are inert markup, not competing tab stops.
|
|
20
20
|
const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
|
|
21
|
-
!el.disabled && el.getAttribute('tabindex') !== '-1'
|
|
21
|
+
!el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
|
|
22
22
|
&& !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
|
|
23
23
|
if (!nested) return { status: 'pass' };
|
|
24
24
|
return {
|
package/package.json
CHANGED