@surea11y/core 1.4.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +18 -21
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check heading-quality
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Heading text should describe the content that follows, not be a placeholder
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.4.6
|
|
11
|
+
* @applicability
|
|
12
|
+
* Elements with a heading role (native <h1>-<h6>, or any element with
|
|
13
|
+
* an explicit role="heading") that are included in the accessibility
|
|
14
|
+
* tree and have a non-empty accessible name. A heading with no name at
|
|
15
|
+
* all is `empty-heading`'s concern, not this rule's.
|
|
16
|
+
* @expectation
|
|
17
|
+
* The heading's accessible name, normalized (whitespace-collapsed,
|
|
18
|
+
* case-folded, trailing punctuation stripped), is not a placeholder
|
|
19
|
+
* left in the markup: a known generic word ("heading", "title",
|
|
20
|
+
* "untitled", "lorem ipsum", ...), a numbered template slot ("Heading
|
|
21
|
+
* 2", "Section 3"), a filename, or a URL. None of these describe the
|
|
22
|
+
* topic or purpose of the content they introduce.
|
|
23
|
+
* @implementation-notes
|
|
24
|
+
* - Authored as `type: 'manual'` (cantTell-capped, never fail), for the
|
|
25
|
+
* same reason `link-name-quality` is: whether a heading describes the
|
|
26
|
+
* content that follows it is a reading-comprehension judgment. What is
|
|
27
|
+
* deterministic is that a placeholder string cannot describe anything,
|
|
28
|
+
* which is the signal this rule raises for review.
|
|
29
|
+
* - Only the placeholder half of ACT b49b2e is reachable this way. Its
|
|
30
|
+
* real expectation (the heading describes the first perceivable
|
|
31
|
+
* content after it) needs a human to read both, and ACT's own failed
|
|
32
|
+
* examples are all well-formed headings on the wrong topic
|
|
33
|
+
* ("Weather" over opening hours), which no markup-level check can
|
|
34
|
+
* catch. Mapped as a partial match; see docs/ACT_RULE_MAPPING.md.
|
|
35
|
+
* - Matching is EXACT against the curated list, never a substring check,
|
|
36
|
+
* the same precision-over-recall trade-off `link-name-quality`'s own
|
|
37
|
+
* header comment reasons through: "Heading into the storm" is a real
|
|
38
|
+
* heading and must not match "heading".
|
|
39
|
+
* - Short headings are intentionally NOT flagged. ACT b49b2e is explicit
|
|
40
|
+
* that a heading may be a single word or even a single character and
|
|
41
|
+
* still be descriptive; its own passed example is `<h1>A</h1>` above a
|
|
42
|
+
* glossary section, so length carries no signal here, unlike in
|
|
43
|
+
* `page-title-patterns` where a <8-character page title is a real one.
|
|
44
|
+
* - Heading resolution (native tag, explicit role, and the
|
|
45
|
+
* role="none"-plus-global-ARIA-attribute conflict-resolution case)
|
|
46
|
+
* mirrors `empty-heading`, which owns the same applicability question;
|
|
47
|
+
* the two rules split the decision, one on presence, one on quality.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const id = 'heading-quality';
|
|
51
|
+
|
|
52
|
+
const meta = {
|
|
53
|
+
title: 'Heading text should be descriptive, not a placeholder',
|
|
54
|
+
description:
|
|
55
|
+
'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL.',
|
|
56
|
+
i18n: {
|
|
57
|
+
titleKey: 'headingQuality_title',
|
|
58
|
+
descriptionKey: 'headingQuality_description'
|
|
59
|
+
},
|
|
60
|
+
helpUrl: null,
|
|
61
|
+
tags: ['wcag2aa', 'wcag246', 'headings', 'structure', 'quality', 'atomic', 'manual'],
|
|
62
|
+
wcagSc: ['2.4.6'],
|
|
63
|
+
normativeMappings: [
|
|
64
|
+
{
|
|
65
|
+
standard: 'WCAG',
|
|
66
|
+
version: '2.2',
|
|
67
|
+
requirement: '2.4.6',
|
|
68
|
+
title: 'Headings and Labels',
|
|
69
|
+
conformanceLevel: 'AA'
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
defaultSeverity: 'minor',
|
|
73
|
+
category: 'operable',
|
|
74
|
+
type: 'manual',
|
|
75
|
+
defaultConfidence: 'medium',
|
|
76
|
+
coverage: { facetsBySc: { '2.4.6': ['heading-text-descriptive-evidence'] } }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function runInPage(ctx) {
|
|
80
|
+
const { document, helpers, rule } = ctx;
|
|
81
|
+
|
|
82
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
83
|
+
// ("runInPage MUST be self-contained").
|
|
84
|
+
const PLACEHOLDER_HEADING_TEXT = new Set([
|
|
85
|
+
'heading',
|
|
86
|
+
'header',
|
|
87
|
+
'headline',
|
|
88
|
+
'subheading',
|
|
89
|
+
'sub heading',
|
|
90
|
+
'sub-heading',
|
|
91
|
+
'title',
|
|
92
|
+
'subtitle',
|
|
93
|
+
'sub title',
|
|
94
|
+
'untitled',
|
|
95
|
+
'section',
|
|
96
|
+
'new section',
|
|
97
|
+
'chapter',
|
|
98
|
+
'content',
|
|
99
|
+
'main content',
|
|
100
|
+
'text',
|
|
101
|
+
'sample text',
|
|
102
|
+
'placeholder',
|
|
103
|
+
'placeholder text',
|
|
104
|
+
'lorem ipsum',
|
|
105
|
+
'insert title here',
|
|
106
|
+
'your title here',
|
|
107
|
+
'add a title',
|
|
108
|
+
'tbd',
|
|
109
|
+
'to be defined',
|
|
110
|
+
'todo',
|
|
111
|
+
'to do',
|
|
112
|
+
'n/a',
|
|
113
|
+
'test',
|
|
114
|
+
'example',
|
|
115
|
+
'default'
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// A numbered template slot left as authored: "Heading 2", "Section 3",
|
|
119
|
+
// "Chapter #1". The word alone is already in the set above; this catches
|
|
120
|
+
// the same words carrying an index.
|
|
121
|
+
const NUMBERED_PLACEHOLDER =
|
|
122
|
+
/^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/;
|
|
123
|
+
|
|
124
|
+
const FILENAME_LIKE =
|
|
125
|
+
/^[\w\s\-.,()[\]]+\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|tiff?|pdf|docx?|xlsx?|pptx?|html?|txt|csv|zip)$/;
|
|
126
|
+
|
|
127
|
+
const URL_LIKE = /^(https?:\/\/|www\.)\S+$/;
|
|
128
|
+
|
|
129
|
+
function normalizeWs(s) {
|
|
130
|
+
return String(s || '')
|
|
131
|
+
.replace(/\s+/g, ' ')
|
|
132
|
+
.trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalize(s) {
|
|
136
|
+
return normalizeWs(s)
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[.,;:!?]+$/g, '')
|
|
139
|
+
.trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getExplicitRoleToken(el) {
|
|
143
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
144
|
+
if (!raw) return '';
|
|
145
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The WAI-ARIA Global States and Properties set, same list and same
|
|
149
|
+
// purpose as empty-heading's copy: a native h1-h6 marked
|
|
150
|
+
// role="none"/"presentation" reverts to its heading role when it carries
|
|
151
|
+
// any of these, the attribute's presence being what triggers conflict
|
|
152
|
+
// resolution, not its value.
|
|
153
|
+
const GLOBAL_ARIA_ATTRS = [
|
|
154
|
+
'aria-atomic',
|
|
155
|
+
'aria-braillelabel',
|
|
156
|
+
'aria-brailleroledescription',
|
|
157
|
+
'aria-busy',
|
|
158
|
+
'aria-controls',
|
|
159
|
+
'aria-current',
|
|
160
|
+
'aria-describedby',
|
|
161
|
+
'aria-description',
|
|
162
|
+
'aria-details',
|
|
163
|
+
'aria-disabled',
|
|
164
|
+
'aria-dropeffect',
|
|
165
|
+
'aria-errormessage',
|
|
166
|
+
'aria-flowto',
|
|
167
|
+
'aria-grabbed',
|
|
168
|
+
'aria-haspopup',
|
|
169
|
+
'aria-hidden',
|
|
170
|
+
'aria-invalid',
|
|
171
|
+
'aria-keyshortcuts',
|
|
172
|
+
'aria-label',
|
|
173
|
+
'aria-labelledby',
|
|
174
|
+
'aria-live',
|
|
175
|
+
'aria-owns',
|
|
176
|
+
'aria-relevant',
|
|
177
|
+
'aria-roledescription'
|
|
178
|
+
];
|
|
179
|
+
|
|
180
|
+
function hasGlobalAriaAttr(el) {
|
|
181
|
+
for (const attr of GLOBAL_ARIA_ATTRS) {
|
|
182
|
+
if (el.getAttribute && el.getAttribute(attr) != null) return true;
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isHeading(el) {
|
|
188
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
189
|
+
const isNativeHeadingTag = /^h[1-6]$/.test(tag);
|
|
190
|
+
|
|
191
|
+
const explicit = getExplicitRoleToken(el);
|
|
192
|
+
if (!explicit) return isNativeHeadingTag;
|
|
193
|
+
if (explicit === 'heading') return true;
|
|
194
|
+
if ((explicit === 'none' || explicit === 'presentation') && isNativeHeadingTag) {
|
|
195
|
+
return hasGlobalAriaAttr(el);
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Same name resolution empty-heading uses, so the two rules agree on
|
|
201
|
+
// what a heading is called: aria-label, then aria-labelledby, then the
|
|
202
|
+
// shared accname-aligned "name from content" helper, then title.
|
|
203
|
+
function getAccessibleNameText(el) {
|
|
204
|
+
const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
|
|
205
|
+
if (al) return al;
|
|
206
|
+
|
|
207
|
+
const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
|
|
208
|
+
if (alb) {
|
|
209
|
+
const parts = [];
|
|
210
|
+
for (const refId of alb.split(/\s+/).filter(Boolean)) {
|
|
211
|
+
try {
|
|
212
|
+
const ref = document.getElementById(refId);
|
|
213
|
+
if (ref) {
|
|
214
|
+
const t = normalizeWs(ref.textContent);
|
|
215
|
+
if (t) parts.push(t);
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
// ignore an unusable reference
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const joined = normalizeWs(parts.join(' '));
|
|
222
|
+
if (joined) return joined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (helpers && typeof helpers.getContentNameInfo === 'function') {
|
|
226
|
+
try {
|
|
227
|
+
const info = helpers.getContentNameInfo(el, ctx);
|
|
228
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
229
|
+
} catch {
|
|
230
|
+
// fall through to title
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return normalizeWs(el.getAttribute && el.getAttribute('title'));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const isAccTreeEligible =
|
|
238
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
239
|
+
|
|
240
|
+
function isEligible(el) {
|
|
241
|
+
if (!isAccTreeEligible) return true;
|
|
242
|
+
try {
|
|
243
|
+
const r = isAccTreeEligible(el, ctx);
|
|
244
|
+
if (typeof r === 'boolean') return r;
|
|
245
|
+
return !!(r && r.eligible);
|
|
246
|
+
} catch {
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function classify(normalized) {
|
|
252
|
+
if (PLACEHOLDER_HEADING_TEXT.has(normalized) || NUMBERED_PLACEHOLDER.test(normalized)) {
|
|
253
|
+
return 'PLACEHOLDER_HEADING_TEXT';
|
|
254
|
+
}
|
|
255
|
+
if (URL_LIKE.test(normalized)) return 'URL_LIKE_HEADING';
|
|
256
|
+
if (FILENAME_LIKE.test(normalized)) return 'FILENAME_LIKE_HEADING';
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const SUMMARY_KEY_BY_REASON = {
|
|
261
|
+
PLACEHOLDER_HEADING_TEXT: 'headingQuality_summary_cantTell_placeholder',
|
|
262
|
+
FILENAME_LIKE_HEADING: 'headingQuality_summary_cantTell_filename',
|
|
263
|
+
URL_LIKE_HEADING: 'headingQuality_summary_cantTell_url'
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const selector = 'h1, h2, h3, h4, h5, h6, [role]';
|
|
267
|
+
const nodes = helpers.queryAllSmart
|
|
268
|
+
? helpers.queryAllSmart(selector)
|
|
269
|
+
: helpers.queryAll(selector);
|
|
270
|
+
|
|
271
|
+
const occurrences = [];
|
|
272
|
+
let applicableCount = 0;
|
|
273
|
+
|
|
274
|
+
for (const el of nodes) {
|
|
275
|
+
if (!el || !el.getAttribute) continue;
|
|
276
|
+
if (!isHeading(el)) continue;
|
|
277
|
+
if (!isEligible(el)) continue;
|
|
278
|
+
|
|
279
|
+
const rawName = getAccessibleNameText(el);
|
|
280
|
+
const normalized = normalize(rawName);
|
|
281
|
+
if (!normalized) continue; // no name at all: empty-heading's concern.
|
|
282
|
+
|
|
283
|
+
applicableCount += 1;
|
|
284
|
+
|
|
285
|
+
const reasonCode = classify(normalized);
|
|
286
|
+
if (!reasonCode) continue;
|
|
287
|
+
|
|
288
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
289
|
+
? (() => {
|
|
290
|
+
try {
|
|
291
|
+
return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
})()
|
|
296
|
+
: null;
|
|
297
|
+
|
|
298
|
+
const name = normalizeWs(rawName);
|
|
299
|
+
const summaryByReason = {
|
|
300
|
+
PLACEHOLDER_HEADING_TEXT: `This heading's accessible name ("${name}") is a placeholder rather than a description of the content it introduces.`,
|
|
301
|
+
FILENAME_LIKE_HEADING: `This heading's accessible name ("${name}") is a filename rather than a description of the content it introduces.`,
|
|
302
|
+
URL_LIKE_HEADING: `This heading's accessible name ("${name}") is a URL rather than a description of the content it introduces.`
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
occurrences.push(
|
|
306
|
+
helpers.reportOccurrence(el, {
|
|
307
|
+
summary: summaryByReason[reasonCode],
|
|
308
|
+
hint: 'Rewrite the heading so it names the topic or purpose of the content that follows it.',
|
|
309
|
+
i18n: {
|
|
310
|
+
summaryKey: SUMMARY_KEY_BY_REASON[reasonCode],
|
|
311
|
+
hintKey: 'headingQuality_hint_cantTell',
|
|
312
|
+
params: { name }
|
|
313
|
+
},
|
|
314
|
+
data: {
|
|
315
|
+
details: { reasonCode, name, normalizedName: normalized },
|
|
316
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (applicableCount === 0) {
|
|
323
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (occurrences.length) {
|
|
327
|
+
return {
|
|
328
|
+
ruleId: rule.ruleId,
|
|
329
|
+
outcome: 'cantTell',
|
|
330
|
+
severity: rule.defaultSeverity || 'minor',
|
|
331
|
+
occurrences
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -9,22 +9,30 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.4.9
|
|
11
11
|
* @applicability
|
|
12
|
-
* Any `a[href]` with a non-empty accessible name,
|
|
13
|
-
* (trimmed, whitespace-collapsed, case-folded).
|
|
12
|
+
* Any `a[href]` or `[role="link"]` with a non-empty accessible name,
|
|
13
|
+
* grouped by that name (trimmed, whitespace-collapsed, case-folded).
|
|
14
14
|
* @expectation
|
|
15
15
|
* Within a page, links that share the same accessible name are expected
|
|
16
|
-
* to serve the same purpose (i.e. resolve to the same destination
|
|
16
|
+
* to serve the same purpose (i.e. resolve to the same destination, the
|
|
17
17
|
* full resolved URL, including any fragment). Same-text-different-
|
|
18
18
|
* destination links are common and frequently intentional in real
|
|
19
19
|
* sites (e.g. repeated "Read more" links per article card), so this is
|
|
20
20
|
* authored as `type: 'manual'` (cantTell-capped, never fail) rather
|
|
21
|
-
* than a hard fail
|
|
21
|
+
* than a hard fail, flagging a real name/destination mismatch for
|
|
22
22
|
* human judgment instead of guessing intent.
|
|
23
23
|
* @implementation-notes
|
|
24
24
|
* - Destination comparison uses the DOM `.href` property (already
|
|
25
25
|
* resolved to an absolute URL by the engine/browser), not the raw
|
|
26
|
-
* `href` attribute
|
|
26
|
+
* `href` attribute, so relative vs. absolute forms of the same target
|
|
27
27
|
* are correctly treated as identical.
|
|
28
|
+
* - A `role="link"` element with no `href` (ACT fd3a94/b20e66's own
|
|
29
|
+
* `<span role="link" onclick="location='...'">`-style examples) falls
|
|
30
|
+
* back to extracting the destination from a `location`/`location.href`
|
|
31
|
+
* assignment or `location.assign(...)`/`location.replace(...)` call in
|
|
32
|
+
* its `onclick` attribute, a literal string already present in
|
|
33
|
+
* markup, not something that requires executing script. An unrecognized
|
|
34
|
+
* onclick shape is simply not resolved (this rule is cantTell-capped,
|
|
35
|
+
* so a missed destination costs recall, never a false fail).
|
|
28
36
|
* - Only flags when a name group actually contains more than one
|
|
29
37
|
* distinct destination; a name reused for links that all point to the
|
|
30
38
|
* same place is not flagged.
|
|
@@ -66,9 +74,34 @@ function runInPage(ctx) {
|
|
|
66
74
|
return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
67
75
|
}
|
|
68
76
|
|
|
77
|
+
// A location-assignment call in an onclick attribute is a literal
|
|
78
|
+
// string already present in markup (`location='...'`,
|
|
79
|
+
// `location.href='...'`, `window.location.assign('...')`, etc.) --
|
|
80
|
+
// reading it needs no script execution, just a regex over the
|
|
81
|
+
// attribute value.
|
|
82
|
+
const ONCLICK_LOCATION_RE =
|
|
83
|
+
/(?:window\s*\.\s*)?location(?:\s*\.\s*href)?\s*=\s*['"]([^'"]+)['"]|(?:window\s*\.\s*)?location\s*\.\s*(?:assign|replace)\s*\(\s*['"]([^'"]+)['"]/;
|
|
84
|
+
|
|
85
|
+
function resolveOnclickLocation(el) {
|
|
86
|
+
try {
|
|
87
|
+
const onclick = el.getAttribute('onclick') || '';
|
|
88
|
+
if (!onclick) return '';
|
|
89
|
+
const m = onclick.match(ONCLICK_LOCATION_RE);
|
|
90
|
+
const raw = m ? m[1] || m[2] : '';
|
|
91
|
+
if (!raw) return '';
|
|
92
|
+
try {
|
|
93
|
+
return new URL(raw, el.ownerDocument.baseURI).href;
|
|
94
|
+
} catch {
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
const nodes = helpers.queryAllSmart
|
|
70
|
-
? helpers.queryAllSmart('a[href]')
|
|
71
|
-
: helpers.queryAll('a[href]');
|
|
103
|
+
? helpers.queryAllSmart('a[href], [role="link"]')
|
|
104
|
+
: helpers.queryAll('a[href], [role="link"]');
|
|
72
105
|
|
|
73
106
|
const groups = new Map(); // normName -> [{ el, href }]
|
|
74
107
|
let applicableCount = 0;
|
|
@@ -81,20 +114,48 @@ function runInPage(ctx) {
|
|
|
81
114
|
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
82
115
|
if (!eligible) continue;
|
|
83
116
|
|
|
117
|
+
// getAccessibleNameInfo alone only covers ARIA/label/title naming, not
|
|
118
|
+
// name-from-content -- a raw el.textContent fallback misses a link
|
|
119
|
+
// named only by a descendant image's alt text (e.g.
|
|
120
|
+
// <a href="..."><img alt="ACT rules"></a> has no text nodes at all).
|
|
121
|
+
// getContentNameInfo is the same content-aware helper link-name-present
|
|
122
|
+
// uses for exactly this reason.
|
|
84
123
|
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
85
|
-
|
|
86
|
-
nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim()
|
|
87
|
-
|
|
88
|
-
|
|
124
|
+
let rawName =
|
|
125
|
+
nameInfo && typeof nameInfo.value === 'string' && nameInfo.value.trim() ? nameInfo.value : '';
|
|
126
|
+
if (!rawName) {
|
|
127
|
+
const contentInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
|
|
128
|
+
rawName =
|
|
129
|
+
contentInfo && typeof contentInfo.value === 'string' && contentInfo.value.trim()
|
|
130
|
+
? contentInfo.value
|
|
131
|
+
: el.textContent || '';
|
|
132
|
+
}
|
|
89
133
|
const name = normName(rawName);
|
|
90
134
|
if (!name) continue;
|
|
91
135
|
|
|
136
|
+
// `.href` on an SVG <a> is an SVGAnimatedString, not a plain string
|
|
137
|
+
// IDL property like HTMLAnchorElement.href -- `String(el.href)` would
|
|
138
|
+
// stringify the wrapper object itself, not its value, silently
|
|
139
|
+
// grouping every SVG link under the same bogus "destination". Fall
|
|
140
|
+
// back to the raw attribute, resolved against the document's base
|
|
141
|
+
// URL, whenever `.href` isn't already a usable string.
|
|
92
142
|
let href;
|
|
93
143
|
try {
|
|
94
|
-
href =
|
|
144
|
+
href = typeof el.href === 'string' ? el.href : '';
|
|
145
|
+
if (!href) {
|
|
146
|
+
const raw = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
147
|
+
if (raw) {
|
|
148
|
+
try {
|
|
149
|
+
href = new URL(raw, el.ownerDocument.baseURI).href;
|
|
150
|
+
} catch {
|
|
151
|
+
href = raw;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
95
155
|
} catch {
|
|
96
156
|
href = '';
|
|
97
157
|
}
|
|
158
|
+
if (!href) href = resolveOnclickLocation(el);
|
|
98
159
|
if (!href) continue;
|
|
99
160
|
|
|
100
161
|
applicableCount += 1;
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* @check image-redundant-alt
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary An image's alt text must not duplicate adjacent visible text
|
|
9
|
-
* @standard Best Practices (no formal WCAG Success Criterion
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
11
|
* Applies to <img> elements with non-empty alt text whose immediate
|
|
12
12
|
* parent element also has other visible text content (i.e. text nodes
|
|
13
|
-
* besides the image itself
|
|
13
|
+
* besides the image itself, commonly an <a> or <button> wrapping both
|
|
14
14
|
* an icon image and a text label).
|
|
15
15
|
* @expectation
|
|
16
16
|
* The image's alt text is not the same (case-insensitive, normalized)
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
* single control (e.g. an icon-plus-text link where the icon's alt
|
|
20
20
|
* duplicates the link text).
|
|
21
21
|
* @implementation-notes
|
|
22
|
-
* - Not WCAG-normative
|
|
22
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
23
23
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
24
24
|
* header comment for the shared rationale/precedent.
|
|
25
|
-
* -
|
|
25
|
+
* - Scoped to the immediate parent only on purpose (not the whole
|
|
26
26
|
* ancestor chain or arbitrary siblings), to keep the "redundant"
|
|
27
27
|
* judgment high-confidence and avoid false positives from unrelated
|
|
28
28
|
* text elsewhere on the page that coincidentally matches.
|
|
@@ -111,23 +111,20 @@ function runInPage(ctx) {
|
|
|
111
111
|
|
|
112
112
|
if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
|
|
129
|
-
}
|
|
130
|
-
});
|
|
114
|
+
occurrences.push(
|
|
115
|
+
helpers.reportOccurrence(el, {
|
|
116
|
+
summary: "This image's alt text duplicates other visible text right next to it.",
|
|
117
|
+
hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
|
|
118
|
+
i18n: {
|
|
119
|
+
summaryKey: 'imageRedundantAlt_summary_cantTell',
|
|
120
|
+
hintKey: 'imageRedundantAlt_hint_cantTell',
|
|
121
|
+
params: { alt }
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
);
|
|
131
128
|
}
|
|
132
129
|
|
|
133
130
|
if (applicableCount === 0) {
|