@surea11y/core 1.4.0 → 1.5.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 +88 -7
- package/README.md +19 -3
- package/bin/surea11y-core.js +0 -0
- package/docs/API_STABILITY.md +2 -2
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/ENGINE_OPTIONS.md +14 -10
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +28 -6
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +2 -0
- package/docs/RULE_AUTHORING.md +53 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/docs/TROUBLESHOOTING.md +2 -2
- package/package.json +8 -1
- package/src/checks/automatic/aria-allowed-attr.js +661 -99
- package/src/checks/automatic/aria-allowed-role.js +14 -16
- package/src/checks/automatic/aria-braille-equivalent.js +17 -19
- package/src/checks/automatic/aria-conditional-attr.js +17 -19
- package/src/checks/automatic/aria-deprecated-role.js +122 -41
- package/src/checks/automatic/aria-hidden-body.js +2 -9
- package/src/checks/automatic/aria-hidden-focus.js +99 -18
- package/src/checks/automatic/aria-prohibited-attr.js +54 -55
- package/src/checks/automatic/aria-prohibited-children.js +26 -26
- package/src/checks/automatic/aria-required-attr.js +14 -17
- package/src/checks/automatic/aria-required-children.js +17 -20
- package/src/checks/automatic/aria-required-parent.js +17 -20
- package/src/checks/automatic/aria-roles-valid.js +66 -27
- package/src/checks/automatic/aria-valid-attr-value.js +18 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -17
- package/src/checks/automatic/autocomplete-valid.js +52 -18
- package/src/checks/automatic/avoid-inline-spacing.js +192 -34
- package/src/checks/automatic/binary-control-name-present.js +30 -24
- package/src/checks/automatic/button-name-present.js +73 -45
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +23 -18
- package/src/checks/automatic/css-orientation-lock.js +22 -22
- package/src/checks/automatic/definition-list-children-valid.js +18 -21
- package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
- package/src/checks/automatic/dialog-name-present.js +24 -19
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +45 -37
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +109 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +29 -31
- package/src/checks/automatic/iframe-name-present.js +15 -17
- package/src/checks/automatic/iframe-title-unique.js +18 -23
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +102 -33
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-in-text-block.js +19 -21
- package/src/checks/automatic/link-name-present.js +75 -47
- package/src/checks/automatic/list-children-valid.js +15 -17
- package/src/checks/automatic/listbox-name-present.js +23 -18
- package/src/checks/automatic/listitem-parent-valid.js +14 -17
- package/src/checks/automatic/menuitem-name-present.js +24 -19
- package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
- package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
- package/src/checks/automatic/meter-name-present.js +24 -19
- package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +24 -19
- package/src/checks/automatic/progressbar-name-present.js +27 -22
- package/src/checks/automatic/searchbox-name-present.js +27 -18
- package/src/checks/automatic/server-side-image-map-absent.js +15 -18
- package/src/checks/automatic/slider-name-present.js +26 -19
- package/src/checks/automatic/spinbutton-name-present.js +27 -18
- package/src/checks/automatic/summary-name-present.js +24 -19
- package/src/checks/automatic/tab-name-present.js +24 -19
- package/src/checks/automatic/table-headers-attr-valid.js +15 -17
- package/src/checks/automatic/table-th-has-data-cells.js +82 -25
- package/src/checks/automatic/target-size-minimum.js +104 -81
- package/src/checks/automatic/td-has-header.js +15 -20
- package/src/checks/automatic/textbox-name-present.js +23 -18
- package/src/checks/automatic/tooltip-name-present.js +24 -19
- package/src/checks/automatic/treeitem-name-present.js +24 -19
- package/src/checks/automatic/valid-lang.js +31 -20
- package/src/checks/manual/accesskeys-manual.js +18 -19
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
- package/src/checks/manual/empty-heading-manual.js +15 -17
- package/src/checks/manual/empty-table-header-manual.js +27 -30
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
- package/src/checks/manual/heading-order-manual.js +17 -22
- package/src/checks/manual/image-redundant-alt-manual.js +14 -17
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/label-title-only-manual.js +15 -17
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
- package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
- package/src/checks/manual/landmark-one-main-manual.js +2 -9
- package/src/checks/manual/landmark-unique-manual.js +22 -27
- package/src/checks/manual/link-name-quality-manual.js +15 -17
- package/src/checks/manual/meta-viewport-large-manual.js +14 -17
- package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
- package/src/checks/manual/page-has-heading-one-manual.js +2 -9
- package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
- package/src/checks/manual/region-manual.js +13 -6
- package/src/checks/manual/scope-attr-valid-manual.js +14 -17
- package/src/checks/manual/skip-link-manual.js +39 -47
- package/src/checks/manual/tabindex-manual.js +14 -17
- package/src/checks/manual/table-duplicate-name-manual.js +14 -17
- package/src/core.js +8818 -4330
- package/src/report.js +14 -0
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +4023 -3911
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
|
@@ -156,7 +156,7 @@ function runInPage(ctx) {
|
|
|
156
156
|
for (const r of rules) {
|
|
157
157
|
if (!r) continue;
|
|
158
158
|
if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
|
|
159
|
-
findings.push({ mediaText, selectorText: trim(r.selectorText)
|
|
159
|
+
findings.push({ mediaText, selectorText: trim(r.selectorText) });
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
}
|
|
@@ -196,28 +196,28 @@ function runInPage(ctx) {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
const target = document.documentElement || document.body || null;
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
199
|
+
const occurrences = findings.map((f) =>
|
|
200
|
+
helpers.reportOccurrence(target, {
|
|
201
|
+
summary: f.selectorText
|
|
202
|
+
? `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`
|
|
203
|
+
: `A "${f.mediaText}" media query rotates an element with no readable selector, locking the page to one orientation.`,
|
|
204
|
+
hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
|
|
205
|
+
i18n: {
|
|
206
|
+
summaryKey: f.selectorText
|
|
207
|
+
? 'cssOrientationLock_summary_fail'
|
|
208
|
+
: 'cssOrientationLock_summary_fail_unknownSelector',
|
|
209
|
+
hintKey: 'cssOrientationLock_hint_fail',
|
|
210
|
+
params: { mediaText: f.mediaText, selectorText: f.selectorText }
|
|
211
|
+
},
|
|
212
|
+
data: {
|
|
213
|
+
details: {
|
|
214
|
+
reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
|
|
215
|
+
mediaText: f.mediaText,
|
|
216
|
+
selectorText: f.selectorText
|
|
217
|
+
}
|
|
218
218
|
}
|
|
219
|
-
}
|
|
220
|
-
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
221
|
|
|
222
222
|
return {
|
|
223
223
|
ruleId: rule.ruleId,
|
|
@@ -121,9 +121,6 @@ function runInPage(ctx) {
|
|
|
121
121
|
: null;
|
|
122
122
|
if (!reasonCode) continue;
|
|
123
123
|
|
|
124
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
125
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
126
|
-
|
|
127
124
|
const summary = invalidTags.length
|
|
128
125
|
? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
|
|
129
126
|
: 'This description list has no <dt>/<dd> term-definition group.';
|
|
@@ -131,24 +128,24 @@ function runInPage(ctx) {
|
|
|
131
128
|
? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
|
|
132
129
|
: 'Add at least one <dt>/<dd> pair inside this <dl>.';
|
|
133
130
|
|
|
134
|
-
occurrences.push(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: '
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
131
|
+
occurrences.push(
|
|
132
|
+
helpers.reportOccurrence(el, {
|
|
133
|
+
summary,
|
|
134
|
+
hint,
|
|
135
|
+
i18n: {
|
|
136
|
+
summaryKey: invalidTags.length
|
|
137
|
+
? 'definitionListChildrenValid_summary_fail_invalidChild'
|
|
138
|
+
: 'definitionListChildrenValid_summary_fail_noDtDd',
|
|
139
|
+
hintKey: invalidTags.length
|
|
140
|
+
? 'definitionListChildrenValid_hint_fail_invalidChild'
|
|
141
|
+
: 'definitionListChildrenValid_hint_fail_noDtDd',
|
|
142
|
+
params: { invalidChildren: dedupedInvalidTags.join(', ') }
|
|
143
|
+
},
|
|
144
|
+
data: {
|
|
145
|
+
details: { reasonCode, invalidChildren: invalidTags }
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
);
|
|
152
149
|
}
|
|
153
150
|
|
|
154
151
|
if (applicableCount === 0) {
|
|
@@ -72,23 +72,21 @@ function runInPage(ctx) {
|
|
|
72
72
|
applicableCount += 1;
|
|
73
73
|
|
|
74
74
|
const tag = el.tagName.toLowerCase();
|
|
75
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
76
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
77
75
|
|
|
78
|
-
occurrences.push(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
76
|
+
occurrences.push(
|
|
77
|
+
helpers.reportOccurrence(el, {
|
|
78
|
+
summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
|
|
79
|
+
hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
|
|
80
|
+
i18n: {
|
|
81
|
+
summaryKey: 'deprecatedElements_summary_fail',
|
|
82
|
+
hintKey: 'deprecatedElements_hint_fail',
|
|
83
|
+
params: { element: tag }
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
);
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
if (applicableCount === 0) {
|
|
@@ -74,9 +74,16 @@ function runInPage(ctx) {
|
|
|
74
74
|
return '';
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
78
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
79
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
77
80
|
function isEligibleAcc(helpers, el, ctx) {
|
|
78
81
|
const fn =
|
|
79
|
-
helpers && typeof helpers.
|
|
82
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
83
|
+
? helpers.isIncludedInAccessibilityTree
|
|
84
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
85
|
+
? helpers.isAccTreeEligible
|
|
86
|
+
: null;
|
|
80
87
|
if (!fn) return true;
|
|
81
88
|
try {
|
|
82
89
|
const r = fn(el, ctx);
|
|
@@ -133,24 +140,22 @@ function runInPage(ctx) {
|
|
|
133
140
|
}
|
|
134
141
|
})()
|
|
135
142
|
: null;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
});
|
|
143
|
+
|
|
144
|
+
occurrences.push(
|
|
145
|
+
helpers.reportOccurrence(el, {
|
|
146
|
+
summary: 'This dialog has no accessible name.',
|
|
147
|
+
hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
|
|
148
|
+
i18n: {
|
|
149
|
+
summaryKey: 'dialogNamePresent_summary_fail',
|
|
150
|
+
hintKey: 'dialogNamePresent_hint_fail',
|
|
151
|
+
params: { role }
|
|
152
|
+
},
|
|
153
|
+
data: {
|
|
154
|
+
details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
|
|
155
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
);
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
if (applicableCount === 0) {
|
|
@@ -80,23 +80,21 @@ function runInPage(ctx) {
|
|
|
80
80
|
if (valid) continue;
|
|
81
81
|
|
|
82
82
|
const tag = el.tagName.toLowerCase();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
});
|
|
83
|
+
|
|
84
|
+
occurrences.push(
|
|
85
|
+
helpers.reportOccurrence(el, {
|
|
86
|
+
summary: 'This description-list item is not contained by a <dl>.',
|
|
87
|
+
hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
|
|
88
|
+
i18n: {
|
|
89
|
+
summaryKey: 'dlitemParentValid_summary_fail',
|
|
90
|
+
hintKey: 'dlitemParentValid_hint_fail',
|
|
91
|
+
params: { element: tag, parentElement: parentTag }
|
|
92
|
+
},
|
|
93
|
+
data: {
|
|
94
|
+
details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
);
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
if (applicableCount === 0) {
|
|
@@ -15,15 +15,16 @@
|
|
|
15
15
|
* attribute (i.e. at least one ARIA ID reference exists to resolve).
|
|
16
16
|
* @expectation
|
|
17
17
|
* For every id value referenced by one of those attributes, exactly one
|
|
18
|
-
* element in the document carries that id. A
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* element in the document carries that id. A duplicate does not break the
|
|
19
|
+
* reference: it resolves to the first element in tree order, so the name is
|
|
20
|
+
* still computed. Whether that element is the intended target depends on
|
|
21
|
+
* author intent, which markup does not carry, so the outcome is cantTell.
|
|
22
22
|
* @implementation-notes
|
|
23
23
|
* - Scoped deliberately to ids referenced by ARIA, not the broader/
|
|
24
24
|
* deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
|
|
25
25
|
* - Document-wide by design: id uniqueness and ARIA id references are a
|
|
26
|
-
* whole-document property, not scoped to a sub-root.
|
|
26
|
+
* whole-document property, not scoped to a sub-root. Reported occurrences are
|
|
27
|
+
* limited to the scanned scope.
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
const id = 'duplicate-id-aria';
|
|
@@ -95,51 +96,58 @@ function runInPage(ctx) {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
// Detection is document-wide; occurrences are limited to the scanned scope.
|
|
100
|
+
let inScope = null;
|
|
101
|
+
if (helpers && typeof helpers.queryAllSmart === 'function') {
|
|
102
|
+
try {
|
|
103
|
+
const scoped = helpers.queryAllSmart('[id]');
|
|
104
|
+
inScope = new Set(Array.isArray(scoped) ? scoped : Array.from(scoped || []));
|
|
105
|
+
} catch {
|
|
106
|
+
inScope = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cantTellOccurrences = [];
|
|
99
111
|
|
|
100
112
|
for (const refId of referencedIds) {
|
|
101
113
|
const els = idMap.get(refId) || [];
|
|
102
114
|
if (els.length <= 1) continue;
|
|
103
115
|
|
|
104
116
|
for (const el of els) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
id: refId,
|
|
124
|
-
duplicateCount: els.length
|
|
117
|
+
if (inScope && !inScope.has(el)) continue;
|
|
118
|
+
|
|
119
|
+
cantTellOccurrences.push(
|
|
120
|
+
helpers.reportOccurrence(el, {
|
|
121
|
+
summary:
|
|
122
|
+
'This id is referenced by an ARIA attribute but is used by more than one element; the reference resolves to the first.',
|
|
123
|
+
hint: 'Confirm the first element carrying this id is the intended target, or make the id unique.',
|
|
124
|
+
i18n: {
|
|
125
|
+
summaryKey: 'duplicateIdAria_summary_cantTell',
|
|
126
|
+
hintKey: 'duplicateIdAria_hint_cantTell',
|
|
127
|
+
params: { id: refId, duplicateCount: String(els.length) }
|
|
128
|
+
},
|
|
129
|
+
data: {
|
|
130
|
+
details: {
|
|
131
|
+
reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
|
|
132
|
+
id: refId,
|
|
133
|
+
duplicateCount: els.length
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
|
-
}
|
|
127
|
-
|
|
136
|
+
})
|
|
137
|
+
);
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
if (referencedIds.size === 0) {
|
|
132
142
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
133
143
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
144
|
+
|
|
145
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
146
|
+
[],
|
|
147
|
+
cantTellOccurrences,
|
|
148
|
+
rule.defaultSeverity || 'serious'
|
|
149
|
+
);
|
|
150
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
143
151
|
}
|
|
144
152
|
|
|
145
153
|
module.exports = { id, meta, runInPage };
|
|
@@ -73,8 +73,15 @@ const meta = {
|
|
|
73
73
|
function runInPage(ctx) {
|
|
74
74
|
const { helpers, rule } = ctx;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// ACT e086e5 applies only to controls included in the accessibility tree, so
|
|
77
|
+
// a focusable control inside aria-hidden is out of scope; aria-hidden-focus
|
|
78
|
+
// reports that markup instead.
|
|
79
|
+
const isEligibleHelper =
|
|
80
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
81
|
+
? helpers.isIncludedInAccessibilityTree
|
|
82
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
83
|
+
? helpers.isAccTreeEligible
|
|
84
|
+
: null;
|
|
78
85
|
const getEligibilityInfo =
|
|
79
86
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
80
87
|
|
|
@@ -85,6 +92,41 @@ function runInPage(ctx) {
|
|
|
85
92
|
|
|
86
93
|
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
87
94
|
|
|
95
|
+
// Roles with their own *-name-present rule; a control carrying one
|
|
96
|
+
// explicitly is skipped here so it is reported once.
|
|
97
|
+
const ROLE_OWNED_ELSEWHERE = [
|
|
98
|
+
'alertdialog',
|
|
99
|
+
'button',
|
|
100
|
+
'checkbox',
|
|
101
|
+
'combobox',
|
|
102
|
+
'dialog',
|
|
103
|
+
'grid',
|
|
104
|
+
'link',
|
|
105
|
+
'listbox',
|
|
106
|
+
'menu',
|
|
107
|
+
'menubar',
|
|
108
|
+
'menuitem',
|
|
109
|
+
'menuitemcheckbox',
|
|
110
|
+
'menuitemradio',
|
|
111
|
+
'meter',
|
|
112
|
+
'option',
|
|
113
|
+
'progressbar',
|
|
114
|
+
'radio',
|
|
115
|
+
'radiogroup',
|
|
116
|
+
'scrollbar',
|
|
117
|
+
'searchbox',
|
|
118
|
+
'slider',
|
|
119
|
+
'spinbutton',
|
|
120
|
+
'switch',
|
|
121
|
+
'tab',
|
|
122
|
+
'tablist',
|
|
123
|
+
'textbox',
|
|
124
|
+
'toolbar',
|
|
125
|
+
'tooltip',
|
|
126
|
+
'tree',
|
|
127
|
+
'treeitem'
|
|
128
|
+
];
|
|
129
|
+
|
|
88
130
|
const metrics = {
|
|
89
131
|
applicableCount: 0,
|
|
90
132
|
passCount: 0,
|
|
@@ -108,9 +150,9 @@ function runInPage(ctx) {
|
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
function isEligibleAcc(el) {
|
|
111
|
-
if (!
|
|
153
|
+
if (!isEligibleHelper) return true;
|
|
112
154
|
try {
|
|
113
|
-
const r =
|
|
155
|
+
const r = isEligibleHelper(el, ctx);
|
|
114
156
|
if (typeof r === 'boolean') return r;
|
|
115
157
|
return !!(r && r.eligible);
|
|
116
158
|
} catch {
|
|
@@ -193,6 +235,8 @@ function runInPage(ctx) {
|
|
|
193
235
|
role = '';
|
|
194
236
|
}
|
|
195
237
|
|
|
238
|
+
if (role && ROLE_OWNED_ELSEWHERE.indexOf(role) !== -1) continue;
|
|
239
|
+
|
|
196
240
|
if (role === 'presentation' || role === 'none') {
|
|
197
241
|
const fi = getFocusableInfo
|
|
198
242
|
? (() => {
|
|
@@ -12,20 +12,29 @@
|
|
|
12
12
|
* Applies to labelable form controls (input, excluding
|
|
13
13
|
* hidden/submit/reset/button/image; select; textarea).
|
|
14
14
|
* @expectation
|
|
15
|
-
* At most one <label>
|
|
16
|
-
* wrapping it, or by a <label for="...">
|
|
17
|
-
* (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* At most one <label> that can contribute to the control's accessible name
|
|
16
|
+
* is associated with it — by wrapping it, or by a <label for="..."> on its
|
|
17
|
+
* id (a label that both wraps and self-references via for counts once).
|
|
18
|
+
* Graded by whether the surplus labels actually compete for the name:
|
|
19
|
+
* - PASS when an override (aria-labelledby / aria-label) supersedes every
|
|
20
|
+
* native <label>: the labels then contribute nothing to the name, so
|
|
21
|
+
* they cannot be ambiguous. A visible-label-vs-name mismatch is SC 2.5.3
|
|
22
|
+
* Label in Name's concern, not this rule's.
|
|
23
|
+
* - FAIL when two or more non-empty labels compete and there is no
|
|
24
|
+
* override: screen readers announce a non-deterministic subset.
|
|
25
|
+
* - CANTTELL when one non-empty label is joined by empty label
|
|
26
|
+
* association(s) and there is no override: the name usually resolves to
|
|
27
|
+
* the real label, but handling of the empty association is not
|
|
28
|
+
* guaranteed across user agents.
|
|
29
|
+
* All-empty associations with no override are a missing-name case (the
|
|
30
|
+
* sibling rule below), not an ambiguity, so this rule stays silent.
|
|
21
31
|
* @implementation-notes
|
|
22
32
|
* - Distinct, atomic decision from form-control-programmatic-label-present
|
|
23
|
-
* (that rule checks a label exists at all; this one checks
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* rule exists to catch.
|
|
33
|
+
* (that rule checks a label exists at all; this one checks at most one
|
|
34
|
+
* matters). Whether a label contributes a name is decided by the shared
|
|
35
|
+
* helpers.labelContributesAccessibleName, so the two rules agree.
|
|
36
|
+
* - Accessibility-tree-ineligible labels (display:none, aria-hidden, etc.)
|
|
37
|
+
* are excluded before counting: they can't contribute to the name.
|
|
29
38
|
*/
|
|
30
39
|
|
|
31
40
|
const id = 'form-control-single-label';
|
|
@@ -62,6 +71,12 @@ function runInPage(ctx) {
|
|
|
62
71
|
|
|
63
72
|
const isAccTreeEligible =
|
|
64
73
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
74
|
+
const getAriaNameInfo =
|
|
75
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
76
|
+
const labelContributesName =
|
|
77
|
+
helpers && typeof helpers.labelContributesAccessibleName === 'function'
|
|
78
|
+
? helpers.labelContributesAccessibleName
|
|
79
|
+
: null;
|
|
65
80
|
|
|
66
81
|
const selector =
|
|
67
82
|
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
@@ -82,7 +97,8 @@ function runInPage(ctx) {
|
|
|
82
97
|
labelsByFor.get(forValue).push(lab);
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
const
|
|
100
|
+
const failOccurrences = [];
|
|
101
|
+
const cantTellOccurrences = [];
|
|
86
102
|
let applicableCount = 0;
|
|
87
103
|
|
|
88
104
|
for (const el of nodes) {
|
|
@@ -111,42 +127,92 @@ function runInPage(ctx) {
|
|
|
111
127
|
|
|
112
128
|
if (eligibleLabels.size <= 1) continue;
|
|
113
129
|
|
|
130
|
+
// An override (aria-labelledby / aria-label) supersedes every native
|
|
131
|
+
// <label>, so the labels contribute nothing to the accessible name and
|
|
132
|
+
// cannot compete.
|
|
133
|
+
const override = getAriaNameInfo ? getAriaNameInfo(el, ctx) : null;
|
|
134
|
+
if (override && override.present && override.value) continue;
|
|
135
|
+
|
|
136
|
+
// Without an override the labels feed the name; only labels with their
|
|
137
|
+
// own text compete for it.
|
|
138
|
+
const contributing = labelContributesName
|
|
139
|
+
? [...eligibleLabels].filter((lab) => labelContributesName(lab))
|
|
140
|
+
: [...eligibleLabels];
|
|
141
|
+
|
|
114
142
|
const tag = el.tagName.toLowerCase();
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
|
|
144
|
+
if (contributing.length >= 2) {
|
|
145
|
+
failOccurrences.push(
|
|
146
|
+
helpers.reportOccurrence(el, {
|
|
147
|
+
summary: 'This form control is associated with more than one <label>.',
|
|
148
|
+
hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
|
|
149
|
+
occurrenceOutcome: 'fail',
|
|
150
|
+
i18n: {
|
|
151
|
+
summaryKey: 'formControlSingleLabel_summary_fail',
|
|
152
|
+
hintKey: 'formControlSingleLabel_hint_fail',
|
|
153
|
+
params: { element: tag, labelCount: String(contributing.length) }
|
|
154
|
+
},
|
|
155
|
+
data: {
|
|
156
|
+
details: {
|
|
157
|
+
reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
|
|
158
|
+
element: tag,
|
|
159
|
+
labelCount: contributing.length
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
} else if (contributing.length === 1) {
|
|
165
|
+
cantTellOccurrences.push(
|
|
166
|
+
helpers.reportOccurrence(el, {
|
|
167
|
+
summary:
|
|
168
|
+
'This form control has one labelling <label> plus an extra empty <label> association.',
|
|
169
|
+
hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
|
|
170
|
+
occurrenceOutcome: 'cantTell',
|
|
171
|
+
i18n: {
|
|
172
|
+
summaryKey: 'formControlSingleLabel_summary_cantTell',
|
|
173
|
+
hintKey: 'formControlSingleLabel_hint_cantTell',
|
|
174
|
+
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
175
|
+
},
|
|
176
|
+
data: {
|
|
177
|
+
details: {
|
|
178
|
+
reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
|
|
179
|
+
element: tag,
|
|
180
|
+
labelCount: eligibleLabels.size,
|
|
181
|
+
contributingLabelCount: contributing.length
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
// contributing.length === 0: no label carries text, so nothing competes
|
|
188
|
+
// for the name. A control left unnamed is form-control-programmatic-
|
|
189
|
+
// label-present's concern.
|
|
136
190
|
}
|
|
137
191
|
|
|
138
192
|
if (applicableCount === 0) {
|
|
139
193
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
140
194
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
195
|
+
|
|
196
|
+
const resolved = helpers.resolveTieredOutcome
|
|
197
|
+
? helpers.resolveTieredOutcome(
|
|
198
|
+
failOccurrences,
|
|
199
|
+
cantTellOccurrences,
|
|
200
|
+
rule.defaultSeverity || 'moderate'
|
|
201
|
+
)
|
|
202
|
+
: failOccurrences.length
|
|
203
|
+
? {
|
|
204
|
+
outcome: 'fail',
|
|
205
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
206
|
+
occurrences: failOccurrences
|
|
207
|
+
}
|
|
208
|
+
: cantTellOccurrences.length
|
|
209
|
+
? {
|
|
210
|
+
outcome: 'cantTell',
|
|
211
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
212
|
+
occurrences: cantTellOccurrences
|
|
213
|
+
}
|
|
214
|
+
: { outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
215
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
150
216
|
}
|
|
151
217
|
|
|
152
218
|
module.exports = { id, meta, runInPage };
|