@surea11y/core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -25,267 +27,228 @@
|
|
|
25
27
|
const id = 'form-control-programmatic-label-quality';
|
|
26
28
|
|
|
27
29
|
const meta = {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
title: 'Form controls should not rely on placeholder or title as the primary label',
|
|
31
|
+
description:
|
|
32
|
+
'Flags form controls whose computed accessible name relies on placeholder or title as the primary labeling method. Prefer <label> or aria-labelledby.',
|
|
33
|
+
i18n: {
|
|
34
|
+
titleKey: 'formControl_programmaticLabelQuality_title',
|
|
35
|
+
descriptionKey: 'formControl_programmaticLabelQuality_description'
|
|
36
|
+
},
|
|
37
|
+
helpUrl: null,
|
|
38
|
+
tags: ['wcag2a', 'wcag412', 'forms', 'labels', 'quality', 'atomic', 'manual'],
|
|
39
|
+
wcagSc: ['4.1.2'],
|
|
40
|
+
normativeMappings: [
|
|
41
|
+
{
|
|
42
|
+
standard: 'WCAG',
|
|
43
|
+
version: '2.2',
|
|
44
|
+
requirement: '4.1.2',
|
|
45
|
+
title: 'Name, Role, Value',
|
|
46
|
+
conformanceLevel: 'A'
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
defaultSeverity: 'moderate',
|
|
50
|
+
category: 'robust',
|
|
51
|
+
type: 'manual',
|
|
52
|
+
defaultConfidence: 'medium',
|
|
53
|
+
coverage: {
|
|
54
|
+
facetsBySc: {
|
|
55
|
+
'4.1.2': ['form-control-name-quality']
|
|
49
56
|
}
|
|
57
|
+
}
|
|
50
58
|
};
|
|
51
59
|
|
|
52
60
|
function runInPage(ctx) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function isEligibleAcc(el) {
|
|
88
|
-
if (!isAccTreeEligible) return true;
|
|
89
|
-
try {
|
|
90
|
-
const r = isAccTreeEligible(el, ctx);
|
|
91
|
-
if (typeof r === 'boolean') return r;
|
|
92
|
-
return !!(r && r.eligible);
|
|
93
|
-
} catch {
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isFocusable(el) {
|
|
99
|
-
if (!getFocusableInfo) return false;
|
|
100
|
-
try {
|
|
101
|
-
const fi = getFocusableInfo(el, ctx);
|
|
102
|
-
return !!(fi && fi.focusable);
|
|
103
|
-
} catch {
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getNonEmptyAttr(el, name) {
|
|
109
|
-
if (!getAttributeInfo) return '';
|
|
110
|
-
try {
|
|
111
|
-
const info = getAttributeInfo(el, name);
|
|
112
|
-
return info && info.present ? trim(info.value) : '';
|
|
113
|
-
} catch {
|
|
114
|
-
return '';
|
|
115
|
-
}
|
|
61
|
+
const { document, root, helpers, rule } = ctx;
|
|
62
|
+
const safeRoot = root || document;
|
|
63
|
+
|
|
64
|
+
const queryAllSmart =
|
|
65
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
66
|
+
|
|
67
|
+
const isAccTreeEligible =
|
|
68
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
69
|
+
const getEligibilityInfo =
|
|
70
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
71
|
+
|
|
72
|
+
const getFocusableInfo =
|
|
73
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
74
|
+
|
|
75
|
+
const getLabelMethod =
|
|
76
|
+
helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
77
|
+
|
|
78
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
79
|
+
|
|
80
|
+
const metrics = {
|
|
81
|
+
applicableCount: 0,
|
|
82
|
+
flaggedCount: 0,
|
|
83
|
+
byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function safeQueryAll(sel) {
|
|
87
|
+
try {
|
|
88
|
+
if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
|
|
89
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
90
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
91
|
+
: [];
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
116
94
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const wrap = el.closest('label');
|
|
128
|
-
if (wrap) return true;
|
|
129
|
-
}
|
|
130
|
-
} catch {}
|
|
131
|
-
|
|
132
|
-
// 3) <label for="id">
|
|
133
|
-
try {
|
|
134
|
-
const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
|
|
135
|
-
if (!idAttr || !document || !document.querySelector) return false;
|
|
136
|
-
|
|
137
|
-
const esc = idAttr.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
138
|
-
const sel = `label[for="${esc}"]`;
|
|
139
|
-
return !!document.querySelector(sel);
|
|
140
|
-
} catch {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isEligibleAcc(el) {
|
|
98
|
+
if (!isAccTreeEligible) return true;
|
|
99
|
+
try {
|
|
100
|
+
const r = isAccTreeEligible(el, ctx);
|
|
101
|
+
if (typeof r === 'boolean') return r;
|
|
102
|
+
return !!(r && r.eligible);
|
|
103
|
+
} catch {
|
|
104
|
+
return true;
|
|
143
105
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (getAriaLabelInfo) {
|
|
159
|
-
try {
|
|
160
|
-
const info = getAriaLabelInfo(el, ctx);
|
|
161
|
-
const v = info && info.present ? trim(info.value) : '';
|
|
162
|
-
if (v) return { method: 'aria-label', value: v };
|
|
163
|
-
} catch {}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const titleV = getNonEmptyAttr(el, 'title');
|
|
167
|
-
if (titleV) return { method: 'title', value: titleV };
|
|
168
|
-
|
|
169
|
-
const phV = getNonEmptyAttr(el, 'placeholder');
|
|
170
|
-
if (phV) return { method: 'placeholder', value: phV };
|
|
171
|
-
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// getLabelMethod is provided by the shared dom-helpers bundle that
|
|
109
|
+
// dom-runner.js always constructs for every rule execution (built-in or
|
|
110
|
+
// custom) — see createDomHelpers's own getLabelMethod, which implements
|
|
111
|
+
// this exact <label>/aria-labelledby/aria-label/title/placeholder
|
|
112
|
+
// priority order. No local reimplementation is needed as a fallback.
|
|
113
|
+
function getLabelMethodSafe(el) {
|
|
114
|
+
if (!getLabelMethod) return { method: 'none', value: '' };
|
|
115
|
+
try {
|
|
116
|
+
const r = getLabelMethod(el, ctx);
|
|
117
|
+
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
118
|
+
const v = r && r.value != null ? trim(r.value) : '';
|
|
119
|
+
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
|
|
172
120
|
return { method: 'none', value: '' };
|
|
121
|
+
return { method: m, value: v };
|
|
122
|
+
} catch {
|
|
123
|
+
return { method: 'none', value: '' };
|
|
173
124
|
}
|
|
125
|
+
}
|
|
174
126
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const r = getLabelMethod(el, ctx);
|
|
179
|
-
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
180
|
-
const v = r && r.value != null ? trim(r.value) : '';
|
|
181
|
-
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
|
|
182
|
-
return { method: m, value: v };
|
|
183
|
-
} catch {
|
|
184
|
-
// fall through
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return computeLabelMethodFallback(el);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Native controls only (same as your current rule)
|
|
191
|
-
const selector =
|
|
192
|
-
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
127
|
+
// Native controls only (same as your current rule)
|
|
128
|
+
const selector =
|
|
129
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
193
130
|
|
|
194
|
-
|
|
131
|
+
const nodes = safeQueryAll(selector);
|
|
195
132
|
|
|
196
|
-
|
|
197
|
-
|
|
133
|
+
if (!nodes.length) {
|
|
134
|
+
return {
|
|
135
|
+
ruleId: rule.ruleId,
|
|
136
|
+
outcome: 'notApplicable',
|
|
137
|
+
severity: 'minor',
|
|
138
|
+
occurrences: [],
|
|
139
|
+
data: { details: { metrics } }
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const occurrences = [];
|
|
144
|
+
|
|
145
|
+
for (const el of nodes) {
|
|
146
|
+
if (!el || !el.getAttribute) continue;
|
|
147
|
+
|
|
148
|
+
if (!isEligibleAcc(el)) continue;
|
|
149
|
+
|
|
150
|
+
const role = (() => {
|
|
151
|
+
try {
|
|
152
|
+
return trim(el.getAttribute('role')).toLowerCase();
|
|
153
|
+
} catch {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
|
|
158
|
+
let fi = null;
|
|
159
|
+
if (getFocusableInfo) {
|
|
160
|
+
try {
|
|
161
|
+
fi = getFocusableInfo(el, ctx);
|
|
162
|
+
} catch {
|
|
163
|
+
fi = null;
|
|
164
|
+
}
|
|
198
165
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const baseOccurrence = {
|
|
245
|
-
summary: 'Form control’s primary label is derived from title or placeholder.',
|
|
246
|
-
hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
|
|
247
|
-
i18n: {
|
|
248
|
-
summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
|
|
249
|
-
hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
|
|
250
|
-
params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
|
|
251
|
-
},
|
|
252
|
-
data: {
|
|
253
|
-
visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
254
|
-
details: {
|
|
255
|
-
reasonCode,
|
|
256
|
-
labelMethod: method,
|
|
257
|
-
labelStrength: 'weak',
|
|
258
|
-
recommendedMethods: ['label', 'aria-labelledby'],
|
|
259
|
-
sourceText: (label && label.value ? String(label.value).slice(0, 120) : '')
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
265
|
-
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
266
|
-
} else {
|
|
267
|
-
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
166
|
+
const tabbable = !!(fi && fi.tabbable);
|
|
167
|
+
|
|
168
|
+
if ((role === 'presentation' || role === 'none') && !tabbable) continue;
|
|
169
|
+
|
|
170
|
+
metrics.applicableCount += 1;
|
|
171
|
+
|
|
172
|
+
const label = getLabelMethodSafe(el);
|
|
173
|
+
const method = label && typeof label.method === 'string' ? label.method : 'none';
|
|
174
|
+
if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method))
|
|
175
|
+
metrics.byMethod[method] += 1;
|
|
176
|
+
else metrics.byMethod.none += 1;
|
|
177
|
+
|
|
178
|
+
// Flag only when the *primary* (best) method is title/placeholder
|
|
179
|
+
const isWeakPrimary = method === 'title' || method === 'placeholder';
|
|
180
|
+
if (!isWeakPrimary) continue;
|
|
181
|
+
|
|
182
|
+
metrics.flaggedCount += 1;
|
|
183
|
+
|
|
184
|
+
const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
185
|
+
|
|
186
|
+
const reasonCode =
|
|
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
|
+
const baseOccurrence = {
|
|
196
|
+
summary: 'Form control’s primary label is derived from title or placeholder.',
|
|
197
|
+
hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
|
|
198
|
+
i18n: {
|
|
199
|
+
summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
|
|
200
|
+
hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
|
|
201
|
+
params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
|
|
202
|
+
},
|
|
203
|
+
data: {
|
|
204
|
+
visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
205
|
+
details: {
|
|
206
|
+
reasonCode,
|
|
207
|
+
labelMethod: method,
|
|
208
|
+
labelStrength: 'weak',
|
|
209
|
+
recommendedMethods: ['label', 'aria-labelledby'],
|
|
210
|
+
sourceText: label && label.value ? String(label.value).slice(0, 120) : ''
|
|
268
211
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (metrics.applicableCount === 0) {
|
|
272
|
-
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
|
|
273
|
-
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
274
214
|
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
severity: rule.defaultSeverity || 'minor',
|
|
280
|
-
occurrences,
|
|
281
|
-
data: { details: { metrics } }
|
|
282
|
-
};
|
|
215
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
216
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
217
|
+
} else {
|
|
218
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
283
219
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (metrics.applicableCount === 0) {
|
|
223
|
+
return {
|
|
224
|
+
ruleId: rule.ruleId,
|
|
225
|
+
outcome: 'notApplicable',
|
|
226
|
+
severity: 'minor',
|
|
227
|
+
occurrences: [],
|
|
228
|
+
data: { details: { metrics } }
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (occurrences.length) {
|
|
233
|
+
return {
|
|
234
|
+
ruleId: rule.ruleId,
|
|
235
|
+
outcome: 'cantTell',
|
|
236
|
+
severity: rule.defaultSeverity || 'minor',
|
|
237
|
+
occurrences,
|
|
238
|
+
data: { details: { metrics } }
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail) —
|
|
243
|
+
// no applicable control relied on a weak (title/placeholder) primary
|
|
244
|
+
// label, so there is nothing to flag for review.
|
|
245
|
+
return {
|
|
246
|
+
ruleId: rule.ruleId,
|
|
247
|
+
outcome: 'notApplicable',
|
|
248
|
+
severity: 'minor',
|
|
249
|
+
occurrences: [],
|
|
250
|
+
data: { details: { metrics } }
|
|
251
|
+
};
|
|
289
252
|
}
|
|
290
253
|
|
|
291
254
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check heading-order
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Heading levels must not skip a level going deeper
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies whenever the page contains two or more heading elements
|
|
10
12
|
* (native <h1>-<h6>, or explicit role="heading" with aria-level —
|
|
@@ -47,10 +49,12 @@ const meta = {
|
|
|
47
49
|
};
|
|
48
50
|
|
|
49
51
|
function runInPage(ctx) {
|
|
50
|
-
const {
|
|
52
|
+
const { helpers, rule } = ctx;
|
|
51
53
|
|
|
52
54
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim();
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
function getExplicitRoleToken(el) {
|
|
@@ -65,20 +69,47 @@ function runInPage(ctx) {
|
|
|
65
69
|
if (explicit !== 'heading') return 0;
|
|
66
70
|
const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
|
|
67
71
|
const n = parseInt(raw, 10);
|
|
68
|
-
return
|
|
72
|
+
return Number.isFinite(n) && n >= 1 ? n : 2;
|
|
69
73
|
}
|
|
70
74
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
71
75
|
const m = /^h([1-6])$/.exec(tag);
|
|
72
76
|
return m ? parseInt(m[1], 10) : 0;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
const nodes = helpers.queryAllSmart
|
|
79
|
+
const nodes = helpers.queryAllSmart
|
|
80
|
+
? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
|
|
81
|
+
: helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
76
82
|
|
|
77
83
|
const headings = [];
|
|
78
84
|
const seen = new Set();
|
|
79
85
|
for (const el of nodes) {
|
|
80
86
|
if (!el || seen.has(el)) continue;
|
|
81
87
|
seen.add(el);
|
|
88
|
+
|
|
89
|
+
// queryAllSmart's default hidden-content policy only excludes "hard"
|
|
90
|
+
// CSS-based hiding (display:none, visibility:hidden, etc.) -- it does
|
|
91
|
+
// NOT exclude aria-hidden, a softer/semantic removal from the
|
|
92
|
+
// accessibility tree that still leaves an element visually rendered.
|
|
93
|
+
// A heading order rule whose whole premise is "the document outline
|
|
94
|
+
// assistive technology users rely on" (see this rule's own header
|
|
95
|
+
// comment) must not let an aria-hidden heading participate in that
|
|
96
|
+
// outline at all: it's invisible to exactly the users this rule
|
|
97
|
+
// exists to protect. Without this check an aria-hidden heading is both
|
|
98
|
+
// flagged itself (though it isn't part of the AT-perceived outline)
|
|
99
|
+
// and can mask a real skip immediately after it, by advancing the
|
|
100
|
+
// "highest level reached so far" tracker on a level no AT user
|
|
101
|
+
// actually encountered.
|
|
102
|
+
if (helpers.isAccTreeEligible) {
|
|
103
|
+
const elig = (() => {
|
|
104
|
+
try {
|
|
105
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
106
|
+
} catch {
|
|
107
|
+
return { eligible: true, reasons: [] };
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
if (elig && elig.eligible === false) continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
82
113
|
const level = getHeadingLevel(el);
|
|
83
114
|
if (level > 0) headings.push({ el, level });
|
|
84
115
|
}
|
|
@@ -95,7 +126,9 @@ function runInPage(ctx) {
|
|
|
95
126
|
|
|
96
127
|
if (level > highestSoFar + 1) {
|
|
97
128
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
98
|
-
const html = helpers.getOuterHtmlSnippet
|
|
129
|
+
const html = helpers.getOuterHtmlSnippet
|
|
130
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
131
|
+
: el.outerHTML || '';
|
|
99
132
|
|
|
100
133
|
occurrences.push({
|
|
101
134
|
selector: stableSelector,
|
|
@@ -108,7 +141,11 @@ function runInPage(ctx) {
|
|
|
108
141
|
params: { fromLevel: String(highestSoFar), toLevel: String(level) }
|
|
109
142
|
},
|
|
110
143
|
data: {
|
|
111
|
-
details: {
|
|
144
|
+
details: {
|
|
145
|
+
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
146
|
+
fromLevel: highestSoFar,
|
|
147
|
+
toLevel: level
|
|
148
|
+
}
|
|
112
149
|
}
|
|
113
150
|
});
|
|
114
151
|
}
|
|
@@ -117,7 +154,12 @@ function runInPage(ctx) {
|
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
if (occurrences.length) {
|
|
120
|
-
return {
|
|
157
|
+
return {
|
|
158
|
+
ruleId: rule.ruleId,
|
|
159
|
+
outcome: 'cantTell',
|
|
160
|
+
severity: rule.defaultSeverity || 'minor',
|
|
161
|
+
occurrences
|
|
162
|
+
};
|
|
121
163
|
}
|
|
122
164
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
123
165
|
}
|