@surea11y/core 1.1.2 → 1.3.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 +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -32,7 +32,13 @@ const meta = {
|
|
|
32
32
|
tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'atomic', 'automatic'],
|
|
33
33
|
wcagSc: ['1.1.1'],
|
|
34
34
|
normativeMappings: [
|
|
35
|
-
{
|
|
35
|
+
{
|
|
36
|
+
standard: 'WCAG',
|
|
37
|
+
version: '2.2',
|
|
38
|
+
requirement: '1.1.1',
|
|
39
|
+
title: 'Non-text Content',
|
|
40
|
+
conformanceLevel: 'A'
|
|
41
|
+
}
|
|
36
42
|
],
|
|
37
43
|
defaultSeverity: 'serious',
|
|
38
44
|
category: 'perceivable',
|
|
@@ -45,33 +51,39 @@ function runInPage(ctx) {
|
|
|
45
51
|
const { document, root, helpers, rule } = ctx;
|
|
46
52
|
const safeRoot = root || document;
|
|
47
53
|
|
|
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;
|
|
54
|
+
const queryAllSmart =
|
|
55
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
56
|
+
const queryAll =
|
|
57
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
58
|
+
? helpers.queryAll
|
|
59
|
+
: (sel) => {
|
|
60
|
+
try {
|
|
61
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
62
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
63
|
+
: [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const getEligibilityInfo =
|
|
70
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
71
|
+
|
|
72
|
+
const isAccTreeEligible =
|
|
73
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
74
|
+
|
|
75
|
+
const getFocusableInfo =
|
|
76
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
77
|
+
|
|
78
|
+
const getAriaNameInfo =
|
|
79
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
71
80
|
|
|
72
81
|
const embeds = (() => {
|
|
73
|
-
try {
|
|
74
|
-
|
|
82
|
+
try {
|
|
83
|
+
return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []);
|
|
84
|
+
} catch {
|
|
85
|
+
return queryAll('embed');
|
|
86
|
+
}
|
|
75
87
|
})();
|
|
76
88
|
|
|
77
89
|
if (!embeds.length) {
|
|
@@ -89,11 +101,22 @@ function runInPage(ctx) {
|
|
|
89
101
|
let aria = null;
|
|
90
102
|
|
|
91
103
|
if (getAriaNameInfo) {
|
|
92
|
-
aria = (() => {
|
|
104
|
+
aria = (() => {
|
|
105
|
+
try {
|
|
106
|
+
return getAriaNameInfo(el, ctx);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
93
111
|
}
|
|
94
112
|
|
|
95
113
|
if (aria && aria.present && trim(aria.value)) {
|
|
96
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
present: true,
|
|
116
|
+
value: trim(aria.value),
|
|
117
|
+
mechanism: aria.mechanism || 'aria',
|
|
118
|
+
flags: (aria.flags || []).slice(0)
|
|
119
|
+
};
|
|
97
120
|
}
|
|
98
121
|
|
|
99
122
|
const title = trim(el.getAttribute && el.getAttribute('title'));
|
|
@@ -120,20 +143,43 @@ function runInPage(ctx) {
|
|
|
120
143
|
if (!el || !el.getAttribute) continue;
|
|
121
144
|
|
|
122
145
|
if (isAccTreeEligible) {
|
|
123
|
-
const elig = (() => {
|
|
146
|
+
const elig = (() => {
|
|
147
|
+
try {
|
|
148
|
+
return isAccTreeEligible(el, ctx);
|
|
149
|
+
} catch {
|
|
150
|
+
return { eligible: true, reasons: [] };
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
124
153
|
if (elig && elig.eligible === false) continue;
|
|
125
154
|
}
|
|
126
155
|
|
|
127
156
|
// role presentation/none exclusion only when not focusable
|
|
128
|
-
const role = (() => {
|
|
157
|
+
const role = (() => {
|
|
158
|
+
try {
|
|
159
|
+
return String(el.getAttribute('role') || '')
|
|
160
|
+
.trim()
|
|
161
|
+
.toLowerCase();
|
|
162
|
+
} catch {
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
129
166
|
if (role === 'presentation' || role === 'none') {
|
|
130
|
-
let focusable
|
|
167
|
+
let focusable;
|
|
131
168
|
if (getFocusableInfo) {
|
|
132
|
-
const fi = (() => {
|
|
169
|
+
const fi = (() => {
|
|
170
|
+
try {
|
|
171
|
+
return getFocusableInfo(el, ctx);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
})();
|
|
133
176
|
focusable = !!(fi && fi.focusable);
|
|
134
177
|
} else {
|
|
135
178
|
const tabindex = el.getAttribute('tabindex');
|
|
136
|
-
focusable =
|
|
179
|
+
focusable =
|
|
180
|
+
tabindex != null &&
|
|
181
|
+
String(tabindex).trim() !== '' &&
|
|
182
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
137
183
|
}
|
|
138
184
|
if (!focusable) continue;
|
|
139
185
|
}
|
|
@@ -150,7 +196,7 @@ function runInPage(ctx) {
|
|
|
150
196
|
|
|
151
197
|
const baseOccurrence = {
|
|
152
198
|
summary: 'Missing text alternative for <embed>.',
|
|
153
|
-
hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby).',
|
|
199
|
+
hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby preferred, or a title attribute as a best-effort fallback).',
|
|
154
200
|
i18n: {
|
|
155
201
|
summaryKey: 'embed_textAltPresent_summary_fail',
|
|
156
202
|
hintKey: 'embed_textAltPresent_hint_fail',
|
|
@@ -184,7 +230,12 @@ function runInPage(ctx) {
|
|
|
184
230
|
};
|
|
185
231
|
}
|
|
186
232
|
|
|
187
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
ruleId: rule.ruleId,
|
|
235
|
+
outcome: 'fail',
|
|
236
|
+
severity: rule.defaultSeverity || 'minor',
|
|
237
|
+
occurrences
|
|
238
|
+
};
|
|
188
239
|
}
|
|
189
240
|
|
|
190
241
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
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.
|
|
4
|
+
* Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
|
|
5
|
+
* and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
|
|
6
|
+
* is an array (multi-region contextSelector support), not a single element.
|
|
7
|
+
* Label-method resolution (label/aria-labelledby/aria-label/title/
|
|
8
|
+
* placeholder priority) delegates entirely to the shared dom-helpers
|
|
9
|
+
* bundle's getLabelMethod, which dom-runner.js always provides to every
|
|
10
|
+
* rule execution -- see getLabelMethodSafe below.
|
|
18
11
|
*
|
|
19
12
|
* WCAG mapping: matches technique H44 ("Using label elements to associate
|
|
20
13
|
* text labels with form controls"), which WCAG's own Techniques document
|
|
@@ -40,9 +33,27 @@ const meta = {
|
|
|
40
33
|
tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
|
|
41
34
|
wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
|
|
42
35
|
normativeMappings: [
|
|
43
|
-
{
|
|
44
|
-
|
|
45
|
-
|
|
36
|
+
{
|
|
37
|
+
standard: 'WCAG',
|
|
38
|
+
version: '2.2',
|
|
39
|
+
requirement: '1.3.1',
|
|
40
|
+
title: 'Info and Relationships',
|
|
41
|
+
conformanceLevel: 'A'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
standard: 'WCAG',
|
|
45
|
+
version: '2.2',
|
|
46
|
+
requirement: '3.3.2',
|
|
47
|
+
title: 'Labels or Instructions',
|
|
48
|
+
conformanceLevel: 'A'
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
standard: 'WCAG',
|
|
52
|
+
version: '2.2',
|
|
53
|
+
requirement: '4.1.2',
|
|
54
|
+
title: 'Name, Role, Value',
|
|
55
|
+
conformanceLevel: 'A'
|
|
56
|
+
}
|
|
46
57
|
],
|
|
47
58
|
defaultSeverity: 'serious',
|
|
48
59
|
category: 'robust',
|
|
@@ -58,48 +69,17 @@ const meta = {
|
|
|
58
69
|
};
|
|
59
70
|
|
|
60
71
|
function runInPage(ctx) {
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
64
|
-
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
72
|
+
const { helpers, rule } = ctx;
|
|
65
73
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
helpers && typeof helpers.
|
|
74
|
+
const isAccTreeEligible =
|
|
75
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
76
|
+
const getEligibilityInfo =
|
|
77
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
70
78
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
-
}
|
|
79
|
+
const getFocusableInfo =
|
|
80
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
81
|
+
const getLabelMethod =
|
|
82
|
+
helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
103
83
|
|
|
104
84
|
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
105
85
|
|
|
@@ -107,7 +87,14 @@ function runInPage(ctx) {
|
|
|
107
87
|
applicableCount: 0,
|
|
108
88
|
passCount: 0,
|
|
109
89
|
failCount: 0,
|
|
110
|
-
byMethod: {
|
|
90
|
+
byMethod: {
|
|
91
|
+
label: 0,
|
|
92
|
+
'aria-labelledby': 0,
|
|
93
|
+
'aria-label': 0,
|
|
94
|
+
title: 0,
|
|
95
|
+
placeholder: 0,
|
|
96
|
+
none: 0
|
|
97
|
+
},
|
|
111
98
|
weakPassCount: 0
|
|
112
99
|
};
|
|
113
100
|
|
|
@@ -118,35 +105,6 @@ function runInPage(ctx) {
|
|
|
118
105
|
return 'none';
|
|
119
106
|
}
|
|
120
107
|
|
|
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
108
|
function isEligibleAcc(el) {
|
|
151
109
|
if (!isAccTreeEligible) return true;
|
|
152
110
|
try {
|
|
@@ -158,110 +116,23 @@ function runInPage(ctx) {
|
|
|
158
116
|
}
|
|
159
117
|
}
|
|
160
118
|
|
|
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 = '';
|
|
119
|
+
// getLabelMethod is provided by the shared dom-helpers bundle that
|
|
120
|
+
// dom-runner.js always constructs for every rule execution (built-in or
|
|
121
|
+
// custom) — see createDomHelpers's own getLabelMethod, which implements
|
|
122
|
+
// this exact <label>/aria-labelledby/aria-label/title/placeholder
|
|
123
|
+
// priority order. No local reimplementation is needed as a fallback.
|
|
124
|
+
function getLabelMethodSafe(el) {
|
|
125
|
+
if (!getLabelMethod) return { method: 'none', value: '' };
|
|
210
126
|
try {
|
|
211
|
-
|
|
127
|
+
const r = getLabelMethod(el, ctx);
|
|
128
|
+
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
129
|
+
const v = r && r.value != null ? trim(r.value) : '';
|
|
130
|
+
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
|
|
131
|
+
return { method: 'none', value: '' };
|
|
132
|
+
return { method: m, value: v };
|
|
212
133
|
} 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 {}
|
|
134
|
+
return { method: 'none', value: '' };
|
|
221
135
|
}
|
|
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
136
|
}
|
|
266
137
|
|
|
267
138
|
// Collect candidate nodes via the shared queryAllSmart/queryAll helpers
|
|
@@ -272,17 +143,21 @@ function runInPage(ctx) {
|
|
|
272
143
|
const nodes = [];
|
|
273
144
|
try {
|
|
274
145
|
const sel = 'input,select,textarea';
|
|
275
|
-
const candidates =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
146
|
+
const candidates =
|
|
147
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
148
|
+
? helpers.queryAllSmart(sel)
|
|
149
|
+
: helpers && typeof helpers.queryAll === 'function'
|
|
150
|
+
? helpers.queryAll(sel)
|
|
151
|
+
: [];
|
|
152
|
+
|
|
153
|
+
for (const el of candidates || []) {
|
|
280
154
|
if (!el || !el.getAttribute) continue;
|
|
281
155
|
const tag = (el.tagName || '').toLowerCase();
|
|
282
156
|
if (tag === 'input') {
|
|
283
157
|
const t = trim(el.getAttribute('type')).toLowerCase();
|
|
284
158
|
// exclude hidden|submit|reset|button|image
|
|
285
|
-
if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
|
|
159
|
+
if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image')
|
|
160
|
+
continue;
|
|
286
161
|
}
|
|
287
162
|
nodes.push(el);
|
|
288
163
|
}
|
|
@@ -309,7 +184,7 @@ function runInPage(ctx) {
|
|
|
309
184
|
if (!isEligibleAcc(el)) continue;
|
|
310
185
|
|
|
311
186
|
// role="presentation"/"none" exclusion only when NOT focusable
|
|
312
|
-
let role
|
|
187
|
+
let role;
|
|
313
188
|
try {
|
|
314
189
|
role = trim(el.getAttribute('role')).toLowerCase();
|
|
315
190
|
} catch {
|
|
@@ -350,7 +225,15 @@ function runInPage(ctx) {
|
|
|
350
225
|
|
|
351
226
|
metrics.failCount += 1;
|
|
352
227
|
|
|
353
|
-
const vf = getEligibilityInfo
|
|
228
|
+
const vf = getEligibilityInfo
|
|
229
|
+
? (() => {
|
|
230
|
+
try {
|
|
231
|
+
return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
232
|
+
} catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
})()
|
|
236
|
+
: null;
|
|
354
237
|
|
|
355
238
|
const baseOccurrence = {
|
|
356
239
|
summary: 'Form control is missing a programmatic label.',
|
|
@@ -21,13 +21,20 @@
|
|
|
21
21
|
* decision from form-control-programmatic-label-present (that
|
|
22
22
|
* rule checks a label exists at all; this one checks there is at most
|
|
23
23
|
* one).
|
|
24
|
+
* - A label that isn't accessibility-tree-eligible (display:none,
|
|
25
|
+
* aria-hidden, etc.) is excluded before counting: it can't contribute to
|
|
26
|
+
* the control's accessible name, so it can't create the ambiguity this
|
|
27
|
+
* rule exists to catch. Matches the reference engine's own
|
|
28
|
+
* multiple-label check, which filters hidden labels the same way before
|
|
29
|
+
* deciding whether there's more than one.
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
const id = 'form-control-single-label';
|
|
27
33
|
|
|
28
34
|
const meta = {
|
|
29
35
|
title: 'Form controls must not have multiple labels',
|
|
30
|
-
description:
|
|
36
|
+
description:
|
|
37
|
+
'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
|
|
31
38
|
i18n: {
|
|
32
39
|
titleKey: 'formControlSingleLabel_title',
|
|
33
40
|
descriptionKey: 'formControlSingleLabel_description'
|
|
@@ -36,7 +43,13 @@ const meta = {
|
|
|
36
43
|
tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
|
|
37
44
|
wcagSc: ['3.3.2'],
|
|
38
45
|
normativeMappings: [
|
|
39
|
-
{
|
|
46
|
+
{
|
|
47
|
+
standard: 'WCAG',
|
|
48
|
+
version: '2.2',
|
|
49
|
+
requirement: '3.3.2',
|
|
50
|
+
title: 'Labels or Instructions',
|
|
51
|
+
conformanceLevel: 'A'
|
|
52
|
+
}
|
|
40
53
|
],
|
|
41
54
|
defaultSeverity: 'moderate',
|
|
42
55
|
category: 'understandable',
|
|
@@ -48,8 +61,14 @@ const meta = {
|
|
|
48
61
|
function runInPage(ctx) {
|
|
49
62
|
const { document, helpers, rule } = ctx;
|
|
50
63
|
|
|
51
|
-
const
|
|
52
|
-
|
|
64
|
+
const isAccTreeEligible =
|
|
65
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
66
|
+
|
|
67
|
+
const selector =
|
|
68
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
69
|
+
const nodes = helpers.queryAllSmart
|
|
70
|
+
? helpers.queryAllSmart(selector)
|
|
71
|
+
: helpers.queryAll(selector);
|
|
53
72
|
|
|
54
73
|
// Build a for-value -> label[] map once (avoids per-control dynamic
|
|
55
74
|
// attribute-selector construction / CSS.escape, which is not guaranteed
|
|
@@ -82,11 +101,20 @@ function runInPage(ctx) {
|
|
|
82
101
|
for (const lab of labelsByFor.get(controlId)) labels.add(lab);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
|
-
|
|
104
|
+
const eligibleLabels = isAccTreeEligible
|
|
105
|
+
? new Set(
|
|
106
|
+
[...labels].filter((lab) => {
|
|
107
|
+
const elig = isAccTreeEligible(lab);
|
|
108
|
+
return !elig || elig.eligible !== false;
|
|
109
|
+
})
|
|
110
|
+
)
|
|
111
|
+
: labels;
|
|
112
|
+
|
|
113
|
+
if (eligibleLabels.size <= 1) continue;
|
|
86
114
|
|
|
87
115
|
const tag = el.tagName.toLowerCase();
|
|
88
116
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
89
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
117
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
90
118
|
|
|
91
119
|
occurrences.push({
|
|
92
120
|
selector: stableSelector,
|
|
@@ -96,10 +124,14 @@ function runInPage(ctx) {
|
|
|
96
124
|
i18n: {
|
|
97
125
|
summaryKey: 'formControlSingleLabel_summary_fail',
|
|
98
126
|
hintKey: 'formControlSingleLabel_hint_fail',
|
|
99
|
-
params: { element: tag, labelCount: String(
|
|
127
|
+
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
100
128
|
},
|
|
101
129
|
data: {
|
|
102
|
-
details: {
|
|
130
|
+
details: {
|
|
131
|
+
reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
|
|
132
|
+
element: tag,
|
|
133
|
+
labelCount: eligibleLabels.size
|
|
134
|
+
}
|
|
103
135
|
}
|
|
104
136
|
});
|
|
105
137
|
}
|
|
@@ -108,9 +140,14 @@ function runInPage(ctx) {
|
|
|
108
140
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
109
141
|
}
|
|
110
142
|
if (occurrences.length) {
|
|
111
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
ruleId: rule.ruleId,
|
|
145
|
+
outcome: 'fail',
|
|
146
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
147
|
+
occurrences
|
|
148
|
+
};
|
|
112
149
|
}
|
|
113
150
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
114
151
|
}
|
|
115
152
|
|
|
116
|
-
module.exports = { id, meta, runInPage };
|
|
153
|
+
module.exports = { id, meta, runInPage };
|