@surea11y/core 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check form-control-label-quality
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary A form field's visible label text should describe that field, not repeat another one
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.4.6
|
|
11
|
+
* @applicability
|
|
12
|
+
* Visible form fields: native `input` (excluding hidden and the
|
|
13
|
+
* button-like types), `select`, `textarea`, or an element with one of
|
|
14
|
+
* the ARIA widget roles ACT cc0f0a lists (checkbox, combobox, listbox,
|
|
15
|
+
* menuitemcheckbox, menuitemradio, radio, searchbox, slider,
|
|
16
|
+
* spinbutton, switch, textbox) that carry a visible programmatic
|
|
17
|
+
* label: a `<label>` association, or the elements `aria-labelledby`
|
|
18
|
+
* points at. A field named only by `aria-label`/`title` has no visible
|
|
19
|
+
* label to judge and is out of scope here (its labelling mechanism is
|
|
20
|
+
* `form-control-programmatic-label-quality`'s concern, its presence
|
|
21
|
+
* `form-control-programmatic-label-present`'s).
|
|
22
|
+
* @expectation
|
|
23
|
+
* The visible label text (a) is not a placeholder left in the markup
|
|
24
|
+
* ("label", "field", "enter text", ...), (b) is not shared with another
|
|
25
|
+
* field that no visible context tells apart (the same "Name" twice,
|
|
26
|
+
* with nothing visible on screen saying which is shipping and which is
|
|
27
|
+
* billing), and (c) is the whole of the field's programmatic label, not
|
|
28
|
+
* the visible fragment of a label whose descriptive part is hidden.
|
|
29
|
+
* @implementation-notes
|
|
30
|
+
* - Authored as `type: 'manual'` (cantTell-capped, never fail). Whether a
|
|
31
|
+
* label describes its field is a reading judgment: ACT cc0f0a fails
|
|
32
|
+
* `<label>Menu<input type="text" name="fname"></label>` on the meaning
|
|
33
|
+
* of the word alone, which no markup-level check can reach. What is
|
|
34
|
+
* deterministic is a placeholder string, a label repeated with no
|
|
35
|
+
* visible differentiator, and a label split between visible and hidden
|
|
36
|
+
* parts: the three shapes this rule reports.
|
|
37
|
+
* - Only PROGRAMMATIC labels count, per ACT: a `<label>` or the targets
|
|
38
|
+
* of `aria-labelledby`. `aria-label` is invisible text, so it can carry
|
|
39
|
+
* no visual context and is not what a sighted user reads.
|
|
40
|
+
* - Visibility is ACT's, not the accessibility tree's: a label positioned
|
|
41
|
+
* off screen or clipped is programmatically fine and visually absent,
|
|
42
|
+
* which is exactly what makes ACT's failed example 4 (`<h2>` at
|
|
43
|
+
* `top: -9999px`) a failure rather than a pass. Hence
|
|
44
|
+
* `isDomVisibleEligible` for rendering plus the shared visibility
|
|
45
|
+
* hints for the off-screen/clipped/transparent patterns.
|
|
46
|
+
* - The context a duplicate label is judged against is the visible
|
|
47
|
+
* context nearest the field: its `<fieldset>`'s visible `<legend>`, or
|
|
48
|
+
* failing that the nearest visible heading before it. A row of a table
|
|
49
|
+
* or a list item contributes its own text too, which is what keeps a
|
|
50
|
+
* repeated "Quantity" field in a product table (differentiated by the
|
|
51
|
+
* product name in the same row) from being reported.
|
|
52
|
+
* - Two fields conflict only when their label text AND their context are
|
|
53
|
+
* both identical. Same label under two different visible headings is
|
|
54
|
+
* ACT's passed example 5 and is not reported.
|
|
55
|
+
* - A label split across visible and hidden parts is reported on its own:
|
|
56
|
+
* `aria-labelledby="submit search"` where "Search" is `display: none`
|
|
57
|
+
* and only the "Go" button renders leaves a sighted user reading a
|
|
58
|
+
* different label than a screen reader announces. ACT's failed example
|
|
59
|
+
* 5 is exactly that. The hidden part may well be a deliberate
|
|
60
|
+
* AT-only addition, which is why this is a review signal rather than a
|
|
61
|
+
* defect.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
const id = 'form-control-label-quality';
|
|
65
|
+
|
|
66
|
+
const meta = {
|
|
67
|
+
title: 'Form field labels should be descriptive and distinguishable',
|
|
68
|
+
description:
|
|
69
|
+
'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart.',
|
|
70
|
+
i18n: {
|
|
71
|
+
titleKey: 'formControlLabelQuality_title',
|
|
72
|
+
descriptionKey: 'formControlLabelQuality_description'
|
|
73
|
+
},
|
|
74
|
+
helpUrl: null,
|
|
75
|
+
tags: ['wcag2aa', 'wcag246', 'forms', 'labels', 'quality', 'atomic', 'manual'],
|
|
76
|
+
wcagSc: ['2.4.6'],
|
|
77
|
+
normativeMappings: [
|
|
78
|
+
{
|
|
79
|
+
standard: 'WCAG',
|
|
80
|
+
version: '2.2',
|
|
81
|
+
requirement: '2.4.6',
|
|
82
|
+
title: 'Headings and Labels',
|
|
83
|
+
conformanceLevel: 'AA'
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
defaultSeverity: 'minor',
|
|
87
|
+
category: 'operable',
|
|
88
|
+
type: 'manual',
|
|
89
|
+
defaultConfidence: 'medium',
|
|
90
|
+
coverage: { facetsBySc: { '2.4.6': ['form-control-label-descriptive-evidence'] } }
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function runInPage(ctx) {
|
|
94
|
+
const { document, helpers, rule } = ctx;
|
|
95
|
+
|
|
96
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
97
|
+
// ("runInPage MUST be self-contained").
|
|
98
|
+
const PLACEHOLDER_LABEL_TEXT = new Set([
|
|
99
|
+
'label',
|
|
100
|
+
'field',
|
|
101
|
+
'form field',
|
|
102
|
+
'input',
|
|
103
|
+
'input field',
|
|
104
|
+
'text',
|
|
105
|
+
'text field',
|
|
106
|
+
'enter text',
|
|
107
|
+
'type here',
|
|
108
|
+
'value',
|
|
109
|
+
'placeholder',
|
|
110
|
+
'untitled',
|
|
111
|
+
'tbd',
|
|
112
|
+
'todo',
|
|
113
|
+
'to do',
|
|
114
|
+
'n/a',
|
|
115
|
+
'test',
|
|
116
|
+
'example',
|
|
117
|
+
'default'
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
const FIELD_SELECTOR = [
|
|
121
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"])',
|
|
122
|
+
'select',
|
|
123
|
+
'textarea',
|
|
124
|
+
'[role="checkbox"]',
|
|
125
|
+
'[role="combobox"]',
|
|
126
|
+
'[role="listbox"]',
|
|
127
|
+
'[role="menuitemcheckbox"]',
|
|
128
|
+
'[role="menuitemradio"]',
|
|
129
|
+
'[role="radio"]',
|
|
130
|
+
'[role="searchbox"]',
|
|
131
|
+
'[role="slider"]',
|
|
132
|
+
'[role="spinbutton"]',
|
|
133
|
+
'[role="switch"]',
|
|
134
|
+
'[role="textbox"]'
|
|
135
|
+
].join(', ');
|
|
136
|
+
|
|
137
|
+
const ROW_SELECTOR = 'tr, [role="row"], li, [role="listitem"]';
|
|
138
|
+
const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
|
|
139
|
+
|
|
140
|
+
function normalizeWs(s) {
|
|
141
|
+
return String(s || '')
|
|
142
|
+
.replace(/\s+/g, ' ')
|
|
143
|
+
.trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalize(s) {
|
|
147
|
+
return normalizeWs(s)
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.replace(/[.,;:!?*]+$/g, '')
|
|
150
|
+
.trim();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const isDomVisibleEligible =
|
|
154
|
+
helpers && typeof helpers.isDomVisibleEligible === 'function'
|
|
155
|
+
? helpers.isDomVisibleEligible
|
|
156
|
+
: null;
|
|
157
|
+
const getVisibilityHintsInfo =
|
|
158
|
+
helpers && typeof helpers.getVisibilityHintsInfo === 'function'
|
|
159
|
+
? helpers.getVisibilityHintsInfo
|
|
160
|
+
: null;
|
|
161
|
+
|
|
162
|
+
const HIDING_HINTS = ['offscreen', 'clipped', 'opacityZero', 'zeroSize'];
|
|
163
|
+
|
|
164
|
+
// ACT's "visible": rendered, and not hidden by one of the visually-hidden
|
|
165
|
+
// CSS patterns. A label the user cannot read provides no visual context,
|
|
166
|
+
// however well it is wired up programmatically.
|
|
167
|
+
function isVisible(el) {
|
|
168
|
+
if (!el) return false;
|
|
169
|
+
if (isDomVisibleEligible) {
|
|
170
|
+
try {
|
|
171
|
+
const vis = isDomVisibleEligible(el, ctx, {
|
|
172
|
+
visibilityMode: 'styleOnly',
|
|
173
|
+
disableGeometry: true
|
|
174
|
+
});
|
|
175
|
+
if (vis && vis.eligible === false) return false;
|
|
176
|
+
} catch {
|
|
177
|
+
// treat as rendered
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (getVisibilityHintsInfo) {
|
|
181
|
+
try {
|
|
182
|
+
const info = getVisibilityHintsInfo(el, ctx);
|
|
183
|
+
const hints = (info && info.hints) || [];
|
|
184
|
+
for (const hint of HIDING_HINTS) {
|
|
185
|
+
if (hints.indexOf(hint) !== -1) return false;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// treat as visible
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveIdRefs(el, attr) {
|
|
195
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute(attr));
|
|
196
|
+
if (!raw) return [];
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const refId of raw.split(/\s+/).filter(Boolean)) {
|
|
199
|
+
try {
|
|
200
|
+
const ref = document.getElementById(refId);
|
|
201
|
+
if (ref) out.push(ref);
|
|
202
|
+
} catch {
|
|
203
|
+
// ignore an unusable reference
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getNativeLabels(el) {
|
|
210
|
+
const labels = [];
|
|
211
|
+
try {
|
|
212
|
+
if (el.labels && el.labels.length) {
|
|
213
|
+
for (const label of el.labels) labels.push(label);
|
|
214
|
+
return labels;
|
|
215
|
+
}
|
|
216
|
+
} catch {
|
|
217
|
+
// fall through to the manual lookup
|
|
218
|
+
}
|
|
219
|
+
const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
|
|
220
|
+
if (idVal) {
|
|
221
|
+
try {
|
|
222
|
+
for (const label of document.querySelectorAll('label[for]')) {
|
|
223
|
+
if (normalizeWs(label.getAttribute('for')) === idVal) labels.push(label);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const wrapping = el.closest ? el.closest('label') : null;
|
|
231
|
+
if (wrapping && labels.indexOf(wrapping) === -1) labels.push(wrapping);
|
|
232
|
+
} catch {
|
|
233
|
+
// ignore
|
|
234
|
+
}
|
|
235
|
+
return labels;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The programmatic labels of a field, per ACT: aria-labelledby targets when
|
|
239
|
+
// present, otherwise the <label> elements associated with it. aria-label is
|
|
240
|
+
// left out on purpose; see the header comment.
|
|
241
|
+
function getVisibleLabelText(el) {
|
|
242
|
+
const referenced = resolveIdRefs(el, 'aria-labelledby');
|
|
243
|
+
const labels = referenced.length ? referenced : getNativeLabels(el);
|
|
244
|
+
const parts = [];
|
|
245
|
+
let hiddenParts = 0;
|
|
246
|
+
for (const label of labels) {
|
|
247
|
+
const text = normalizeWs(label.textContent);
|
|
248
|
+
if (!text) continue;
|
|
249
|
+
if (isVisible(label)) parts.push(text);
|
|
250
|
+
else hiddenParts += 1;
|
|
251
|
+
}
|
|
252
|
+
return { text: normalizeWs(parts.join(' ')), hiddenParts };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const headings = (() => {
|
|
256
|
+
try {
|
|
257
|
+
return Array.prototype.slice.call(document.querySelectorAll(HEADING_SELECTOR));
|
|
258
|
+
} catch {
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
|
|
263
|
+
function precedes(a, b) {
|
|
264
|
+
try {
|
|
265
|
+
// DOCUMENT_POSITION_PRECEDING (2) on b relative to a.
|
|
266
|
+
return !!(b.compareDocumentPosition(a) & 2);
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function nearestVisibleHeadingText(el) {
|
|
273
|
+
for (let i = headings.length - 1; i >= 0; i--) {
|
|
274
|
+
const heading = headings[i];
|
|
275
|
+
if (!precedes(heading, el)) continue;
|
|
276
|
+
if (!isVisible(heading)) continue;
|
|
277
|
+
const text = normalizeWs(heading.textContent);
|
|
278
|
+
if (text) return text;
|
|
279
|
+
}
|
|
280
|
+
return '';
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function fieldsetLegendText(el) {
|
|
284
|
+
let fieldset;
|
|
285
|
+
try {
|
|
286
|
+
fieldset = el.closest ? el.closest('fieldset') : null;
|
|
287
|
+
} catch {
|
|
288
|
+
fieldset = null;
|
|
289
|
+
}
|
|
290
|
+
while (fieldset) {
|
|
291
|
+
let legend;
|
|
292
|
+
try {
|
|
293
|
+
legend = fieldset.querySelector('legend');
|
|
294
|
+
} catch {
|
|
295
|
+
legend = null;
|
|
296
|
+
}
|
|
297
|
+
if (legend && isVisible(legend)) {
|
|
298
|
+
const text = normalizeWs(legend.textContent);
|
|
299
|
+
if (text) return text;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
fieldset = fieldset.parentElement ? fieldset.parentElement.closest('fieldset') : null;
|
|
303
|
+
} catch {
|
|
304
|
+
fieldset = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// A table row or list item carries its own context (the product name a
|
|
311
|
+
// repeated "Quantity" field belongs to), so it takes part in the key.
|
|
312
|
+
function rowContextText(el, labelText) {
|
|
313
|
+
let row;
|
|
314
|
+
try {
|
|
315
|
+
row = el.closest ? el.closest(ROW_SELECTOR) : null;
|
|
316
|
+
} catch {
|
|
317
|
+
row = null;
|
|
318
|
+
}
|
|
319
|
+
if (!row || !isVisible(row)) return '';
|
|
320
|
+
const text = normalizeWs(row.textContent);
|
|
321
|
+
if (!text) return '';
|
|
322
|
+
return normalizeWs(text.split(labelText).join(' '));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function contextKey(el, labelText) {
|
|
326
|
+
const group = fieldsetLegendText(el) || nearestVisibleHeadingText(el);
|
|
327
|
+
return `${normalize(group)}##${normalize(rowContextText(el, labelText))}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const nodes = helpers.queryAllSmart
|
|
331
|
+
? helpers.queryAllSmart(FIELD_SELECTOR)
|
|
332
|
+
: helpers.queryAll(FIELD_SELECTOR);
|
|
333
|
+
|
|
334
|
+
const fields = [];
|
|
335
|
+
for (const el of nodes) {
|
|
336
|
+
if (!el || el.nodeType !== 1) continue;
|
|
337
|
+
if (!isVisible(el)) continue;
|
|
338
|
+
|
|
339
|
+
const label = getVisibleLabelText(el);
|
|
340
|
+
const labelText = label.text;
|
|
341
|
+
if (!labelText) continue; // no visible label to judge, a different rule's concern
|
|
342
|
+
|
|
343
|
+
fields.push({
|
|
344
|
+
el,
|
|
345
|
+
labelText,
|
|
346
|
+
normalized: normalize(labelText),
|
|
347
|
+
hiddenParts: label.hiddenParts
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!fields.length) {
|
|
352
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Group by label text plus the visible context that would tell two
|
|
356
|
+
// same-named fields apart.
|
|
357
|
+
const byKey = new Map();
|
|
358
|
+
for (const field of fields) {
|
|
359
|
+
field.key = `${field.normalized}||${contextKey(field.el, field.labelText)}`;
|
|
360
|
+
const bucket = byKey.get(field.key);
|
|
361
|
+
if (bucket) bucket.push(field);
|
|
362
|
+
else byKey.set(field.key, [field]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const occurrences = [];
|
|
366
|
+
|
|
367
|
+
for (const field of fields) {
|
|
368
|
+
const isPlaceholder = PLACEHOLDER_LABEL_TEXT.has(field.normalized);
|
|
369
|
+
const shared = byKey.get(field.key) || [];
|
|
370
|
+
const isDuplicate = shared.length > 1;
|
|
371
|
+
const isPartiallyHidden = field.hiddenParts > 0;
|
|
372
|
+
|
|
373
|
+
if (!isPlaceholder && !isDuplicate && !isPartiallyHidden) continue;
|
|
374
|
+
|
|
375
|
+
const reasonCode = isPlaceholder
|
|
376
|
+
? 'PLACEHOLDER_LABEL_TEXT'
|
|
377
|
+
: isDuplicate
|
|
378
|
+
? 'DUPLICATE_LABEL_TEXT'
|
|
379
|
+
: 'PARTIALLY_HIDDEN_LABEL';
|
|
380
|
+
|
|
381
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
382
|
+
? (() => {
|
|
383
|
+
try {
|
|
384
|
+
return helpers.getEligibilityInfo(field.el, ctx, { targetSet: 'acc' });
|
|
385
|
+
} catch {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
})()
|
|
389
|
+
: null;
|
|
390
|
+
|
|
391
|
+
const summaryByReason = {
|
|
392
|
+
PLACEHOLDER_LABEL_TEXT: `This field's visible label ("${field.labelText}") is a placeholder rather than a description of what the field is for.`,
|
|
393
|
+
DUPLICATE_LABEL_TEXT: `This field's visible label ("${field.labelText}") is shared with ${shared.length - 1} other field(s), with no visible heading, legend or row text telling them apart.`,
|
|
394
|
+
PARTIALLY_HIDDEN_LABEL: `This field's label is split: "${field.labelText}" is what renders, while ${field.hiddenParts} other part(s) of the label are hidden from sight.`
|
|
395
|
+
};
|
|
396
|
+
const hintByReason = {
|
|
397
|
+
PLACEHOLDER_LABEL_TEXT:
|
|
398
|
+
'Replace the label with one naming the information the field collects.',
|
|
399
|
+
DUPLICATE_LABEL_TEXT:
|
|
400
|
+
'Give each field a label of its own, or put the distinguishing context on screen: a visible heading or a fieldset legend above each group.',
|
|
401
|
+
PARTIALLY_HIDDEN_LABEL:
|
|
402
|
+
'Confirm the visible part alone identifies the field, or make the rest of the label visible.'
|
|
403
|
+
};
|
|
404
|
+
const SUMMARY_KEY_BY_REASON = {
|
|
405
|
+
PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_placeholder',
|
|
406
|
+
DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_summary_cantTell_duplicate',
|
|
407
|
+
PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_summary_cantTell_partiallyHidden'
|
|
408
|
+
};
|
|
409
|
+
const HINT_KEY_BY_REASON = {
|
|
410
|
+
PLACEHOLDER_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_placeholder',
|
|
411
|
+
DUPLICATE_LABEL_TEXT: 'formControlLabelQuality_hint_cantTell_duplicate',
|
|
412
|
+
PARTIALLY_HIDDEN_LABEL: 'formControlLabelQuality_hint_cantTell_partiallyHidden'
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
occurrences.push(
|
|
416
|
+
helpers.reportOccurrence(field.el, {
|
|
417
|
+
summary: summaryByReason[reasonCode],
|
|
418
|
+
hint: hintByReason[reasonCode],
|
|
419
|
+
i18n: {
|
|
420
|
+
summaryKey: SUMMARY_KEY_BY_REASON[reasonCode],
|
|
421
|
+
hintKey: HINT_KEY_BY_REASON[reasonCode],
|
|
422
|
+
params: {
|
|
423
|
+
label: field.labelText,
|
|
424
|
+
count: String(shared.length - 1),
|
|
425
|
+
hiddenCount: String(field.hiddenParts)
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
data: {
|
|
429
|
+
details: {
|
|
430
|
+
reasonCode,
|
|
431
|
+
label: field.labelText,
|
|
432
|
+
sharedWith: isDuplicate ? shared.length - 1 : 0,
|
|
433
|
+
hiddenLabelParts: field.hiddenParts
|
|
434
|
+
},
|
|
435
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (occurrences.length) {
|
|
442
|
+
return {
|
|
443
|
+
ruleId: rule.ruleId,
|
|
444
|
+
outcome: 'cantTell',
|
|
445
|
+
severity: rule.defaultSeverity || 'minor',
|
|
446
|
+
occurrences
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -107,7 +107,7 @@ function runInPage(ctx) {
|
|
|
107
107
|
|
|
108
108
|
// getLabelMethod is provided by the shared dom-helpers bundle that
|
|
109
109
|
// dom-runner.js always constructs for every rule execution (built-in or
|
|
110
|
-
// custom)
|
|
110
|
+
// custom); see createDomHelpers's own getLabelMethod, which implements
|
|
111
111
|
// this exact <label>/aria-labelledby/aria-label/title/placeholder
|
|
112
112
|
// priority order. No local reimplementation is needed as a fallback.
|
|
113
113
|
function getLabelMethodSafe(el) {
|
|
@@ -185,20 +185,13 @@ function runInPage(ctx) {
|
|
|
185
185
|
|
|
186
186
|
const reasonCode =
|
|
187
187
|
method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
|
|
188
|
-
const methodLabel =
|
|
189
|
-
method === 'title'
|
|
190
|
-
? 'title'
|
|
191
|
-
: method === 'placeholder'
|
|
192
|
-
? 'placeholder'
|
|
193
|
-
: 'title or placeholder';
|
|
194
|
-
|
|
195
188
|
const baseOccurrence = {
|
|
196
|
-
summary:
|
|
189
|
+
summary: `Form control’s primary label is derived from ${method}.`,
|
|
197
190
|
hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
|
|
198
191
|
i18n: {
|
|
199
192
|
summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
|
|
200
193
|
hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
|
|
201
|
-
params: { element: (el.tagName || '').toLowerCase(), method
|
|
194
|
+
params: { element: (el.tagName || '').toLowerCase(), method }
|
|
202
195
|
},
|
|
203
196
|
data: {
|
|
204
197
|
visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
@@ -239,7 +232,7 @@ function runInPage(ctx) {
|
|
|
239
232
|
};
|
|
240
233
|
}
|
|
241
234
|
|
|
242
|
-
// Manual rules may only emit cantTell/notApplicable (never pass/fail)
|
|
235
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|
|
243
236
|
// no applicable control relied on a weak (title/placeholder) primary
|
|
244
237
|
// label, so there is nothing to flag for review.
|
|
245
238
|
return {
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* @check heading-order
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Heading levels must not skip a level going deeper
|
|
9
|
-
* @standard Best Practices (no formal WCAG Success Criterion
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
11
|
* Applies whenever the page contains two or more heading elements
|
|
12
|
-
* (native <h1>-<h6>, or explicit role="heading" with aria-level
|
|
12
|
+
* (native <h1>-<h6>, or explicit role="heading" with aria-level;
|
|
13
13
|
* default level 2 per the ARIA spec when aria-level is absent/invalid).
|
|
14
14
|
* @expectation
|
|
15
15
|
* In document order, each heading's level is no more than one greater
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* when navigating by heading. Going back to a shallower level at any
|
|
20
20
|
* point is always fine.
|
|
21
21
|
* @implementation-notes
|
|
22
|
-
* - Not WCAG-normative
|
|
22
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
23
23
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
24
24
|
* header comment for the shared rationale/precedent.
|
|
25
25
|
* - Tracks the highest level reached so far (not just the immediately
|
|
@@ -125,29 +125,24 @@ function runInPage(ctx) {
|
|
|
125
125
|
const { el, level } = headings[i];
|
|
126
126
|
|
|
127
127
|
if (level > highestSoFar + 1) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
data: {
|
|
144
|
-
details: {
|
|
145
|
-
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
146
|
-
fromLevel: highestSoFar,
|
|
147
|
-
toLevel: level
|
|
128
|
+
occurrences.push(
|
|
129
|
+
helpers.reportOccurrence(el, {
|
|
130
|
+
summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
|
|
131
|
+
hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
|
|
132
|
+
i18n: {
|
|
133
|
+
summaryKey: 'headingOrder_summary_cantTell',
|
|
134
|
+
hintKey: 'headingOrder_hint_cantTell',
|
|
135
|
+
params: { fromLevel: String(highestSoFar), toLevel: String(level) }
|
|
136
|
+
},
|
|
137
|
+
data: {
|
|
138
|
+
details: {
|
|
139
|
+
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
140
|
+
fromLevel: highestSoFar,
|
|
141
|
+
toLevel: level
|
|
142
|
+
}
|
|
148
143
|
}
|
|
149
|
-
}
|
|
150
|
-
|
|
144
|
+
})
|
|
145
|
+
);
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
if (level > highestSoFar) highestSoFar = level;
|