@surea11y/core 1.0.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 +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Optimized, regression-safe rewrite of:
|
|
5
|
+
* form-control-programmatic-label-present
|
|
6
|
+
*
|
|
7
|
+
* Goals:
|
|
8
|
+
* - Preserve semantics, schema, determinism, and no-throw behavior.
|
|
9
|
+
* - Reduce per-element DOM queries and avoid unnecessary helper calls.
|
|
10
|
+
*
|
|
11
|
+
* Key safety points:
|
|
12
|
+
* - Control discovery goes through helpers.queryAllSmart/queryAll (multi-root
|
|
13
|
+
* and shadow-DOM aware), not a direct root/safeRoot DOM query -- ctx.root
|
|
14
|
+
* is an array (multi-region contextSelector support), not a single element.
|
|
15
|
+
* - label[for] association uses a single precomputed Set of `for` values (document-wide, like original).
|
|
16
|
+
* - getFocusableInfo is only invoked when role is presentational and thus relevant.
|
|
17
|
+
* - aria-labelledby / aria-label helper resolution is only invoked when the attribute is present.
|
|
18
|
+
*
|
|
19
|
+
* WCAG mapping: matches technique H44 ("Using label elements to associate
|
|
20
|
+
* text labels with form controls"), which WCAG's own Techniques document
|
|
21
|
+
* lists as sufficient for 1.3.1, 3.3.2, AND 4.1.2 simultaneously — a label
|
|
22
|
+
* that programmatically associates with a control conveys the
|
|
23
|
+
* relationship (1.3.1), provides the instruction (3.3.2), and exposes the
|
|
24
|
+
* accessible name (4.1.2) all at once. This rule was originally only wired
|
|
25
|
+
* to 4.1.2, even though its own `tags` already listed wcag131/wcag332 and
|
|
26
|
+
* wcag-facets.js already had matching facet ids under those SCs.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const id = 'form-control-programmatic-label-present';
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: 'Form controls must have a programmatic label',
|
|
33
|
+
description:
|
|
34
|
+
'Checks that form controls have a programmatic label via <label>, aria-label, aria-labelledby, title, or placeholder.',
|
|
35
|
+
i18n: {
|
|
36
|
+
titleKey: 'formControl_programmaticLabelPresent_title',
|
|
37
|
+
descriptionKey: 'formControl_programmaticLabelPresent_description'
|
|
38
|
+
},
|
|
39
|
+
helpUrl: null,
|
|
40
|
+
tags: ['wcag2a', 'wcag131', 'wcag332', 'wcag412', 'forms', 'labels', 'atomic', 'automatic'],
|
|
41
|
+
wcagSc: ['1.3.1', '3.3.2', '4.1.2'],
|
|
42
|
+
normativeMappings: [
|
|
43
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' },
|
|
44
|
+
{ standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' },
|
|
45
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
46
|
+
],
|
|
47
|
+
defaultSeverity: 'serious',
|
|
48
|
+
category: 'robust',
|
|
49
|
+
type: 'automatic',
|
|
50
|
+
defaultConfidence: 'medium',
|
|
51
|
+
coverage: {
|
|
52
|
+
facetsBySc: {
|
|
53
|
+
'1.3.1': ['form-control-programmatic-label-present'],
|
|
54
|
+
'3.3.2': ['form-control-labels-or-instructions-present'],
|
|
55
|
+
'4.1.2': ['form-control-name-present']
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function runInPage(ctx) {
|
|
61
|
+
const { document, helpers, rule } = ctx;
|
|
62
|
+
|
|
63
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
64
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
65
|
+
|
|
66
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
67
|
+
const getAriaLabelInfo = helpers && typeof helpers.getAriaLabelInfo === 'function' ? helpers.getAriaLabelInfo : null;
|
|
68
|
+
const getAriaLabelledByInfo =
|
|
69
|
+
helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
|
|
70
|
+
|
|
71
|
+
const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
|
|
72
|
+
const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
73
|
+
const getContentNameInfo = helpers && typeof helpers.getContentNameInfo === 'function' ? helpers.getContentNameInfo : null;
|
|
74
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
75
|
+
|
|
76
|
+
// A <label> counts as an association if it contributes a name either via
|
|
77
|
+
// its own aria-label/aria-labelledby (checked first, same ARIA-over-
|
|
78
|
+
// content precedence any element's accessible name gives — verified
|
|
79
|
+
// against a real page: <label aria-label="Toggle Navigation"><svg
|
|
80
|
+
// aria-hidden="true">...</svg></label> names its control "Toggle
|
|
81
|
+
// Navigation" even though the label's only child content is aria-hidden)
|
|
82
|
+
// or, failing that, non-empty ACCESSIBLE content (getContentNameInfo
|
|
83
|
+
// already excludes aria-hidden/display:none/inert descendants — a label
|
|
84
|
+
// whose only text is aria-hidden gives the control no real accessible
|
|
85
|
+
// name even though the structural association exists). See
|
|
86
|
+
// dom-helpers.js's labelContributesAccessibleName for the primary
|
|
87
|
+
// (shared-helper) path; this is the fallback-only mirror.
|
|
88
|
+
function labelHasAccessibleContent(labelEl) {
|
|
89
|
+
if (getAriaNameInfo) {
|
|
90
|
+
try {
|
|
91
|
+
const aria = getAriaNameInfo(labelEl, ctx);
|
|
92
|
+
if (aria && aria.present && trim(aria.value)) return true;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
if (!getContentNameInfo) return true; // conservative: don't newly fail without the helper
|
|
96
|
+
try {
|
|
97
|
+
const info = getContentNameInfo(labelEl, ctx);
|
|
98
|
+
return !!(info && info.present && trim(info.value));
|
|
99
|
+
} catch {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
105
|
+
|
|
106
|
+
const metrics = {
|
|
107
|
+
applicableCount: 0,
|
|
108
|
+
passCount: 0,
|
|
109
|
+
failCount: 0,
|
|
110
|
+
byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 },
|
|
111
|
+
weakPassCount: 0
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function getLabelStrength(method) {
|
|
115
|
+
if (method === 'label' || method === 'aria-labelledby') return 'strong';
|
|
116
|
+
if (method === 'aria-label') return 'medium';
|
|
117
|
+
if (method === 'title' || method === 'placeholder') return 'weak';
|
|
118
|
+
return 'none';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getNonEmptyAttrViaHelper(el, name) {
|
|
122
|
+
if (!getAttributeInfo) return '';
|
|
123
|
+
try {
|
|
124
|
+
const info = getAttributeInfo(el, name);
|
|
125
|
+
return info && info.present ? trim(info.value) : '';
|
|
126
|
+
} catch {
|
|
127
|
+
return '';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build a document-wide Map of label[for] value -> label element once
|
|
132
|
+
// (O(#labels) instead of O(#controls) selector queries). Keeps the
|
|
133
|
+
// element reference (not just a presence flag) so hasLabelAssociation can
|
|
134
|
+
// check the label's actual accessible content.
|
|
135
|
+
const labelForMap = new Map();
|
|
136
|
+
try {
|
|
137
|
+
if (document && typeof document.getElementsByTagName === 'function') {
|
|
138
|
+
const labels = document.getElementsByTagName('label');
|
|
139
|
+
for (let i = 0; i < labels.length; i++) {
|
|
140
|
+
const lab = labels[i];
|
|
141
|
+
if (!lab || !lab.getAttribute) continue;
|
|
142
|
+
const f = trim(lab.getAttribute('for'));
|
|
143
|
+
if (f && !labelForMap.has(f)) labelForMap.set(f, lab);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// no-throw
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isEligibleAcc(el) {
|
|
151
|
+
if (!isAccTreeEligible) return true;
|
|
152
|
+
try {
|
|
153
|
+
const r = isAccTreeEligible(el, ctx);
|
|
154
|
+
if (typeof r === 'boolean') return r;
|
|
155
|
+
return !!(r && r.eligible);
|
|
156
|
+
} catch {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function hasLabelAssociation(el) {
|
|
162
|
+
// 1) Native labels API — resolves both wrapping <label> and
|
|
163
|
+
// <label for="id"> as real elements in one call.
|
|
164
|
+
try {
|
|
165
|
+
if (el && 'labels' in el && el.labels && el.labels.length) {
|
|
166
|
+
return Array.prototype.some.call(el.labels, labelHasAccessibleContent);
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
|
|
170
|
+
// 2) Wrapped by <label>
|
|
171
|
+
try {
|
|
172
|
+
if (el && el.closest) {
|
|
173
|
+
const wrap = el.closest('label');
|
|
174
|
+
if (wrap) return labelHasAccessibleContent(wrap);
|
|
175
|
+
}
|
|
176
|
+
} catch {}
|
|
177
|
+
|
|
178
|
+
// 3) <label for="id">
|
|
179
|
+
try {
|
|
180
|
+
const idAttr = el && el.getAttribute ? trim(el.getAttribute('id')) : '';
|
|
181
|
+
if (!idAttr) return false;
|
|
182
|
+
const lab = labelForMap.get(idAttr);
|
|
183
|
+
return !!lab && labelHasAccessibleContent(lab);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function computeLabelMethodFallback(el) {
|
|
190
|
+
// Deterministic priority order
|
|
191
|
+
if (hasLabelAssociation(el)) return { method: 'label', value: '' };
|
|
192
|
+
|
|
193
|
+
// Only resolve aria-labelledby if attribute exists
|
|
194
|
+
let raw = '';
|
|
195
|
+
try {
|
|
196
|
+
raw = el && el.getAttribute ? trim(el.getAttribute('aria-labelledby')) : '';
|
|
197
|
+
} catch {
|
|
198
|
+
raw = '';
|
|
199
|
+
}
|
|
200
|
+
if (raw && getAriaLabelledByInfo) {
|
|
201
|
+
try {
|
|
202
|
+
const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
|
|
203
|
+
const v = info && info.present ? trim(info.value) : '';
|
|
204
|
+
if (v) return { method: 'aria-labelledby', value: v };
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Only resolve aria-label if attribute exists
|
|
209
|
+
raw = '';
|
|
210
|
+
try {
|
|
211
|
+
raw = el && el.getAttribute ? trim(el.getAttribute('aria-label')) : '';
|
|
212
|
+
} catch {
|
|
213
|
+
raw = '';
|
|
214
|
+
}
|
|
215
|
+
if (raw && getAriaLabelInfo) {
|
|
216
|
+
try {
|
|
217
|
+
const info = getAriaLabelInfo(el, ctx);
|
|
218
|
+
const v = info && info.present ? trim(info.value) : '';
|
|
219
|
+
if (v) return { method: 'aria-label', value: v };
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// title / placeholder
|
|
224
|
+
const titleV = getNonEmptyAttrViaHelper(el, 'title') || (() => {
|
|
225
|
+
try { return trim(el.getAttribute('title')); } catch { return ''; }
|
|
226
|
+
})();
|
|
227
|
+
if (titleV) return { method: 'title', value: titleV };
|
|
228
|
+
|
|
229
|
+
// `placeholder` is only a valid name/hint source for text-entry input
|
|
230
|
+
// types and <textarea> — not checkbox/radio/range/color/date/... or
|
|
231
|
+
// <select>, which browsers/AT never read a placeholder from.
|
|
232
|
+
const isPlaceholderCapable = (() => {
|
|
233
|
+
try {
|
|
234
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
235
|
+
if (tag === 'textarea') return true;
|
|
236
|
+
if (tag !== 'input') return false;
|
|
237
|
+
const type = trim(el.getAttribute('type') || 'text').toLowerCase() || 'text';
|
|
238
|
+
return ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(type);
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
})();
|
|
243
|
+
|
|
244
|
+
const phV = isPlaceholderCapable && (getNonEmptyAttrViaHelper(el, 'placeholder') || (() => {
|
|
245
|
+
try { return trim(el.getAttribute('placeholder')); } catch { return ''; }
|
|
246
|
+
})());
|
|
247
|
+
if (phV) return { method: 'placeholder', value: phV };
|
|
248
|
+
|
|
249
|
+
return { method: 'none', value: '' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getLabelMethodSafe(el) {
|
|
253
|
+
if (getLabelMethod) {
|
|
254
|
+
try {
|
|
255
|
+
const r = getLabelMethod(el, ctx);
|
|
256
|
+
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
257
|
+
const v = r && r.value != null ? trim(r.value) : '';
|
|
258
|
+
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m)) return { method: 'none', value: '' };
|
|
259
|
+
return { method: m, value: v };
|
|
260
|
+
} catch {
|
|
261
|
+
// fall through
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return computeLabelMethodFallback(el);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Collect candidate nodes via the shared queryAllSmart/queryAll helpers
|
|
268
|
+
// (multi-root/shadow-DOM-aware, cached) rather than raw safeRoot DOM
|
|
269
|
+
// access -- safeRoot is ctx.root, which is an array with multi-region
|
|
270
|
+
// contextSelector support and never had a .querySelectorAll/
|
|
271
|
+
// .getElementsByTagName of its own to call directly.
|
|
272
|
+
const nodes = [];
|
|
273
|
+
try {
|
|
274
|
+
const sel = 'input,select,textarea';
|
|
275
|
+
const candidates = (helpers && typeof helpers.queryAllSmart === 'function')
|
|
276
|
+
? helpers.queryAllSmart(sel)
|
|
277
|
+
: (helpers && typeof helpers.queryAll === 'function' ? helpers.queryAll(sel) : []);
|
|
278
|
+
|
|
279
|
+
for (const el of (candidates || [])) {
|
|
280
|
+
if (!el || !el.getAttribute) continue;
|
|
281
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
282
|
+
if (tag === 'input') {
|
|
283
|
+
const t = trim(el.getAttribute('type')).toLowerCase();
|
|
284
|
+
// exclude hidden|submit|reset|button|image
|
|
285
|
+
if (t === 'hidden' || t === 'submit' || t === 'reset' || t === 'button' || t === 'image') continue;
|
|
286
|
+
}
|
|
287
|
+
nodes.push(el);
|
|
288
|
+
}
|
|
289
|
+
} catch {
|
|
290
|
+
// no-throw
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!nodes.length) {
|
|
294
|
+
return {
|
|
295
|
+
ruleId: rule.ruleId,
|
|
296
|
+
outcome: 'notApplicable',
|
|
297
|
+
severity: 'minor',
|
|
298
|
+
occurrences: [],
|
|
299
|
+
data: { details: { metrics } }
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const occurrences = [];
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
306
|
+
const el = nodes[i];
|
|
307
|
+
if (!el || !el.getAttribute) continue;
|
|
308
|
+
|
|
309
|
+
if (!isEligibleAcc(el)) continue;
|
|
310
|
+
|
|
311
|
+
// role="presentation"/"none" exclusion only when NOT focusable
|
|
312
|
+
let role = '';
|
|
313
|
+
try {
|
|
314
|
+
role = trim(el.getAttribute('role')).toLowerCase();
|
|
315
|
+
} catch {
|
|
316
|
+
role = '';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (role === 'presentation' || role === 'none') {
|
|
320
|
+
const fi = getFocusableInfo
|
|
321
|
+
? (() => {
|
|
322
|
+
try {
|
|
323
|
+
return getFocusableInfo(el, ctx);
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
})()
|
|
328
|
+
: null;
|
|
329
|
+
const tabbable = !!(fi && fi.tabbable);
|
|
330
|
+
if (!tabbable) continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
metrics.applicableCount += 1;
|
|
334
|
+
|
|
335
|
+
const label = getLabelMethodSafe(el);
|
|
336
|
+
if (Object.prototype.hasOwnProperty.call(metrics.byMethod, label.method)) {
|
|
337
|
+
metrics.byMethod[label.method] += 1;
|
|
338
|
+
} else {
|
|
339
|
+
metrics.byMethod.none += 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const strength = getLabelStrength(label.method);
|
|
343
|
+
const ok = label.method !== 'none';
|
|
344
|
+
|
|
345
|
+
if (ok) {
|
|
346
|
+
metrics.passCount += 1;
|
|
347
|
+
if (strength === 'weak') metrics.weakPassCount += 1;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
metrics.failCount += 1;
|
|
352
|
+
|
|
353
|
+
const vf = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
|
|
354
|
+
|
|
355
|
+
const baseOccurrence = {
|
|
356
|
+
summary: 'Form control is missing a programmatic label.',
|
|
357
|
+
hint: 'Provide a <label> association, aria-label, aria-labelledby, title, or placeholder.',
|
|
358
|
+
i18n: {
|
|
359
|
+
summaryKey: 'formControl_programmaticLabelPresent_summary_fail',
|
|
360
|
+
hintKey: 'formControl_programmaticLabelPresent_hint_fail',
|
|
361
|
+
params: { element: (el.tagName || '').toLowerCase() }
|
|
362
|
+
},
|
|
363
|
+
data: {
|
|
364
|
+
visibilityFilter: vf
|
|
365
|
+
? { targetSet: vf.targetSet, accEligible: vf.accEligible, reasons: vf.reasons }
|
|
366
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
367
|
+
details: {
|
|
368
|
+
reasonCode: 'missing_programmatic_label',
|
|
369
|
+
labelMethod: 'none',
|
|
370
|
+
labelStrength: 'none'
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
376
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
377
|
+
} else {
|
|
378
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (metrics.applicableCount === 0) {
|
|
383
|
+
return {
|
|
384
|
+
ruleId: rule.ruleId,
|
|
385
|
+
outcome: 'notApplicable',
|
|
386
|
+
severity: 'minor',
|
|
387
|
+
occurrences: [],
|
|
388
|
+
data: { details: { metrics } }
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (occurrences.length) {
|
|
392
|
+
return {
|
|
393
|
+
ruleId: rule.ruleId,
|
|
394
|
+
outcome: 'fail',
|
|
395
|
+
severity: rule.defaultSeverity || 'minor',
|
|
396
|
+
occurrences,
|
|
397
|
+
data: { details: { metrics } }
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
ruleId: rule.ruleId,
|
|
402
|
+
outcome: 'pass',
|
|
403
|
+
severity: 'minor',
|
|
404
|
+
occurrences: [],
|
|
405
|
+
data: { details: { metrics } }
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check form-control-single-label
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A form control must not be associated with more than one <label>
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 3.3.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to labelable form controls (input, excluding
|
|
11
|
+
* hidden/submit/reset/button/image; select; textarea).
|
|
12
|
+
* @expectation
|
|
13
|
+
* At most one <label> is associated with the control — either by
|
|
14
|
+
* wrapping it, or by a <label for="..."> pointing to its id
|
|
15
|
+
* (deduplicated: a label that both wraps the control and
|
|
16
|
+
* self-references it via for counts once). Multiple associated labels
|
|
17
|
+
* are ambiguous: many screen readers only announce one of them, and it
|
|
18
|
+
* is not deterministic which.
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Matches a widely-used reference engine's form-field-multiple-labels. Distinct, atomic
|
|
21
|
+
* decision from form-control-programmatic-label-present (that
|
|
22
|
+
* rule checks a label exists at all; this one checks there is at most
|
|
23
|
+
* one).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'form-control-single-label';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Form controls must not have multiple labels',
|
|
30
|
+
description: 'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'formControlSingleLabel_title',
|
|
33
|
+
descriptionKey: 'formControlSingleLabel_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
|
|
37
|
+
wcagSc: ['3.3.2'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'moderate',
|
|
42
|
+
category: 'understandable',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: { facetsBySc: { '3.3.2': ['form-control-single-label'] } }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
53
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
54
|
+
|
|
55
|
+
// Build a for-value -> label[] map once (avoids per-control dynamic
|
|
56
|
+
// attribute-selector construction / CSS.escape, which is not guaranteed
|
|
57
|
+
// to exist as a global in every runtime this engine executes in).
|
|
58
|
+
const labelsByFor = new Map();
|
|
59
|
+
const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
|
|
60
|
+
for (const lab of allLabels) {
|
|
61
|
+
if (!lab || !lab.getAttribute) continue;
|
|
62
|
+
const forValue = String(lab.getAttribute('for') || '').trim();
|
|
63
|
+
if (!forValue) continue;
|
|
64
|
+
if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
|
|
65
|
+
labelsByFor.get(forValue).push(lab);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const occurrences = [];
|
|
69
|
+
let applicableCount = 0;
|
|
70
|
+
|
|
71
|
+
for (const el of nodes) {
|
|
72
|
+
if (!el || !el.getAttribute) continue;
|
|
73
|
+
|
|
74
|
+
applicableCount += 1;
|
|
75
|
+
|
|
76
|
+
const labels = new Set();
|
|
77
|
+
|
|
78
|
+
const wrappingLabel = el.closest ? el.closest('label') : null;
|
|
79
|
+
if (wrappingLabel) labels.add(wrappingLabel);
|
|
80
|
+
|
|
81
|
+
const controlId = String(el.getAttribute('id') || '').trim();
|
|
82
|
+
if (controlId && labelsByFor.has(controlId)) {
|
|
83
|
+
for (const lab of labelsByFor.get(controlId)) labels.add(lab);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (labels.size <= 1) continue;
|
|
87
|
+
|
|
88
|
+
const tag = el.tagName.toLowerCase();
|
|
89
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
90
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
91
|
+
|
|
92
|
+
occurrences.push({
|
|
93
|
+
selector: stableSelector,
|
|
94
|
+
html,
|
|
95
|
+
summary: 'This form control is associated with more than one <label>.',
|
|
96
|
+
hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
|
|
97
|
+
i18n: {
|
|
98
|
+
summaryKey: 'formControlSingleLabel_summary_fail',
|
|
99
|
+
hintKey: 'formControlSingleLabel_hint_fail',
|
|
100
|
+
params: { element: tag, labelCount: String(labels.size) }
|
|
101
|
+
},
|
|
102
|
+
data: {
|
|
103
|
+
details: { reasonCode: 'FORM_FIELD_MULTIPLE_LABELS', element: tag, labelCount: labels.size }
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (applicableCount === 0) {
|
|
109
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
110
|
+
}
|
|
111
|
+
if (occurrences.length) {
|
|
112
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
113
|
+
}
|
|
114
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check html-xml-lang-mismatch
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The lang and xml:lang attributes on <html> must not disagree
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 3.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies when the <html> element has both a non-empty lang attribute
|
|
11
|
+
* and a non-empty xml:lang attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* The primary language subtag (the part before the first "-") of lang
|
|
14
|
+
* and xml:lang match, case-insensitively. When both attributes are
|
|
15
|
+
* present but declare different languages, assistive technology and
|
|
16
|
+
* user agents may resolve the page's language inconsistently.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Distinct, atomic decision from html-lang-attr-present (that
|
|
19
|
+
* rule checks presence/syntax of lang alone; this rule checks
|
|
20
|
+
* agreement between lang and xml:lang when both exist).
|
|
21
|
+
* - Compares primary subtags only (not full tag equality), since e.g.
|
|
22
|
+
* lang="en-US" and xml:lang="en-GB" both correctly declare English and
|
|
23
|
+
* should not be flagged as disagreeing on language.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'html-xml-lang-mismatch';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'lang and xml:lang must not disagree',
|
|
30
|
+
description: 'Checks that the <html> element\'s lang and xml:lang attributes declare the same primary language, when both are present.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'htmlXmlLangMismatch_title',
|
|
33
|
+
descriptionKey: 'htmlXmlLangMismatch_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag2a', 'wcag311', 'structure', 'language', 'atomic', 'automatic'],
|
|
37
|
+
wcagSc: ['3.1.1'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '3.1.1', title: 'Language of Page', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'serious',
|
|
42
|
+
category: 'understandable',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: { facetsBySc: { '3.1.1': ['html-xml-lang-mismatch'] } }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, helpers, rule } = ctx;
|
|
50
|
+
|
|
51
|
+
const html = document && document.documentElement;
|
|
52
|
+
const tag = (html && html.tagName) ? String(html.tagName).toLowerCase() : '';
|
|
53
|
+
if (!html || tag !== 'html') {
|
|
54
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lang = String(html.getAttribute('lang') || '').trim();
|
|
58
|
+
const xmlLang = String(html.getAttribute('xml:lang') || '').trim();
|
|
59
|
+
|
|
60
|
+
if (!lang || !xmlLang) {
|
|
61
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const primary = (s) => s.split('-')[0].toLowerCase();
|
|
65
|
+
|
|
66
|
+
if (primary(lang) === primary(xmlLang)) {
|
|
67
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
|
|
71
|
+
const htmlSnippet = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(html) : (html.outerHTML || '').slice(0, 200);
|
|
72
|
+
|
|
73
|
+
const occurrences = [{
|
|
74
|
+
selector: stableSelector,
|
|
75
|
+
html: htmlSnippet,
|
|
76
|
+
summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
|
|
77
|
+
hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
|
|
78
|
+
i18n: {
|
|
79
|
+
summaryKey: 'htmlXmlLangMismatch_summary_fail',
|
|
80
|
+
hintKey: 'htmlXmlLangMismatch_hint_fail',
|
|
81
|
+
params: { lang, xmlLang }
|
|
82
|
+
},
|
|
83
|
+
data: {
|
|
84
|
+
details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
|
|
85
|
+
}
|
|
86
|
+
}];
|
|
87
|
+
|
|
88
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { id, meta, runInPage };
|