@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,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check form-control-programmatic-label-quality
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Form controls should not rely on placeholder or title as the primary label
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to labelable native form controls exposed to assistive technologies:
|
|
11
|
+
* - input (excluding type=hidden|submit|reset|button|image)
|
|
12
|
+
* - select
|
|
13
|
+
* - textarea
|
|
14
|
+
* role="presentation"/"none" are excluded only when not focusable.
|
|
15
|
+
* @expectation
|
|
16
|
+
* If a control has a programmatic name, it should not rely ONLY on:
|
|
17
|
+
* - placeholder (non-empty)
|
|
18
|
+
* - title (non-empty)
|
|
19
|
+
* Prefer an associated <label> or aria-labelledby.
|
|
20
|
+
* @note
|
|
21
|
+
* This is a quality/best-practice signal. Controls may still meet SC 4.1.2
|
|
22
|
+
* while relying on placeholder/title; this rule surfaces that risk as cantTell.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const id = 'form-control-programmatic-label-quality';
|
|
26
|
+
|
|
27
|
+
const meta = {
|
|
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
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'moderate',
|
|
42
|
+
category: 'robust',
|
|
43
|
+
type: 'manual',
|
|
44
|
+
defaultConfidence: 'medium',
|
|
45
|
+
coverage: {
|
|
46
|
+
facetsBySc: {
|
|
47
|
+
'4.1.2': ['form-control-name-quality']
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function runInPage(ctx) {
|
|
53
|
+
const { document, root, helpers, rule } = ctx;
|
|
54
|
+
const safeRoot = root || document;
|
|
55
|
+
|
|
56
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
57
|
+
|
|
58
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
59
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
60
|
+
|
|
61
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
62
|
+
const getAriaLabelInfo = helpers && typeof helpers.getAriaLabelInfo === 'function' ? helpers.getAriaLabelInfo : null;
|
|
63
|
+
const getAriaLabelledByInfo = helpers && typeof helpers.getAriaLabelledByInfo === 'function' ? helpers.getAriaLabelledByInfo : null;
|
|
64
|
+
|
|
65
|
+
const getAttributeInfo = helpers && typeof helpers.getAttributeInfo === 'function' ? helpers.getAttributeInfo : null;
|
|
66
|
+
|
|
67
|
+
// Optional helper; we validate/normalize if present.
|
|
68
|
+
const getLabelMethod = helpers && typeof helpers.getLabelMethod === 'function' ? helpers.getLabelMethod : null;
|
|
69
|
+
|
|
70
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
71
|
+
|
|
72
|
+
const metrics = {
|
|
73
|
+
applicableCount: 0,
|
|
74
|
+
flaggedCount: 0,
|
|
75
|
+
byMethod: { label: 0, 'aria-labelledby': 0, 'aria-label': 0, title: 0, placeholder: 0, none: 0 }
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function safeQueryAll(sel) {
|
|
79
|
+
try {
|
|
80
|
+
if (queryAllSmart) return Array.from(queryAllSmart(sel) || []);
|
|
81
|
+
return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
|
|
82
|
+
} catch {
|
|
83
|
+
return [];
|
|
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
|
+
}
|
|
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
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function computeLabelMethodFallback(el) {
|
|
146
|
+
// Deterministic priority order:
|
|
147
|
+
// label > aria-labelledby > aria-label > title > placeholder
|
|
148
|
+
if (hasLabelAssociation(el)) return { method: 'label', value: '' };
|
|
149
|
+
|
|
150
|
+
if (getAriaLabelledByInfo) {
|
|
151
|
+
try {
|
|
152
|
+
const info = getAriaLabelledByInfo(el, ctx, { maxRefs: 8 });
|
|
153
|
+
const v = info && info.present ? trim(info.value) : '';
|
|
154
|
+
if (v) return { method: 'aria-labelledby', value: v };
|
|
155
|
+
} catch {}
|
|
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
|
+
|
|
172
|
+
return { method: 'none', value: '' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getLabelMethodSafe(el) {
|
|
176
|
+
if (getLabelMethod) {
|
|
177
|
+
try {
|
|
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';
|
|
193
|
+
|
|
194
|
+
const nodes = safeQueryAll(selector);
|
|
195
|
+
|
|
196
|
+
if (!nodes.length) {
|
|
197
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const occurrences = [];
|
|
201
|
+
|
|
202
|
+
for (const el of nodes) {
|
|
203
|
+
if (!el || !el.getAttribute) continue;
|
|
204
|
+
|
|
205
|
+
if (!isEligibleAcc(el)) continue;
|
|
206
|
+
|
|
207
|
+
const role = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return trim(el.getAttribute('role')).toLowerCase();
|
|
210
|
+
} catch {
|
|
211
|
+
return '';
|
|
212
|
+
}
|
|
213
|
+
})();
|
|
214
|
+
|
|
215
|
+
let fi = null;
|
|
216
|
+
if (getFocusableInfo) {
|
|
217
|
+
try { fi = getFocusableInfo(el, ctx); } catch { fi = null; }
|
|
218
|
+
}
|
|
219
|
+
const tabbable = !!(fi && fi.tabbable);
|
|
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
|
+
}
|
|
274
|
+
|
|
275
|
+
if (occurrences.length) {
|
|
276
|
+
return {
|
|
277
|
+
ruleId: rule.ruleId,
|
|
278
|
+
outcome: 'cantTell',
|
|
279
|
+
severity: rule.defaultSeverity || 'minor',
|
|
280
|
+
occurrences,
|
|
281
|
+
data: { details: { metrics } }
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail) —
|
|
286
|
+
// no applicable control relied on a weak (title/placeholder) primary
|
|
287
|
+
// label, so there is nothing to flag for review.
|
|
288
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [], data: { details: { metrics } } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check heading-order
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Heading levels must not skip a level going deeper
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies whenever the page contains two or more heading elements
|
|
10
|
+
* (native <h1>-<h6>, or explicit role="heading" with aria-level —
|
|
11
|
+
* default level 2 per the ARIA spec when aria-level is absent/invalid).
|
|
12
|
+
* @expectation
|
|
13
|
+
* In document order, each heading's level is no more than one greater
|
|
14
|
+
* than the highest heading level seen so far. Jumping deeper by more
|
|
15
|
+
* than one level (e.g. an <h1> followed directly by an <h3>, skipping
|
|
16
|
+
* <h2>) breaks the document outline assistive technology users rely on
|
|
17
|
+
* when navigating by heading. Going back to a shallower level at any
|
|
18
|
+
* point is always fine.
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
22
|
+
* header comment for the shared rationale/precedent.
|
|
23
|
+
* - Tracks the highest level reached so far (not just the immediately
|
|
24
|
+
* previous heading's level), so a sequence like h1, h2, h3, h2, h4 is
|
|
25
|
+
* correctly treated as valid (the h4 follows an h3 having already been
|
|
26
|
+
* reached, even though the immediately preceding heading was h2).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const id = 'heading-order';
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: 'Heading levels must not skip a level',
|
|
33
|
+
description: 'Checks that heading levels increase by at most one at a time in document order.',
|
|
34
|
+
i18n: {
|
|
35
|
+
titleKey: 'headingOrder_title',
|
|
36
|
+
descriptionKey: 'headingOrder_description'
|
|
37
|
+
},
|
|
38
|
+
helpUrl: null,
|
|
39
|
+
tags: ['best-practice', 'headings', 'structure', 'atomic', 'manual'],
|
|
40
|
+
wcagSc: [],
|
|
41
|
+
normativeMappings: [],
|
|
42
|
+
defaultSeverity: 'minor',
|
|
43
|
+
category: 'operable',
|
|
44
|
+
type: 'manual',
|
|
45
|
+
defaultConfidence: 'medium',
|
|
46
|
+
coverage: {}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function runInPage(ctx) {
|
|
50
|
+
const { document, helpers, rule } = ctx;
|
|
51
|
+
|
|
52
|
+
function normalizeWs(s) {
|
|
53
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getExplicitRoleToken(el) {
|
|
57
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
58
|
+
if (!raw) return '';
|
|
59
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getHeadingLevel(el) {
|
|
63
|
+
const explicit = getExplicitRoleToken(el);
|
|
64
|
+
if (explicit) {
|
|
65
|
+
if (explicit !== 'heading') return 0;
|
|
66
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
|
|
67
|
+
const n = parseInt(raw, 10);
|
|
68
|
+
return (Number.isFinite(n) && n >= 1) ? n : 2;
|
|
69
|
+
}
|
|
70
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
71
|
+
const m = /^h([1-6])$/.exec(tag);
|
|
72
|
+
return m ? parseInt(m[1], 10) : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let nodes = [];
|
|
76
|
+
try {
|
|
77
|
+
nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
78
|
+
} catch {
|
|
79
|
+
nodes = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const headings = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const el of nodes) {
|
|
85
|
+
if (!el || seen.has(el)) continue;
|
|
86
|
+
seen.add(el);
|
|
87
|
+
const level = getHeadingLevel(el);
|
|
88
|
+
if (level > 0) headings.push({ el, level });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (headings.length < 2) {
|
|
92
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const occurrences = [];
|
|
96
|
+
let highestSoFar = headings[0].level;
|
|
97
|
+
|
|
98
|
+
for (let i = 1; i < headings.length; i += 1) {
|
|
99
|
+
const { el, level } = headings[i];
|
|
100
|
+
|
|
101
|
+
if (level > highestSoFar + 1) {
|
|
102
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
103
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
104
|
+
|
|
105
|
+
occurrences.push({
|
|
106
|
+
selector: stableSelector,
|
|
107
|
+
html,
|
|
108
|
+
summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
|
|
109
|
+
hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
|
|
110
|
+
i18n: {
|
|
111
|
+
summaryKey: 'headingOrder_summary_cantTell',
|
|
112
|
+
hintKey: 'headingOrder_hint_cantTell',
|
|
113
|
+
params: { fromLevel: String(highestSoFar), toLevel: String(level) }
|
|
114
|
+
},
|
|
115
|
+
data: {
|
|
116
|
+
details: { reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL', fromLevel: highestSoFar, toLevel: level }
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (level > highestSoFar) highestSoFar = level;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (occurrences.length) {
|
|
125
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
126
|
+
}
|
|
127
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check identical-links-same-purpose
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Links with the same accessible name should lead to the same destination
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.4.9
|
|
9
|
+
* @applicability
|
|
10
|
+
* Any `a[href]` with a non-empty accessible name, grouped by that name
|
|
11
|
+
* (trimmed, whitespace-collapsed, case-folded).
|
|
12
|
+
* @expectation
|
|
13
|
+
* Within a page, links that share the same accessible name are expected
|
|
14
|
+
* to serve the same purpose (i.e. resolve to the same destination — the
|
|
15
|
+
* full resolved URL, including any fragment). Same-text-different-
|
|
16
|
+
* destination links are common and frequently intentional in real
|
|
17
|
+
* sites (e.g. repeated "Read more" links per article card), so this is
|
|
18
|
+
* authored as `type: 'manual'` (cantTell-capped, never fail) rather
|
|
19
|
+
* than a hard fail — flagging a real name/destination mismatch for
|
|
20
|
+
* human judgment instead of guessing intent.
|
|
21
|
+
* @implementation-notes
|
|
22
|
+
* - Destination comparison uses the DOM `.href` property (already
|
|
23
|
+
* resolved to an absolute URL by the engine/browser), not the raw
|
|
24
|
+
* `href` attribute — so relative vs. absolute forms of the same target
|
|
25
|
+
* are correctly treated as identical.
|
|
26
|
+
* - Only flags when a name group actually contains more than one
|
|
27
|
+
* distinct destination; a name reused for links that all point to the
|
|
28
|
+
* same place is not flagged.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const id = 'identical-links-same-purpose';
|
|
32
|
+
|
|
33
|
+
const meta = {
|
|
34
|
+
title: 'Links with the same accessible name should lead to the same destination',
|
|
35
|
+
description:
|
|
36
|
+
'Flags groups of links that share the same accessible name but resolve to more than one distinct destination, for manual review of whether they serve the same purpose.',
|
|
37
|
+
i18n: {
|
|
38
|
+
titleKey: 'identicalLinksSamePurpose_title',
|
|
39
|
+
descriptionKey: 'identicalLinksSamePurpose_description'
|
|
40
|
+
},
|
|
41
|
+
helpUrl: null,
|
|
42
|
+
tags: ['wcag2aaa', 'wcag249', 'navigation', 'atomic', 'manual'],
|
|
43
|
+
wcagSc: ['2.4.9'],
|
|
44
|
+
normativeMappings: [
|
|
45
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.4.9', title: 'Link Purpose (Link Only)', conformanceLevel: 'AAA' }
|
|
46
|
+
],
|
|
47
|
+
defaultSeverity: 'minor',
|
|
48
|
+
category: 'operable',
|
|
49
|
+
type: 'manual',
|
|
50
|
+
defaultConfidence: 'low',
|
|
51
|
+
coverage: { facetsBySc: { '2.4.9': ['identical-links-same-purpose-evidence'] } }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function runInPage(ctx) {
|
|
55
|
+
const { document, root, helpers, rule } = ctx;
|
|
56
|
+
const safeRoot = root || document;
|
|
57
|
+
|
|
58
|
+
function normName(s) {
|
|
59
|
+
return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
|
|
63
|
+
|
|
64
|
+
const groups = new Map(); // normName -> [{ el, href }]
|
|
65
|
+
let applicableCount = 0;
|
|
66
|
+
|
|
67
|
+
for (const el of nodes) {
|
|
68
|
+
if (!el || !el.getAttribute) continue;
|
|
69
|
+
|
|
70
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
71
|
+
const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
72
|
+
if (!eligible) continue;
|
|
73
|
+
|
|
74
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
75
|
+
const rawName = (nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim())
|
|
76
|
+
? nameInfo.value
|
|
77
|
+
: (el.textContent || '');
|
|
78
|
+
const name = normName(rawName);
|
|
79
|
+
if (!name) continue;
|
|
80
|
+
|
|
81
|
+
let href = '';
|
|
82
|
+
try { href = String(el.href || ''); } catch { href = ''; }
|
|
83
|
+
if (!href) continue;
|
|
84
|
+
|
|
85
|
+
applicableCount += 1;
|
|
86
|
+
|
|
87
|
+
if (!groups.has(name)) groups.set(name, []);
|
|
88
|
+
groups.get(name).push({ el, href });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const occurrences = [];
|
|
92
|
+
|
|
93
|
+
for (const [name, entries] of groups) {
|
|
94
|
+
const distinctHrefs = new Set(entries.map((e) => e.href));
|
|
95
|
+
if (distinctHrefs.size <= 1) continue;
|
|
96
|
+
|
|
97
|
+
for (const { el, href } of entries) {
|
|
98
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
99
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
100
|
+
|
|
101
|
+
const baseOccurrence = {
|
|
102
|
+
selector: stableSelector,
|
|
103
|
+
html,
|
|
104
|
+
summary: 'This link shares an accessible name with other links on the page that lead to a different destination.',
|
|
105
|
+
hint: 'Ensure links with the same text serve the same purpose, or make the link text distinct enough to describe each destination.',
|
|
106
|
+
i18n: {
|
|
107
|
+
summaryKey: 'identicalLinksSamePurpose_summary_cantTell',
|
|
108
|
+
hintKey: 'identicalLinksSamePurpose_hint_cantTell',
|
|
109
|
+
params: { name, destinationCount: String(distinctHrefs.size) }
|
|
110
|
+
},
|
|
111
|
+
data: {
|
|
112
|
+
details: {
|
|
113
|
+
reasonCode: 'SAME_NAME_DIFFERENT_DESTINATION',
|
|
114
|
+
name,
|
|
115
|
+
href,
|
|
116
|
+
distinctDestinationCount: distinctHrefs.size
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
122
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
123
|
+
} else {
|
|
124
|
+
occurrences.push(baseOccurrence);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (applicableCount === 0) {
|
|
130
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (occurrences.length) {
|
|
134
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|
|
138
|
+
// every name group already resolves to a single shared destination.
|
|
139
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check image-redundant-alt
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary An image's alt text must not duplicate adjacent visible text
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies to <img> elements with non-empty alt text whose immediate
|
|
10
|
+
* parent element also has other visible text content (i.e. text nodes
|
|
11
|
+
* besides the image itself — commonly an <a> or <button> wrapping both
|
|
12
|
+
* an icon image and a text label).
|
|
13
|
+
* @expectation
|
|
14
|
+
* The image's alt text is not the same (case-insensitive, normalized)
|
|
15
|
+
* as the other visible text already in the same parent. When both are
|
|
16
|
+
* present, assistive technology announces the same words twice for a
|
|
17
|
+
* single control (e.g. an icon-plus-text link where the icon's alt
|
|
18
|
+
* duplicates the link text).
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
22
|
+
* header comment for the shared rationale/precedent.
|
|
23
|
+
* - Deliberately scoped to the immediate parent only (not the whole
|
|
24
|
+
* ancestor chain or arbitrary siblings), to keep the "redundant"
|
|
25
|
+
* judgment high-confidence and avoid false positives from unrelated
|
|
26
|
+
* text elsewhere on the page that coincidentally matches.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const id = 'image-redundant-alt';
|
|
30
|
+
|
|
31
|
+
const meta = {
|
|
32
|
+
title: 'Image alt text must not duplicate adjacent visible text',
|
|
33
|
+
description: 'Checks that an <img> alt text is not identical to other visible text already present in its immediate parent element.',
|
|
34
|
+
i18n: {
|
|
35
|
+
titleKey: 'imageRedundantAlt_title',
|
|
36
|
+
descriptionKey: 'imageRedundantAlt_description'
|
|
37
|
+
},
|
|
38
|
+
helpUrl: null,
|
|
39
|
+
tags: ['best-practice', 'images', 'nontext', 'atomic', 'manual'],
|
|
40
|
+
wcagSc: [],
|
|
41
|
+
normativeMappings: [],
|
|
42
|
+
defaultSeverity: 'minor',
|
|
43
|
+
category: 'perceivable',
|
|
44
|
+
type: 'manual',
|
|
45
|
+
defaultConfidence: 'medium',
|
|
46
|
+
coverage: {}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function runInPage(ctx) {
|
|
50
|
+
const { document, root, helpers, rule } = ctx;
|
|
51
|
+
const safeRoot = root || document;
|
|
52
|
+
|
|
53
|
+
function normalizeWs(s) {
|
|
54
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getOwnTextExcludingImg(parent, imgEl) {
|
|
58
|
+
let text = '';
|
|
59
|
+
for (const child of parent.childNodes || []) {
|
|
60
|
+
if (child === imgEl) continue;
|
|
61
|
+
if (child.nodeType === 3) {
|
|
62
|
+
text += ' ' + (child.nodeValue || '');
|
|
63
|
+
} else if (child.nodeType === 1 && child !== imgEl) {
|
|
64
|
+
text += ' ' + (child.textContent || '');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return normalizeWs(text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]', safeRoot) : helpers.queryAll('img[alt]', safeRoot);
|
|
71
|
+
|
|
72
|
+
const occurrences = [];
|
|
73
|
+
let applicableCount = 0;
|
|
74
|
+
|
|
75
|
+
for (const el of nodes) {
|
|
76
|
+
if (!el || !el.getAttribute) continue;
|
|
77
|
+
const alt = normalizeWs(el.getAttribute('alt'));
|
|
78
|
+
if (!alt) continue;
|
|
79
|
+
|
|
80
|
+
const parent = el.parentElement;
|
|
81
|
+
if (!parent) continue;
|
|
82
|
+
|
|
83
|
+
const otherText = getOwnTextExcludingImg(parent, el);
|
|
84
|
+
if (!otherText) continue;
|
|
85
|
+
|
|
86
|
+
applicableCount += 1;
|
|
87
|
+
|
|
88
|
+
if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
|
|
89
|
+
|
|
90
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
91
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
92
|
+
|
|
93
|
+
occurrences.push({
|
|
94
|
+
selector: stableSelector,
|
|
95
|
+
html,
|
|
96
|
+
summary: 'This image\'s alt text duplicates other visible text right next to it.',
|
|
97
|
+
hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
|
|
98
|
+
i18n: {
|
|
99
|
+
summaryKey: 'imageRedundantAlt_summary_cantTell',
|
|
100
|
+
hintKey: 'imageRedundantAlt_hint_cantTell',
|
|
101
|
+
params: { alt }
|
|
102
|
+
},
|
|
103
|
+
data: {
|
|
104
|
+
details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (applicableCount === 0) {
|
|
110
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
111
|
+
}
|
|
112
|
+
if (occurrences.length) {
|
|
113
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
114
|
+
}
|
|
115
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { id, meta, runInPage };
|