@surea11y/core 1.2.0 → 1.4.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/CHANGELOG.md +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -18,9 +20,8 @@
|
|
|
18
20
|
* which element the reference resolves to (typically the first, silently
|
|
19
21
|
* dropping the others).
|
|
20
22
|
* @implementation-notes
|
|
21
|
-
* - Scoped deliberately to ids referenced by ARIA
|
|
22
|
-
*
|
|
23
|
-
* check — see ROADMAP.md's "Skip" list).
|
|
23
|
+
* - Scoped deliberately to ids referenced by ARIA, not the broader/
|
|
24
|
+
* deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
|
|
24
25
|
* - Document-wide by design: id uniqueness and ARIA id references are a
|
|
25
26
|
* whole-document property, not scoped to a sub-root.
|
|
26
27
|
*/
|
|
@@ -29,7 +30,8 @@ const id = 'duplicate-id-aria';
|
|
|
29
30
|
|
|
30
31
|
const meta = {
|
|
31
32
|
title: 'IDs referenced by ARIA must be unique',
|
|
32
|
-
description:
|
|
33
|
+
description:
|
|
34
|
+
'Checks that any id value referenced by an ARIA ID-reference attribute (aria-labelledby, aria-describedby, aria-owns, aria-controls, aria-activedescendant, aria-flowto, aria-errormessage, aria-details) is unique in the document.',
|
|
33
35
|
i18n: {
|
|
34
36
|
titleKey: 'duplicateIdAria_title',
|
|
35
37
|
descriptionKey: 'duplicateIdAria_description'
|
|
@@ -38,7 +40,13 @@ const meta = {
|
|
|
38
40
|
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
39
41
|
wcagSc: ['4.1.2'],
|
|
40
42
|
normativeMappings: [
|
|
41
|
-
{
|
|
43
|
+
{
|
|
44
|
+
standard: 'WCAG',
|
|
45
|
+
version: '2.2',
|
|
46
|
+
requirement: '4.1.2',
|
|
47
|
+
title: 'Name, Role, Value',
|
|
48
|
+
conformanceLevel: 'A'
|
|
49
|
+
}
|
|
42
50
|
],
|
|
43
51
|
defaultSeverity: 'serious',
|
|
44
52
|
category: 'robust',
|
|
@@ -95,7 +103,9 @@ function runInPage(ctx) {
|
|
|
95
103
|
|
|
96
104
|
for (const el of els) {
|
|
97
105
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
98
|
-
const html = helpers.getOuterHtmlSnippet
|
|
106
|
+
const html = helpers.getOuterHtmlSnippet
|
|
107
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
108
|
+
: el.outerHTML || '';
|
|
99
109
|
|
|
100
110
|
occurrences.push({
|
|
101
111
|
selector: stableSelector,
|
|
@@ -108,7 +118,11 @@ function runInPage(ctx) {
|
|
|
108
118
|
params: { id: refId, duplicateCount: String(els.length) }
|
|
109
119
|
},
|
|
110
120
|
data: {
|
|
111
|
-
details: {
|
|
121
|
+
details: {
|
|
122
|
+
reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
|
|
123
|
+
id: refId,
|
|
124
|
+
duplicateCount: els.length
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
});
|
|
114
128
|
}
|
|
@@ -118,9 +132,14 @@ function runInPage(ctx) {
|
|
|
118
132
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
119
133
|
}
|
|
120
134
|
if (occurrences.length) {
|
|
121
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
ruleId: rule.ruleId,
|
|
137
|
+
outcome: 'fail',
|
|
138
|
+
severity: rule.defaultSeverity || 'serious',
|
|
139
|
+
occurrences
|
|
140
|
+
};
|
|
122
141
|
}
|
|
123
142
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
124
143
|
}
|
|
125
144
|
|
|
126
|
-
module.exports = { id, meta, runInPage };
|
|
145
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -32,7 +34,13 @@ const meta = {
|
|
|
32
34
|
tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'atomic', 'automatic'],
|
|
33
35
|
wcagSc: ['1.1.1'],
|
|
34
36
|
normativeMappings: [
|
|
35
|
-
{
|
|
37
|
+
{
|
|
38
|
+
standard: 'WCAG',
|
|
39
|
+
version: '2.2',
|
|
40
|
+
requirement: '1.1.1',
|
|
41
|
+
title: 'Non-text Content',
|
|
42
|
+
conformanceLevel: 'A'
|
|
43
|
+
}
|
|
36
44
|
],
|
|
37
45
|
defaultSeverity: 'serious',
|
|
38
46
|
category: 'perceivable',
|
|
@@ -45,33 +53,39 @@ function runInPage(ctx) {
|
|
|
45
53
|
const { document, root, helpers, rule } = ctx;
|
|
46
54
|
const safeRoot = root || document;
|
|
47
55
|
|
|
48
|
-
const queryAllSmart =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
: null;
|
|
56
|
+
const queryAllSmart =
|
|
57
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
58
|
+
const queryAll =
|
|
59
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
60
|
+
? helpers.queryAll
|
|
61
|
+
: (sel) => {
|
|
62
|
+
try {
|
|
63
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
64
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
65
|
+
: [];
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const getEligibilityInfo =
|
|
72
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
73
|
+
|
|
74
|
+
const isAccTreeEligible =
|
|
75
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
76
|
+
|
|
77
|
+
const getFocusableInfo =
|
|
78
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
79
|
+
|
|
80
|
+
const getAriaNameInfo =
|
|
81
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
71
82
|
|
|
72
83
|
const embeds = (() => {
|
|
73
|
-
try {
|
|
74
|
-
|
|
84
|
+
try {
|
|
85
|
+
return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
|
|
86
|
+
} catch {
|
|
87
|
+
return queryAll('embed');
|
|
88
|
+
}
|
|
75
89
|
})();
|
|
76
90
|
|
|
77
91
|
if (!embeds.length) {
|
|
@@ -89,11 +103,22 @@ function runInPage(ctx) {
|
|
|
89
103
|
let aria = null;
|
|
90
104
|
|
|
91
105
|
if (getAriaNameInfo) {
|
|
92
|
-
aria = (() => {
|
|
106
|
+
aria = (() => {
|
|
107
|
+
try {
|
|
108
|
+
return getAriaNameInfo(el, ctx);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
})();
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
if (aria && aria.present && trim(aria.value)) {
|
|
96
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
present: true,
|
|
118
|
+
value: trim(aria.value),
|
|
119
|
+
mechanism: aria.mechanism || 'aria',
|
|
120
|
+
flags: (aria.flags || []).slice(0)
|
|
121
|
+
};
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
const title = trim(el.getAttribute && el.getAttribute('title'));
|
|
@@ -120,20 +145,43 @@ function runInPage(ctx) {
|
|
|
120
145
|
if (!el || !el.getAttribute) continue;
|
|
121
146
|
|
|
122
147
|
if (isAccTreeEligible) {
|
|
123
|
-
const elig = (() => {
|
|
148
|
+
const elig = (() => {
|
|
149
|
+
try {
|
|
150
|
+
return isAccTreeEligible(el, ctx);
|
|
151
|
+
} catch {
|
|
152
|
+
return { eligible: true, reasons: [] };
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
124
155
|
if (elig && elig.eligible === false) continue;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
// role presentation/none exclusion only when not focusable
|
|
128
|
-
const role = (() => {
|
|
159
|
+
const role = (() => {
|
|
160
|
+
try {
|
|
161
|
+
return String(el.getAttribute('role') || '')
|
|
162
|
+
.trim()
|
|
163
|
+
.toLowerCase();
|
|
164
|
+
} catch {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
129
168
|
if (role === 'presentation' || role === 'none') {
|
|
130
|
-
let focusable
|
|
169
|
+
let focusable;
|
|
131
170
|
if (getFocusableInfo) {
|
|
132
|
-
const fi = (() => {
|
|
171
|
+
const fi = (() => {
|
|
172
|
+
try {
|
|
173
|
+
return getFocusableInfo(el, ctx);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
133
178
|
focusable = !!(fi && fi.focusable);
|
|
134
179
|
} else {
|
|
135
180
|
const tabindex = el.getAttribute('tabindex');
|
|
136
|
-
focusable =
|
|
181
|
+
focusable =
|
|
182
|
+
tabindex != null &&
|
|
183
|
+
String(tabindex).trim() !== '' &&
|
|
184
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
137
185
|
}
|
|
138
186
|
if (!focusable) continue;
|
|
139
187
|
}
|
|
@@ -150,7 +198,7 @@ function runInPage(ctx) {
|
|
|
150
198
|
|
|
151
199
|
const baseOccurrence = {
|
|
152
200
|
summary: 'Missing text alternative for <embed>.',
|
|
153
|
-
hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby).',
|
|
201
|
+
hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby preferred, or a title attribute as a best-effort fallback).',
|
|
154
202
|
i18n: {
|
|
155
203
|
summaryKey: 'embed_textAltPresent_summary_fail',
|
|
156
204
|
hintKey: 'embed_textAltPresent_hint_fail',
|
|
@@ -184,7 +232,12 @@ function runInPage(ctx) {
|
|
|
184
232
|
};
|
|
185
233
|
}
|
|
186
234
|
|
|
187
|
-
return {
|
|
235
|
+
return {
|
|
236
|
+
ruleId: rule.ruleId,
|
|
237
|
+
outcome: 'fail',
|
|
238
|
+
severity: rule.defaultSeverity || 'minor',
|
|
239
|
+
occurrences
|
|
240
|
+
};
|
|
188
241
|
}
|
|
189
242
|
|
|
190
243
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,20 +1,15 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Key safety points:
|
|
12
|
-
* - Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
|
|
13
|
-
* and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
|
|
14
|
-
* is an array (multi-region contextSelector support), not a single element.
|
|
15
|
-
* - label[for] association uses a single precomputed Set of `for` values (document-wide, like original).
|
|
16
|
-
* - getFocusableInfo is only invoked when role is presentational and thus relevant.
|
|
17
|
-
* - aria-labelledby / aria-label helper resolution is only invoked when the attribute is present.
|
|
6
|
+
* Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
|
|
7
|
+
* and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
|
|
8
|
+
* is an array (multi-region contextSelector support), not a single element.
|
|
9
|
+
* Label-method resolution (label/aria-labelledby/aria-label/title/
|
|
10
|
+
* placeholder priority) delegates entirely to the shared dom-helpers
|
|
11
|
+
* bundle's getLabelMethod, which dom-runner.js always provides to every
|
|
12
|
+
* rule execution -- see getLabelMethodSafe below.
|
|
18
13
|
*
|
|
19
14
|
* WCAG mapping: matches technique H44 ("Using label elements to associate
|
|
20
15
|
* text labels with form controls"), which WCAG's own Techniques document
|
|
@@ -40,9 +35,27 @@ const meta = {
|
|
|
40
35
|
tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
|
|
41
36
|
wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
|
|
42
37
|
normativeMappings: [
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
{
|
|
39
|
+
standard: 'WCAG',
|
|
40
|
+
version: '2.2',
|
|
41
|
+
requirement: '1.3.1',
|
|
42
|
+
title: 'Info and Relationships',
|
|
43
|
+
conformanceLevel: 'A'
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
standard: 'WCAG',
|
|
47
|
+
version: '2.2',
|
|
48
|
+
requirement: '3.3.2',
|
|
49
|
+
title: 'Labels or Instructions',
|
|
50
|
+
conformanceLevel: 'A'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
standard: 'WCAG',
|
|
54
|
+
version: '2.2',
|
|
55
|
+
requirement: '4.1.2',
|
|
56
|
+
title: 'Name, Role, Value',
|
|
57
|
+
conformanceLevel: 'A'
|
|
58
|
+
}
|
|
46
59
|
],
|
|
47
60
|
defaultSeverity: 'serious',
|
|
48
61
|
category: 'robust',
|
|
@@ -58,48 +71,17 @@ const meta = {
|
|
|
58
71
|
};
|
|
59
72
|
|
|
60
73
|
function runInPage(ctx) {
|
|
61
|
-
const {
|
|
74
|
+
const { helpers, rule } = ctx;
|
|
62
75
|
|
|
63
|
-
const isAccTreeEligible =
|
|
64
|
-
|
|
76
|
+
const isAccTreeEligible =
|
|
77
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
78
|
+
const getEligibilityInfo =
|
|
79
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
65
80
|
|
|
66
|
-
const getFocusableInfo =
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
helpers && typeof helpers.
|
|
70
|
-
|
|
71
|
-
const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
|
|
72
|
-
const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
73
|
-
const getContentNameInfo = helpers && typeof helpers.getContentNameInfo === 'function' ? helpers.getContentNameInfo : null;
|
|
74
|
-
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
75
|
-
|
|
76
|
-
// A <label> counts as an association if it contributes a name either via
|
|
77
|
-
// its own aria-label/aria-labelledby (checked first, same ARIA-over-
|
|
78
|
-
// content precedence any element's accessible name gives — verified
|
|
79
|
-
// against a real page: <label aria-label="Toggle Navigation"><svg
|
|
80
|
-
// aria-hidden="true">...</svg></label> names its control "Toggle
|
|
81
|
-
// Navigation" even though the label's only child content is aria-hidden)
|
|
82
|
-
// or, failing that, non-empty ACCESSIBLE content (getContentNameInfo
|
|
83
|
-
// already excludes aria-hidden/display:none/inert descendants — a label
|
|
84
|
-
// whose only text is aria-hidden gives the control no real accessible
|
|
85
|
-
// name even though the structural association exists). See
|
|
86
|
-
// dom-helpers.js's labelContributesAccessibleName for the primary
|
|
87
|
-
// (shared-helper) path; this is the fallback-only mirror.
|
|
88
|
-
function labelHasAccessibleContent(labelEl) {
|
|
89
|
-
if (getAriaNameInfo) {
|
|
90
|
-
try {
|
|
91
|
-
const aria = getAriaNameInfo(labelEl, ctx);
|
|
92
|
-
if (aria && aria.present && trim(aria.value)) return true;
|
|
93
|
-
} catch {}
|
|
94
|
-
}
|
|
95
|
-
if (!getContentNameInfo) return true; // conservative: don't newly fail without the helper
|
|
96
|
-
try {
|
|
97
|
-
const info = getContentNameInfo(labelEl, ctx);
|
|
98
|
-
return !!(info && info.present && trim(info.value));
|
|
99
|
-
} catch {
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
81
|
+
const getFocusableInfo =
|
|
82
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
83
|
+
const getLabelMethod =
|
|
84
|
+
helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
103
85
|
|
|
104
86
|
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
105
87
|
|
|
@@ -107,7 +89,14 @@ function runInPage(ctx) {
|
|
|
107
89
|
applicableCount: 0,
|
|
108
90
|
passCount: 0,
|
|
109
91
|
failCount: 0,
|
|
110
|
-
byMethod: {
|
|
92
|
+
byMethod: {
|
|
93
|
+
label: 0,
|
|
94
|
+
'aria-labelledby': 0,
|
|
95
|
+
'aria-label': 0,
|
|
96
|
+
title: 0,
|
|
97
|
+
placeholder: 0,
|
|
98
|
+
none: 0
|
|
99
|
+
},
|
|
111
100
|
weakPassCount: 0
|
|
112
101
|
};
|
|
113
102
|
|
|
@@ -118,35 +107,6 @@ function runInPage(ctx) {
|
|
|
118
107
|
return 'none';
|
|
119
108
|
}
|
|
120
109
|
|
|
121
|
-
function getNonEmptyAttrViaHelper(el, name) {
|
|
122
|
-
if (!getAttributeInfo) return '';
|
|
123
|
-
try {
|
|
124
|
-
const info = getAttributeInfo(el, name);
|
|
125
|
-
return info && info.present ? trim(info.value) : '';
|
|
126
|
-
} catch {
|
|
127
|
-
return '';
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Build a document-wide Map of label[for] value -> label element once
|
|
132
|
-
// (O(#labels) instead of O(#controls) selector queries). Keeps the
|
|
133
|
-
// element reference (not just a presence flag) so hasLabelAssociation can
|
|
134
|
-
// check the label's actual accessible content.
|
|
135
|
-
const labelForMap = new Map();
|
|
136
|
-
try {
|
|
137
|
-
if (document && typeof document.getElementsByTagName === 'function') {
|
|
138
|
-
const labels = document.getElementsByTagName('label');
|
|
139
|
-
for (let i = 0; i < labels.length; i++) {
|
|
140
|
-
const lab = labels[i];
|
|
141
|
-
if (!lab || !lab.getAttribute) continue;
|
|
142
|
-
const f = trim(lab.getAttribute('for'));
|
|
143
|
-
if (f && !labelForMap.has(f)) labelForMap.set(f, lab);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
} catch {
|
|
147
|
-
// no-throw
|
|
148
|
-
}
|
|
149
|
-
|
|
150
110
|
function isEligibleAcc(el) {
|
|
151
111
|
if (!isAccTreeEligible) return true;
|
|
152
112
|
try {
|
|
@@ -158,110 +118,23 @@ function runInPage(ctx) {
|
|
|
158
118
|
}
|
|
159
119
|
}
|
|
160
120
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
} catch {}
|
|
169
|
-
|
|
170
|
-
// 2) Wrapped by <label>
|
|
171
|
-
try {
|
|
172
|
-
if (el && el.closest) {
|
|
173
|
-
const wrap = el.closest('label');
|
|
174
|
-
if (wrap) return labelHasAccessibleContent(wrap);
|
|
175
|
-
}
|
|
176
|
-
} catch {}
|
|
177
|
-
|
|
178
|
-
// 3) <label for="id">
|
|
179
|
-
try {
|
|
180
|
-
const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
|
|
181
|
-
if (!idAttr) return false;
|
|
182
|
-
const lab = labelForMap.get(idAttr);
|
|
183
|
-
return !!lab && labelHasAccessibleContent(lab);
|
|
184
|
-
} catch {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function computeLabelMethodFallback(el) {
|
|
190
|
-
// Deterministic priority order
|
|
191
|
-
if (hasLabelAssociation(el)) return { method: 'label', value: '' };
|
|
192
|
-
|
|
193
|
-
// Only resolve aria-labelledby if attribute exists
|
|
194
|
-
let raw = '';
|
|
195
|
-
try {
|
|
196
|
-
raw = el && el.getAttribute ? trim(el.getAttribute('aria-labelledby')) : '';
|
|
197
|
-
} catch {
|
|
198
|
-
raw = '';
|
|
199
|
-
}
|
|
200
|
-
if (raw && getAriaLabelledByInfo) {
|
|
201
|
-
try {
|
|
202
|
-
const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
|
|
203
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
204
|
-
if (v) return { method: 'aria-labelledby', value: v };
|
|
205
|
-
} catch {}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Only resolve aria-label if attribute exists
|
|
209
|
-
raw = '';
|
|
121
|
+
// getLabelMethod is provided by the shared dom-helpers bundle that
|
|
122
|
+
// dom-runner.js always constructs for every rule execution (built-in or
|
|
123
|
+
// custom) — see createDomHelpers's own getLabelMethod, which implements
|
|
124
|
+
// this exact <label>/aria-labelledby/aria-label/title/placeholder
|
|
125
|
+
// priority order. No local reimplementation is needed as a fallback.
|
|
126
|
+
function getLabelMethodSafe(el) {
|
|
127
|
+
if (!getLabelMethod) return { method: 'none', value: '' };
|
|
210
128
|
try {
|
|
211
|
-
|
|
129
|
+
const r = getLabelMethod(el, ctx);
|
|
130
|
+
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
131
|
+
const v = r && r.value != null ? trim(r.value) : '';
|
|
132
|
+
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
|
|
133
|
+
return { method: 'none', value: '' };
|
|
134
|
+
return { method: m, value: v };
|
|
212
135
|
} catch {
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
if (raw && getAriaLabelInfo) {
|
|
216
|
-
try {
|
|
217
|
-
const info = getAriaLabelInfo(el, ctx);
|
|
218
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
219
|
-
if (v) return { method: 'aria-label', value: v };
|
|
220
|
-
} catch {}
|
|
136
|
+
return { method: 'none', value: '' };
|
|
221
137
|
}
|
|
222
|
-
|
|
223
|
-
// title / placeholder
|
|
224
|
-
const titleV = getNonEmptyAttrViaHelper(el, 'title') || (() => {
|
|
225
|
-
try { return trim(el.getAttribute('title')); } catch { return ''; }
|
|
226
|
-
})();
|
|
227
|
-
if (titleV) return { method: 'title', value: titleV };
|
|
228
|
-
|
|
229
|
-
// `placeholder` is only a valid name/hint source for text-entry input
|
|
230
|
-
// types and <textarea> — not checkbox/radio/range/color/date/... or
|
|
231
|
-
// <select>, which browsers/AT never read a placeholder from.
|
|
232
|
-
const isPlaceholderCapable = (() => {
|
|
233
|
-
try {
|
|
234
|
-
const tag = (el.tagName || '').toLowerCase();
|
|
235
|
-
if (tag === 'textarea') return true;
|
|
236
|
-
if (tag !== 'input') return false;
|
|
237
|
-
const type = trim(el.getAttribute('type') || 'text').toLowerCase() || 'text';
|
|
238
|
-
return ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(type);
|
|
239
|
-
} catch {
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
})();
|
|
243
|
-
|
|
244
|
-
const phV = isPlaceholderCapable && (getNonEmptyAttrViaHelper(el, 'placeholder') || (() => {
|
|
245
|
-
try { return trim(el.getAttribute('placeholder')); } catch { return ''; }
|
|
246
|
-
})());
|
|
247
|
-
if (phV) return { method: 'placeholder', value: phV };
|
|
248
|
-
|
|
249
|
-
return { method: 'none', value: '' };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function getLabelMethodSafe(el) {
|
|
253
|
-
if (getLabelMethod) {
|
|
254
|
-
try {
|
|
255
|
-
const r = getLabelMethod(el, ctx);
|
|
256
|
-
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
257
|
-
const v = r && r.value != null ? trim(r.value) : '';
|
|
258
|
-
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
|
|
259
|
-
return { method: m, value: v };
|
|
260
|
-
} catch {
|
|
261
|
-
// fall through
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
return computeLabelMethodFallback(el);
|
|
265
138
|
}
|
|
266
139
|
|
|
267
140
|
// Collect candidate nodes via the shared queryAllSmart/queryAll helpers
|
|
@@ -272,17 +145,21 @@ function runInPage(ctx) {
|
|
|
272
145
|
const nodes = [];
|
|
273
146
|
try {
|
|
274
147
|
const sel = 'input,select,textarea';
|
|
275
|
-
const candidates =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
148
|
+
const candidates =
|
|
149
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
150
|
+
? helpers.queryAllSmart(sel)
|
|
151
|
+
: helpers && typeof helpers.queryAll === 'function'
|
|
152
|
+
? helpers.queryAll(sel)
|
|
153
|
+
: [];
|
|
154
|
+
|
|
155
|
+
for (const el of candidates || []) {
|
|
280
156
|
if (!el || !el.getAttribute) continue;
|
|
281
157
|
const tag = (el.tagName || '').toLowerCase();
|
|
282
158
|
if (tag === 'input') {
|
|
283
159
|
const t = trim(el.getAttribute('type')).toLowerCase();
|
|
284
160
|
// exclude hidden|submit|reset|button|image
|
|
285
|
-
if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
|
|
161
|
+
if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
|
|
162
|
+
continue;
|
|
286
163
|
}
|
|
287
164
|
nodes.push(el);
|
|
288
165
|
}
|
|
@@ -309,7 +186,7 @@ function runInPage(ctx) {
|
|
|
309
186
|
if (!isEligibleAcc(el)) continue;
|
|
310
187
|
|
|
311
188
|
// role="presentation"/"none" exclusion only when NOT focusable
|
|
312
|
-
let role
|
|
189
|
+
let role;
|
|
313
190
|
try {
|
|
314
191
|
role = trim(el.getAttribute('role')).toLowerCase();
|
|
315
192
|
} catch {
|
|
@@ -350,7 +227,15 @@ function runInPage(ctx) {
|
|
|
350
227
|
|
|
351
228
|
metrics.failCount += 1;
|
|
352
229
|
|
|
353
|
-
const vf = getEligibilityInfo
|
|
230
|
+
const vf = getEligibilityInfo
|
|
231
|
+
? (() => {
|
|
232
|
+
try {
|
|
233
|
+
return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
})()
|
|
238
|
+
: null;
|
|
354
239
|
|
|
355
240
|
const baseOccurrence = {
|
|
356
241
|
summary: 'Form control is missing a programmatic label.',
|