@surea11y/core 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -25,267 +25,228 @@
|
|
|
25
25
|
const id = 'form-control-programmatic-label-quality';
|
|
26
26
|
|
|
27
27
|
const meta = {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
28
|
+
title: 'Form controls should not rely on placeholder or title as the primary label',
|
|
29
|
+
description:
|
|
30
|
+
'Flags form controls whose computed accessible name relies on placeholder or title as the primary labeling method. Prefer <label> or aria-labelledby.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'formControl_programmaticLabelQuality_title',
|
|
33
|
+
descriptionKey: 'formControl_programmaticLabelQuality_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag2a', 'wcag412', 'forms', 'labels', 'quality', 'atomic', 'manual'],
|
|
37
|
+
wcagSc: ['4.1.2'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{
|
|
40
|
+
standard: 'WCAG',
|
|
41
|
+
version: '2.2',
|
|
42
|
+
requirement: '4.1.2',
|
|
43
|
+
title: 'Name, Role, Value',
|
|
44
|
+
conformanceLevel: 'A'
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
defaultSeverity: 'moderate',
|
|
48
|
+
category: 'robust',
|
|
49
|
+
type: 'manual',
|
|
50
|
+
defaultConfidence: 'medium',
|
|
51
|
+
coverage: {
|
|
52
|
+
facetsBySc: {
|
|
53
|
+
'4.1.2': ['form-control-name-quality']
|
|
49
54
|
}
|
|
55
|
+
}
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
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
|
-
|
|
59
|
+
const { document, root, helpers, rule } = ctx;
|
|
60
|
+
const safeRoot = root || document;
|
|
61
|
+
|
|
62
|
+
const queryAllSmart =
|
|
63
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
64
|
+
|
|
65
|
+
const isAccTreeEligible =
|
|
66
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
67
|
+
const getEligibilityInfo =
|
|
68
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
69
|
+
|
|
70
|
+
const getFocusableInfo =
|
|
71
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
72
|
+
|
|
73
|
+
const getLabelMethod =
|
|
74
|
+
helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
75
|
+
|
|
76
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
77
|
+
|
|
78
|
+
const metrics = {
|
|
79
|
+
applicableCount: 0,
|
|
80
|
+
flaggedCount: 0,
|
|
81
|
+
byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function safeQueryAll(sel) {
|
|
85
|
+
try {
|
|
86
|
+
if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
|
|
87
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
88
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
89
|
+
: [];
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
85
92
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function hasLabelAssociation(el) {
|
|
119
|
-
// 1) Native labels API
|
|
120
|
-
try {
|
|
121
|
-
if (el && 'labels' in el && el.labels && el.labels.length) return true;
|
|
122
|
-
} catch {}
|
|
123
|
-
|
|
124
|
-
// 2) Wrapped by <label>
|
|
125
|
-
try {
|
|
126
|
-
if (el && el.closest) {
|
|
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
|
-
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isEligibleAcc(el) {
|
|
96
|
+
if (!isAccTreeEligible) return true;
|
|
97
|
+
try {
|
|
98
|
+
const r = isAccTreeEligible(el, ctx);
|
|
99
|
+
if (typeof r === 'boolean') return r;
|
|
100
|
+
return !!(r && r.eligible);
|
|
101
|
+
} catch {
|
|
102
|
+
return true;
|
|
143
103
|
}
|
|
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
|
-
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// getLabelMethod is provided by the shared dom-helpers bundle that
|
|
107
|
+
// dom-runner.js always constructs for every rule execution (built-in or
|
|
108
|
+
// custom) — see createDomHelpers's own getLabelMethod, which implements
|
|
109
|
+
// this exact <label>/aria-labelledby/aria-label/title/placeholder
|
|
110
|
+
// priority order. No local reimplementation is needed as a fallback.
|
|
111
|
+
function getLabelMethodSafe(el) {
|
|
112
|
+
if (!getLabelMethod) return { method: 'none', value: '' };
|
|
113
|
+
try {
|
|
114
|
+
const r = getLabelMethod(el, ctx);
|
|
115
|
+
const m = r && typeof r.method === 'string' ? r.method : 'none';
|
|
116
|
+
const v = r && r.value != null ? trim(r.value) : '';
|
|
117
|
+
if (!Object.prototype.hasOwnProperty.call(metrics.byMethod, m))
|
|
172
118
|
return { method: 'none', value: '' };
|
|
119
|
+
return { method: m, value: v };
|
|
120
|
+
} catch {
|
|
121
|
+
return { method: 'none', value: '' };
|
|
173
122
|
}
|
|
123
|
+
}
|
|
174
124
|
|
|
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';
|
|
125
|
+
// Native controls only (same as your current rule)
|
|
126
|
+
const selector =
|
|
127
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
193
128
|
|
|
194
|
-
|
|
129
|
+
const nodes = safeQueryAll(selector);
|
|
195
130
|
|
|
196
|
-
|
|
197
|
-
|
|
131
|
+
if (!nodes.length) {
|
|
132
|
+
return {
|
|
133
|
+
ruleId: rule.ruleId,
|
|
134
|
+
outcome: 'notApplicable',
|
|
135
|
+
severity: 'minor',
|
|
136
|
+
occurrences: [],
|
|
137
|
+
data: { details: { metrics } }
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const occurrences = [];
|
|
142
|
+
|
|
143
|
+
for (const el of nodes) {
|
|
144
|
+
if (!el || !el.getAttribute) continue;
|
|
145
|
+
|
|
146
|
+
if (!isEligibleAcc(el)) continue;
|
|
147
|
+
|
|
148
|
+
const role = (() => {
|
|
149
|
+
try {
|
|
150
|
+
return trim(el.getAttribute('role')).toLowerCase();
|
|
151
|
+
} catch {
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
})();
|
|
155
|
+
|
|
156
|
+
let fi = null;
|
|
157
|
+
if (getFocusableInfo) {
|
|
158
|
+
try {
|
|
159
|
+
fi = getFocusableInfo(el, ctx);
|
|
160
|
+
} catch {
|
|
161
|
+
fi = null;
|
|
162
|
+
}
|
|
198
163
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
164
|
+
const tabbable = !!(fi && fi.tabbable);
|
|
165
|
+
|
|
166
|
+
if ((role === 'presentation' || role === 'none') && !tabbable) continue;
|
|
167
|
+
|
|
168
|
+
metrics.applicableCount += 1;
|
|
169
|
+
|
|
170
|
+
const label = getLabelMethodSafe(el);
|
|
171
|
+
const method = label && typeof label.method === 'string' ? label.method : 'none';
|
|
172
|
+
if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method))
|
|
173
|
+
metrics.byMethod[method] += 1;
|
|
174
|
+
else metrics.byMethod.none += 1;
|
|
175
|
+
|
|
176
|
+
// Flag only when the *primary* (best) method is title/placeholder
|
|
177
|
+
const isWeakPrimary = method === 'title' || method === 'placeholder';
|
|
178
|
+
if (!isWeakPrimary) continue;
|
|
179
|
+
|
|
180
|
+
metrics.flaggedCount += 1;
|
|
181
|
+
|
|
182
|
+
const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
183
|
+
|
|
184
|
+
const reasonCode =
|
|
185
|
+
method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
|
|
186
|
+
const methodLabel =
|
|
187
|
+
method === 'title'
|
|
188
|
+
? 'title'
|
|
189
|
+
: method === 'placeholder'
|
|
190
|
+
? 'placeholder'
|
|
191
|
+
: 'title or placeholder';
|
|
192
|
+
|
|
193
|
+
const baseOccurrence = {
|
|
194
|
+
summary: 'Form control’s primary label is derived from title or placeholder.',
|
|
195
|
+
hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
|
|
196
|
+
i18n: {
|
|
197
|
+
summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
|
|
198
|
+
hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
|
|
199
|
+
params: { element: (el.tagName || '').toLowerCase(), method, methodLabel }
|
|
200
|
+
},
|
|
201
|
+
data: {
|
|
202
|
+
visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
203
|
+
details: {
|
|
204
|
+
reasonCode,
|
|
205
|
+
labelMethod: method,
|
|
206
|
+
labelStrength: 'weak',
|
|
207
|
+
recommendedMethods: ['label', 'aria-labelledby'],
|
|
208
|
+
sourceText: label && label.value ? String(label.value).slice(0, 120) : ''
|
|
218
209
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if ((role === 'presentation' || role === 'none') && !tabbable) continue;
|
|
222
|
-
|
|
223
|
-
metrics.applicableCount += 1;
|
|
224
|
-
|
|
225
|
-
const label = getLabelMethodSafe(el);
|
|
226
|
-
const method = label && typeof label.method === 'string' ? label.method : 'none';
|
|
227
|
-
if (Object.prototype.hasOwnProperty.call(metrics.byMethod, method)) metrics.byMethod[method] += 1;
|
|
228
|
-
else metrics.byMethod.none += 1;
|
|
229
|
-
|
|
230
|
-
// Flag only when the *primary* (best) method is title/placeholder
|
|
231
|
-
const isWeakPrimary = method === 'title' || method === 'placeholder';
|
|
232
|
-
if (!isWeakPrimary) continue;
|
|
233
|
-
|
|
234
|
-
metrics.flaggedCount += 1;
|
|
235
|
-
|
|
236
|
-
const vf = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
237
|
-
|
|
238
|
-
const reasonCode = method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
|
|
239
|
-
const methodLabel =
|
|
240
|
-
method === 'title' ? 'title'
|
|
241
|
-
: method === 'placeholder' ? 'placeholder'
|
|
242
|
-
: 'title or placeholder';
|
|
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 });
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (metrics.applicableCount === 0) {
|
|
272
|
-
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
|
|
273
|
-
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
274
212
|
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
severity: rule.defaultSeverity || 'minor',
|
|
280
|
-
occurrences,
|
|
281
|
-
data: { details: { metrics } }
|
|
282
|
-
};
|
|
213
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
214
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
215
|
+
} else {
|
|
216
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
283
217
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (metrics.applicableCount === 0) {
|
|
221
|
+
return {
|
|
222
|
+
ruleId: rule.ruleId,
|
|
223
|
+
outcome: 'notApplicable',
|
|
224
|
+
severity: 'minor',
|
|
225
|
+
occurrences: [],
|
|
226
|
+
data: { details: { metrics } }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (occurrences.length) {
|
|
231
|
+
return {
|
|
232
|
+
ruleId: rule.ruleId,
|
|
233
|
+
outcome: 'cantTell',
|
|
234
|
+
severity: rule.defaultSeverity || 'minor',
|
|
235
|
+
occurrences,
|
|
236
|
+
data: { details: { metrics } }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail) —
|
|
241
|
+
// no applicable control relied on a weak (title/placeholder) primary
|
|
242
|
+
// label, so there is nothing to flag for review.
|
|
243
|
+
return {
|
|
244
|
+
ruleId: rule.ruleId,
|
|
245
|
+
outcome: 'notApplicable',
|
|
246
|
+
severity: 'minor',
|
|
247
|
+
occurrences: [],
|
|
248
|
+
data: { details: { metrics } }
|
|
249
|
+
};
|
|
289
250
|
}
|
|
290
251
|
|
|
291
252
|
module.exports = { id, meta, runInPage };
|
|
@@ -47,10 +47,12 @@ const meta = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
function runInPage(ctx) {
|
|
50
|
-
const {
|
|
50
|
+
const { helpers, rule } = ctx;
|
|
51
51
|
|
|
52
52
|
function normalizeWs(s) {
|
|
53
|
-
return String(s || '')
|
|
53
|
+
return String(s || '')
|
|
54
|
+
.replace(/\s+/g, ' ')
|
|
55
|
+
.trim();
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function getExplicitRoleToken(el) {
|
|
@@ -65,19 +67,16 @@ function runInPage(ctx) {
|
|
|
65
67
|
if (explicit !== 'heading') return 0;
|
|
66
68
|
const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
|
|
67
69
|
const n = parseInt(raw, 10);
|
|
68
|
-
return
|
|
70
|
+
return Number.isFinite(n) && n >= 1 ? n : 2;
|
|
69
71
|
}
|
|
70
72
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
71
73
|
const m = /^h([1-6])$/.exec(tag);
|
|
72
74
|
return m ? parseInt(m[1], 10) : 0;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} catch {
|
|
79
|
-
nodes = [];
|
|
80
|
-
}
|
|
77
|
+
const nodes = helpers.queryAllSmart
|
|
78
|
+
? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]')
|
|
79
|
+
: helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
81
80
|
|
|
82
81
|
const headings = [];
|
|
83
82
|
const seen = new Set();
|
|
@@ -100,7 +99,9 @@ function runInPage(ctx) {
|
|
|
100
99
|
|
|
101
100
|
if (level > highestSoFar + 1) {
|
|
102
101
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
103
|
-
const html = helpers.getOuterHtmlSnippet
|
|
102
|
+
const html = helpers.getOuterHtmlSnippet
|
|
103
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
104
|
+
: el.outerHTML || '';
|
|
104
105
|
|
|
105
106
|
occurrences.push({
|
|
106
107
|
selector: stableSelector,
|
|
@@ -113,7 +114,11 @@ function runInPage(ctx) {
|
|
|
113
114
|
params: { fromLevel: String(highestSoFar), toLevel: String(level) }
|
|
114
115
|
},
|
|
115
116
|
data: {
|
|
116
|
-
details: {
|
|
117
|
+
details: {
|
|
118
|
+
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
119
|
+
fromLevel: highestSoFar,
|
|
120
|
+
toLevel: level
|
|
121
|
+
}
|
|
117
122
|
}
|
|
118
123
|
});
|
|
119
124
|
}
|
|
@@ -122,7 +127,12 @@ function runInPage(ctx) {
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
if (occurrences.length) {
|
|
125
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
ruleId: rule.ruleId,
|
|
132
|
+
outcome: 'cantTell',
|
|
133
|
+
severity: rule.defaultSeverity || 'minor',
|
|
134
|
+
occurrences
|
|
135
|
+
};
|
|
126
136
|
}
|
|
127
137
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
128
138
|
}
|
|
@@ -42,7 +42,13 @@ const meta = {
|
|
|
42
42
|
tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
|
|
43
43
|
wcagSc: ['2.4.9'],
|
|
44
44
|
normativeMappings: [
|
|
45
|
-
{
|
|
45
|
+
{
|
|
46
|
+
standard: 'WCAG',
|
|
47
|
+
version: '2.2',
|
|
48
|
+
requirement: '2.4.9',
|
|
49
|
+
title: 'Link Purpose (Link Only)',
|
|
50
|
+
conformanceLevel: 'AAA'
|
|
51
|
+
}
|
|
46
52
|
],
|
|
47
53
|
defaultSeverity: 'minor',
|
|
48
54
|
category: 'operable',
|
|
@@ -52,13 +58,15 @@ const meta = {
|
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
function runInPage(ctx) {
|
|
55
|
-
const {
|
|
61
|
+
const { helpers, rule } = ctx;
|
|
56
62
|
|
|
57
63
|
function normName(s) {
|
|
58
64
|
return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
const nodes = helpers.queryAllSmart
|
|
67
|
+
const nodes = helpers.queryAllSmart
|
|
68
|
+
? helpers.queryAllSmart('a[href]')
|
|
69
|
+
: helpers.queryAll('a[href]');
|
|
62
70
|
|
|
63
71
|
const groups = new Map(); // normName -> [{ el, href }]
|
|
64
72
|
let applicableCount = 0;
|
|
@@ -67,18 +75,24 @@ function runInPage(ctx) {
|
|
|
67
75
|
if (!el || !el.getAttribute) continue;
|
|
68
76
|
|
|
69
77
|
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
70
|
-
const eligible =
|
|
78
|
+
const eligible =
|
|
79
|
+
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
71
80
|
if (!eligible) continue;
|
|
72
81
|
|
|
73
82
|
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
74
|
-
const rawName =
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
const rawName =
|
|
84
|
+
nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
|
|
85
|
+
? nameInfo.value
|
|
86
|
+
: el.textContent || '';
|
|
77
87
|
const name = normName(rawName);
|
|
78
88
|
if (!name) continue;
|
|
79
89
|
|
|
80
|
-
let href
|
|
81
|
-
try {
|
|
90
|
+
let href;
|
|
91
|
+
try {
|
|
92
|
+
href = String(el.href || '');
|
|
93
|
+
} catch {
|
|
94
|
+
href = '';
|
|
95
|
+
}
|
|
82
96
|
if (!href) continue;
|
|
83
97
|
|
|
84
98
|
applicableCount += 1;
|
|
@@ -95,12 +109,15 @@ function runInPage(ctx) {
|
|
|
95
109
|
|
|
96
110
|
for (const { el, href } of entries) {
|
|
97
111
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
98
|
-
const html = helpers.getOuterHtmlSnippet
|
|
112
|
+
const html = helpers.getOuterHtmlSnippet
|
|
113
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
114
|
+
: el.outerHTML || '';
|
|
99
115
|
|
|
100
116
|
const baseOccurrence = {
|
|
101
117
|
selector: stableSelector,
|
|
102
118
|
html,
|
|
103
|
-
summary:
|
|
119
|
+
summary:
|
|
120
|
+
'This link shares an accessible name with other links on the page that lead to a different destination.',
|
|
104
121
|
hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
|
|
105
122
|
i18n: {
|
|
106
123
|
summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
|
|
@@ -130,7 +147,12 @@ function runInPage(ctx) {
|
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
if (occurrences.length) {
|
|
133
|
-
return {
|
|
150
|
+
return {
|
|
151
|
+
ruleId: rule.ruleId,
|
|
152
|
+
outcome: 'cantTell',
|
|
153
|
+
severity: rule.defaultSeverity || 'minor',
|
|
154
|
+
occurrences
|
|
155
|
+
};
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|