@surea11y/core 1.6.0 → 1.8.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -10,20 +10,25 @@
|
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @type manual
|
|
12
12
|
* @applicability
|
|
13
|
-
* Applies to <input type="image"> elements
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Applies to <input type="image"> elements that get a non-empty text
|
|
14
|
+
* alternative from any source: aria-labelledby (resolving to text),
|
|
15
|
+
* aria-label, alt or title. An element whose alt is present but empty is
|
|
16
|
+
* left to input-image-alt-decorative, which asks about that case. The
|
|
17
|
+
* element must be included in the accessibility tree, and
|
|
18
|
+
* role="presentation"/"none" takes it out of scope unless it is
|
|
19
|
+
* focusable, which restores its role.
|
|
17
20
|
* @expectation
|
|
18
|
-
* Human review is required to confirm that the provided text alternative is
|
|
21
|
+
* Human review is required to confirm that the provided text alternative is
|
|
22
|
+
* accurate and appropriate. Each occurrence lists every source present
|
|
23
|
+
* (data.details.sources), so the reviewer checks each one.
|
|
19
24
|
*/
|
|
20
25
|
|
|
21
26
|
const id = 'input-image-alt-quality';
|
|
22
27
|
|
|
23
28
|
const meta = {
|
|
24
|
-
title: '<input type="image">
|
|
29
|
+
title: '<input type="image"> text alternative must be appropriate (manual review)',
|
|
25
30
|
description:
|
|
26
|
-
'Flags <input type="image"> elements with non-empty alt
|
|
31
|
+
'Flags <input type="image"> elements with a non-empty text alternative (alt, aria-label, aria-labelledby or title) for human review of appropriateness.',
|
|
27
32
|
i18n: {
|
|
28
33
|
titleKey: 'inputImage_altQuality_title',
|
|
29
34
|
descriptionKey: 'inputImage_altQuality_description'
|
|
@@ -112,6 +117,53 @@ function runInPage(ctx) {
|
|
|
112
117
|
return !focusable;
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
const getAriaNameInfo =
|
|
121
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
122
|
+
|
|
123
|
+
// Every non-empty text-alternative source on the element, in accessible-name
|
|
124
|
+
// order: aria-labelledby (when it resolves to text), aria-label, alt, title.
|
|
125
|
+
// aria-labelledby wins over aria-label in the name, but a present aria-label
|
|
126
|
+
// is still listed, so each attribute present is asked about.
|
|
127
|
+
function collectTextAlternativeSources(el) {
|
|
128
|
+
const attr = (name) => {
|
|
129
|
+
try {
|
|
130
|
+
const v = el.getAttribute(name);
|
|
131
|
+
return v == null ? '' : String(v).trim();
|
|
132
|
+
} catch {
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const sources = [];
|
|
137
|
+
let name = '';
|
|
138
|
+
let aria = null;
|
|
139
|
+
if (getAriaNameInfo) {
|
|
140
|
+
try {
|
|
141
|
+
aria = getAriaNameInfo(el, ctx);
|
|
142
|
+
} catch {
|
|
143
|
+
aria = null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (aria && aria.present && aria.value) {
|
|
147
|
+
name = String(aria.value).trim();
|
|
148
|
+
sources.push(aria.mechanism);
|
|
149
|
+
if (aria.mechanism === 'aria-labelledby' && attr('aria-label')) sources.push('aria-label');
|
|
150
|
+
} else if (!getAriaNameInfo && attr('aria-label')) {
|
|
151
|
+
name = attr('aria-label');
|
|
152
|
+
sources.push('aria-label');
|
|
153
|
+
}
|
|
154
|
+
const altText = attr('alt');
|
|
155
|
+
if (altText) {
|
|
156
|
+
sources.push('alt');
|
|
157
|
+
if (!name) name = altText;
|
|
158
|
+
}
|
|
159
|
+
const titleText = attr('title');
|
|
160
|
+
if (titleText) {
|
|
161
|
+
sources.push('title');
|
|
162
|
+
if (!name) name = titleText;
|
|
163
|
+
}
|
|
164
|
+
return { sources, name, alt: altText };
|
|
165
|
+
}
|
|
166
|
+
|
|
115
167
|
const els = (() => {
|
|
116
168
|
try {
|
|
117
169
|
return Array.from(
|
|
@@ -146,24 +198,40 @@ function runInPage(ctx) {
|
|
|
146
198
|
|
|
147
199
|
if (isRolePresentationExcluded(el)) continue;
|
|
148
200
|
|
|
149
|
-
//
|
|
150
|
-
|
|
201
|
+
// alt="" with another name is input-image-alt-decorative's question, so
|
|
202
|
+
// it is left there rather than asked twice.
|
|
203
|
+
let altRaw;
|
|
204
|
+
try {
|
|
205
|
+
altRaw = el.getAttribute('alt');
|
|
206
|
+
} catch {
|
|
207
|
+
altRaw = null;
|
|
208
|
+
}
|
|
209
|
+
if (altRaw != null && String(altRaw).trim() === '') continue;
|
|
210
|
+
|
|
211
|
+
// Applies when any text-alternative source gives the control a non-empty
|
|
212
|
+
// name; each present source is listed so the reviewer checks all of them.
|
|
213
|
+
const alt = collectTextAlternativeSources(el);
|
|
214
|
+
if (!alt.sources.length) continue;
|
|
151
215
|
|
|
152
216
|
applicableCount += 1;
|
|
153
217
|
|
|
154
218
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
219
|
+
const sourcesText = alt.sources.join(', ');
|
|
220
|
+
|
|
221
|
+
const details = { name: alt.name, sources: alt.sources.slice() };
|
|
222
|
+
if (alt.alt) details.alt = alt.alt;
|
|
155
223
|
|
|
156
224
|
const baseOccurrence = {
|
|
157
|
-
summary:
|
|
158
|
-
hint: 'Ensure
|
|
225
|
+
summary: `Review the text alternative of this <input type="image"> (${sourcesText}) for accuracy and appropriateness.`,
|
|
226
|
+
hint: 'Ensure each listed text alternative describes the control’s action (e.g., “Search”, “Submit order”) in context.',
|
|
159
227
|
i18n: {
|
|
160
228
|
summaryKey: 'inputImage_altQuality_summary_cantTell',
|
|
161
229
|
hintKey: 'inputImage_altQuality_hint_cantTell',
|
|
162
|
-
params: { element: 'input[type=image]' }
|
|
230
|
+
params: { element: 'input[type=image]', sources: sourcesText }
|
|
163
231
|
},
|
|
164
232
|
data: {
|
|
165
233
|
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
166
|
-
details
|
|
234
|
+
details
|
|
167
235
|
}
|
|
168
236
|
};
|
|
169
237
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check landmark-complementary-is-top-level
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary The complementary landmark must not be nested inside another landmark
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
|
+
* @applicability
|
|
11
|
+
* Applies whenever the page contains at least one element carrying the
|
|
12
|
+
* complementary role: explicit role="complementary", or an <aside> that
|
|
13
|
+
* keeps its implicit role (see implementation notes on when it does not).
|
|
14
|
+
* @expectation
|
|
15
|
+
* No complementary candidate has an ancestor that is itself a landmark
|
|
16
|
+
* region. Complementary content supports the main content of the page and
|
|
17
|
+
* sits beside it; nested inside another landmark it is a section of that
|
|
18
|
+
* landmark instead, which is not what landmark navigation announces.
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
21
|
+
* `type: 'manual'` rule, matching its three siblings
|
|
22
|
+
* (`landmark-banner-is-top-level`, `landmark-contentinfo-is-top-level`,
|
|
23
|
+
* `landmark-main-is-top-level`). Landmark detection and the
|
|
24
|
+
* ancestor walk are identical to theirs; only the role being looked for
|
|
25
|
+
* differs.
|
|
26
|
+
* - An unnamed <aside> inside sectioning content has no complementary role
|
|
27
|
+
* per HTML-AAM, so it is not a candidate at all: reporting it would name a
|
|
28
|
+
* landmark that does not exist. A *named* one keeps the role wherever it
|
|
29
|
+
* sits, which is exactly the case worth review -- an <aside aria-label>
|
|
30
|
+
* inside <main> really is a complementary landmark nested in another
|
|
31
|
+
* landmark. `landmark-unique` and the sibling top-level rules already
|
|
32
|
+
* resolve <aside> this way, through the same shared helper.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const id = 'landmark-complementary-is-top-level';
|
|
36
|
+
|
|
37
|
+
const meta = {
|
|
38
|
+
title: 'Complementary landmark must be top-level',
|
|
39
|
+
description:
|
|
40
|
+
'Checks that the complementary landmark (role="complementary" or an <aside> that keeps its implicit role) is not nested inside another landmark region.',
|
|
41
|
+
i18n: {
|
|
42
|
+
titleKey: 'landmarkComplementaryIsTopLevel_title',
|
|
43
|
+
descriptionKey: 'landmarkComplementaryIsTopLevel_description'
|
|
44
|
+
},
|
|
45
|
+
helpUrl: null,
|
|
46
|
+
tags: ['best-practice', 'landmarks', 'structure', 'atomic', 'manual'],
|
|
47
|
+
wcagSc: [],
|
|
48
|
+
normativeMappings: [],
|
|
49
|
+
defaultSeverity: 'minor',
|
|
50
|
+
category: 'operable',
|
|
51
|
+
type: 'manual',
|
|
52
|
+
defaultConfidence: 'medium',
|
|
53
|
+
coverage: {}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function runInPage(ctx) {
|
|
57
|
+
const { document, root, helpers, rule } = ctx;
|
|
58
|
+
|
|
59
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
60
|
+
// ("runInPage MUST be self-contained").
|
|
61
|
+
function normalizeWs(s) {
|
|
62
|
+
return String(s || '')
|
|
63
|
+
.replace(/\s+/g, ' ')
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
68
|
+
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
69
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
70
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
71
|
+
function getAccessibleLandmarkName(el) {
|
|
72
|
+
try {
|
|
73
|
+
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
74
|
+
const info = helpers.getLandmarkNameInfo(el, ctx);
|
|
75
|
+
if (info && info.present && info.value) return normalizeWs(info.value);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getExplicitRoleToken(el) {
|
|
82
|
+
const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
|
|
83
|
+
if (!raw) return '';
|
|
84
|
+
return raw.split(/\s+/)[0].toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
88
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
89
|
+
// ancestor that suppresses its conditional implicit role": role-aware
|
|
90
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
91
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
92
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
93
|
+
// in src/core/aria-helpers.js for the full algorithm.
|
|
94
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
95
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
96
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
97
|
+
: false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getImplicitLandmarkRole(el) {
|
|
101
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
102
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
103
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
104
|
+
if (tag === 'main') return 'main';
|
|
105
|
+
if (tag === 'nav') return 'navigation';
|
|
106
|
+
if (tag === 'aside') {
|
|
107
|
+
// A named <aside> is never suppressed, even when nested. It keeps
|
|
108
|
+
// "complementary" when it has an accessible name, even inside
|
|
109
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
110
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
111
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
112
|
+
}
|
|
113
|
+
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
114
|
+
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LANDMARK_ROLES = new Set([
|
|
119
|
+
'banner',
|
|
120
|
+
'contentinfo',
|
|
121
|
+
'main',
|
|
122
|
+
'navigation',
|
|
123
|
+
'complementary',
|
|
124
|
+
'region',
|
|
125
|
+
'form',
|
|
126
|
+
'search'
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function getLandmarkRole(el) {
|
|
130
|
+
if (!el || !el.getAttribute) return '';
|
|
131
|
+
const explicit = getExplicitRoleToken(el);
|
|
132
|
+
if (explicit) return LANDMARK_ROLES.has(explicit) ? explicit : '';
|
|
133
|
+
return getImplicitLandmarkRole(el);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A candidate must actually carry the complementary role. An <aside> that
|
|
137
|
+
// HTML-AAM strips the role from is not a complementary landmark at all, so
|
|
138
|
+
// flagging it would report a landmark that does not exist.
|
|
139
|
+
function isComplementaryCandidate(el) {
|
|
140
|
+
return getLandmarkRole(el) === 'complementary';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasLandmarkAncestor(el) {
|
|
144
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
145
|
+
let p = el.parentElement;
|
|
146
|
+
while (p) {
|
|
147
|
+
if (getLandmarkRole(p)) return true;
|
|
148
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
149
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
150
|
+
if (scopeRoots.includes(p)) break;
|
|
151
|
+
p = p.parentElement;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
157
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
158
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
159
|
+
let nodes;
|
|
160
|
+
try {
|
|
161
|
+
nodes =
|
|
162
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
163
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
164
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
165
|
+
} catch {
|
|
166
|
+
nodes = [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const complementaries = [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
for (const el of nodes) {
|
|
172
|
+
if (!el || seen.has(el)) continue;
|
|
173
|
+
seen.add(el);
|
|
174
|
+
if (!isComplementaryCandidate(el)) continue;
|
|
175
|
+
|
|
176
|
+
// An aria-hidden candidate is removed from the accessibility tree
|
|
177
|
+
// entirely, so it is not part of the landmark structure assistive
|
|
178
|
+
// technology users navigate and there is no real landmark to call
|
|
179
|
+
// nested. queryAllSmart's default hidden-content policy only excludes
|
|
180
|
+
// "hard" CSS-based hiding (display:none, etc.), not the softer
|
|
181
|
+
// aria-hidden exclusion, so this needs its own check.
|
|
182
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
183
|
+
const elig = (() => {
|
|
184
|
+
try {
|
|
185
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
186
|
+
} catch {
|
|
187
|
+
return { eligible: true, reasons: [] };
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
if (elig && elig.eligible === false) continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
complementaries.push(el);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (complementaries.length === 0) {
|
|
197
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const occurrences = [];
|
|
201
|
+
for (const el of complementaries) {
|
|
202
|
+
if (!hasLandmarkAncestor(el)) continue;
|
|
203
|
+
|
|
204
|
+
occurrences.push(
|
|
205
|
+
helpers.reportOccurrence(el, {
|
|
206
|
+
summary: 'This complementary landmark is nested inside another landmark region.',
|
|
207
|
+
hint: 'Move the complementary landmark (<aside>/role="complementary") so it is not contained by another landmark; complementary content belongs beside the main content, not inside another region.',
|
|
208
|
+
i18n: {
|
|
209
|
+
summaryKey: 'landmarkComplementaryIsTopLevel_summary_cantTell',
|
|
210
|
+
hintKey: 'landmarkComplementaryIsTopLevel_hint_cantTell',
|
|
211
|
+
params: {}
|
|
212
|
+
},
|
|
213
|
+
data: {
|
|
214
|
+
details: { reasonCode: 'LANDMARK_COMPLEMENTARY_NOT_TOP_LEVEL' }
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (occurrences.length) {
|
|
221
|
+
return {
|
|
222
|
+
ruleId: rule.ruleId,
|
|
223
|
+
outcome: 'cantTell',
|
|
224
|
+
severity: rule.defaultSeverity || 'minor',
|
|
225
|
+
occurrences
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
* enclosing list item/table cell/paragraph's own text, or (format
|
|
25
25
|
* names only) a table's first-row header) naming what it belongs to.
|
|
26
26
|
* @implementation-notes
|
|
27
|
+
* - Phrase lists exist for en, de, es, fr and ja. English is always
|
|
28
|
+
* checked; the list for the element's own language (nearest lang
|
|
29
|
+
* attribute, across shadow roots) is added on top. Matching every list
|
|
30
|
+
* everywhere would flag words that are generic in one language and a
|
|
31
|
+
* real name in another ("Suite", "Plus" on an English page).
|
|
27
32
|
* - EXACT match only, on purpose, against small, well-established
|
|
28
33
|
* phrase lists, not a substring/contains check. "Read more about our
|
|
29
34
|
* privacy policy" does not match "read more"; only the bare phrase
|
|
@@ -52,7 +57,7 @@ const id = 'link-name-quality';
|
|
|
52
57
|
const meta = {
|
|
53
58
|
title: 'Link text should be descriptive, not generic',
|
|
54
59
|
description:
|
|
55
|
-
'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more") or a bare file-format name (e.g. "HTML", "PDF") with no adjacent context naming what it leads to, for manual review of whether the purpose is clear.',
|
|
60
|
+
'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more") or a bare file-format name (e.g. "HTML", "PDF") with no adjacent context naming what it leads to, for manual review of whether the purpose is clear. English phrases are always recognized, and German, Spanish, French or Japanese ones when the link is in that language.',
|
|
56
61
|
i18n: {
|
|
57
62
|
titleKey: 'linkNameQuality_title',
|
|
58
63
|
descriptionKey: 'linkNameQuality_description'
|
|
@@ -79,7 +84,7 @@ const meta = {
|
|
|
79
84
|
function runInPage(ctx) {
|
|
80
85
|
const { helpers, rule } = ctx;
|
|
81
86
|
|
|
82
|
-
const
|
|
87
|
+
const GENERIC_LINK_TEXT_EN = new Set([
|
|
83
88
|
'click here',
|
|
84
89
|
'here',
|
|
85
90
|
'click',
|
|
@@ -101,6 +106,100 @@ function runInPage(ctx) {
|
|
|
101
106
|
'info'
|
|
102
107
|
]);
|
|
103
108
|
|
|
109
|
+
const GENERIC_LINK_TEXT = {
|
|
110
|
+
en: GENERIC_LINK_TEXT_EN,
|
|
111
|
+
de: new Set([
|
|
112
|
+
'hier klicken',
|
|
113
|
+
'klicken sie hier',
|
|
114
|
+
'hier',
|
|
115
|
+
'klicken',
|
|
116
|
+
'mehr',
|
|
117
|
+
'mehr info',
|
|
118
|
+
'mehr infos',
|
|
119
|
+
'mehr informationen',
|
|
120
|
+
'weiterlesen',
|
|
121
|
+
'mehr lesen',
|
|
122
|
+
'mehr erfahren',
|
|
123
|
+
'weiter',
|
|
124
|
+
'details',
|
|
125
|
+
'mehr details',
|
|
126
|
+
'link',
|
|
127
|
+
'dieser link',
|
|
128
|
+
'los',
|
|
129
|
+
'herunterladen',
|
|
130
|
+
'mehr anzeigen',
|
|
131
|
+
'info'
|
|
132
|
+
]),
|
|
133
|
+
es: new Set([
|
|
134
|
+
'haga clic aquí',
|
|
135
|
+
'haz clic aquí',
|
|
136
|
+
'clic aquí',
|
|
137
|
+
'pulse aquí',
|
|
138
|
+
'pincha aquí',
|
|
139
|
+
'aquí',
|
|
140
|
+
'clic',
|
|
141
|
+
'más',
|
|
142
|
+
'más info',
|
|
143
|
+
'más información',
|
|
144
|
+
'leer más',
|
|
145
|
+
'saber más',
|
|
146
|
+
'seguir leyendo',
|
|
147
|
+
'continuar leyendo',
|
|
148
|
+
'continuar',
|
|
149
|
+
'detalles',
|
|
150
|
+
'más detalles',
|
|
151
|
+
'enlace',
|
|
152
|
+
'este enlace',
|
|
153
|
+
'ir',
|
|
154
|
+
'descargar',
|
|
155
|
+
'ver más',
|
|
156
|
+
'info'
|
|
157
|
+
]),
|
|
158
|
+
fr: new Set([
|
|
159
|
+
'cliquez ici',
|
|
160
|
+
'cliquer ici',
|
|
161
|
+
'ici',
|
|
162
|
+
'cliquez',
|
|
163
|
+
'plus',
|
|
164
|
+
"plus d'infos",
|
|
165
|
+
"plus d'informations",
|
|
166
|
+
'en savoir plus',
|
|
167
|
+
'lire la suite',
|
|
168
|
+
'la suite',
|
|
169
|
+
'suite',
|
|
170
|
+
'continuer',
|
|
171
|
+
'détails',
|
|
172
|
+
'plus de détails',
|
|
173
|
+
'lien',
|
|
174
|
+
'ce lien',
|
|
175
|
+
'télécharger',
|
|
176
|
+
'voir plus',
|
|
177
|
+
'info'
|
|
178
|
+
]),
|
|
179
|
+
ja: new Set([
|
|
180
|
+
'こちら',
|
|
181
|
+
'ここ',
|
|
182
|
+
'こちらをクリック',
|
|
183
|
+
'ここをクリック',
|
|
184
|
+
'クリック',
|
|
185
|
+
'詳しく',
|
|
186
|
+
'詳しくは',
|
|
187
|
+
'詳しくはこちら',
|
|
188
|
+
'詳細',
|
|
189
|
+
'詳細はこちら',
|
|
190
|
+
'詳細を見る',
|
|
191
|
+
'もっと見る',
|
|
192
|
+
'もっと読む',
|
|
193
|
+
'さらに詳しく',
|
|
194
|
+
'続きを読む',
|
|
195
|
+
'続き',
|
|
196
|
+
'リンク',
|
|
197
|
+
'このリンク',
|
|
198
|
+
'ダウンロード',
|
|
199
|
+
'情報'
|
|
200
|
+
])
|
|
201
|
+
};
|
|
202
|
+
|
|
104
203
|
const FORMAT_NAME_LINK_TEXT = new Set([
|
|
105
204
|
'html',
|
|
106
205
|
'pdf',
|
|
@@ -124,15 +223,42 @@ function runInPage(ctx) {
|
|
|
124
223
|
|
|
125
224
|
const CONTEXT_BLOCK_TAGS = new Set(['td', 'th', 'p', 'dd', 'blockquote', 'figcaption', 'dt']);
|
|
126
225
|
|
|
226
|
+
// NFKC folds full-width letters and punctuation (!, >) into their ASCII
|
|
227
|
+
// forms, and the curly apostrophe is folded so "plus d’infos" matches.
|
|
228
|
+
// Trailing arrows ("Read more »", 「詳しくはこちら→」) are decoration, not
|
|
229
|
+
// part of the phrase.
|
|
127
230
|
function normalize(s) {
|
|
128
231
|
return (s == null ? '' : String(s))
|
|
232
|
+
.normalize('NFKC')
|
|
233
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
129
234
|
.replace(/\s+/g, ' ')
|
|
130
235
|
.trim()
|
|
131
236
|
.toLowerCase()
|
|
132
|
-
.replace(/[
|
|
237
|
+
.replace(/[\s.,;:!?。、>»›→]+$/g, '')
|
|
133
238
|
.trim();
|
|
134
239
|
}
|
|
135
240
|
|
|
241
|
+
// Primary language subtag of the nearest lang attribute, crossing shadow
|
|
242
|
+
// roots; '' when none is declared. Phrase lists are matched in English
|
|
243
|
+
// plus this language, so a word that is generic in one language ("plus"
|
|
244
|
+
// in French) is not flagged when it is a real name in another.
|
|
245
|
+
function primaryLangOf(node) {
|
|
246
|
+
let n = node;
|
|
247
|
+
while (n) {
|
|
248
|
+
if (n.nodeType === 1 && n.getAttribute) {
|
|
249
|
+
const v = n.getAttribute('lang');
|
|
250
|
+
if (v != null) return v.trim().split('-')[0].toLowerCase();
|
|
251
|
+
}
|
|
252
|
+
n = n.parentNode || n.host || null;
|
|
253
|
+
}
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function inPhraseList(byLang, normalized, lang) {
|
|
258
|
+
if (byLang.en.has(normalized)) return true;
|
|
259
|
+
return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
|
|
260
|
+
}
|
|
261
|
+
|
|
136
262
|
function ownDirectText(el) {
|
|
137
263
|
let out = '';
|
|
138
264
|
const kids = el.childNodes || [];
|
|
@@ -240,7 +366,7 @@ function runInPage(ctx) {
|
|
|
240
366
|
|
|
241
367
|
applicableCount += 1;
|
|
242
368
|
|
|
243
|
-
const isGeneric = GENERIC_LINK_TEXT
|
|
369
|
+
const isGeneric = inPhraseList(GENERIC_LINK_TEXT, normalized, primaryLangOf(el));
|
|
244
370
|
const isFormatName = !isGeneric && FORMAT_NAME_LINK_TEXT.has(normalized);
|
|
245
371
|
if (!isGeneric && !isFormatName) continue;
|
|
246
372
|
|
|
@@ -13,6 +13,16 @@
|
|
|
13
13
|
* @expectation If a strong transcript/text-alternative signal is present (e.g., aria-describedby binding to
|
|
14
14
|
* a visible transcript block, or a nearby clearly labeled Transcript section/link), no occurrence is reported.
|
|
15
15
|
* Otherwise, the rule reports cantTell (insufficient evidence) for that media element.
|
|
16
|
+
* @implementation-notes
|
|
17
|
+
* - An <audio> without `controls` is hidden by the browser's own stylesheet
|
|
18
|
+
* (`display: none`), not by the author, and it still plays. So the rule
|
|
19
|
+
* does not use queryAllSmart, whose hidden-content filter would drop it in
|
|
20
|
+
* a real browser. It queries <audio>/<video> directly (scope,
|
|
21
|
+
* excludeSelectors and open shadow roots honoured) and applies the
|
|
22
|
+
* eligibility check to the element itself, except for an <audio> without
|
|
23
|
+
* `controls`: there it applies the check to the parent (or shadow host)
|
|
24
|
+
* and to the element's own `hidden` and `aria-hidden="true"`, since its
|
|
25
|
+
* computed style cannot tell the browser's hiding from the author's.
|
|
16
26
|
*/
|
|
17
27
|
|
|
18
28
|
const id = 'media-alternative-transcript-evidence';
|
|
@@ -48,14 +58,24 @@ function runInPage(ctx) {
|
|
|
48
58
|
const { document, root, helpers, rule } = ctx;
|
|
49
59
|
const safeRoot = root || document;
|
|
50
60
|
|
|
51
|
-
// Conservative keyword set (deterministic)
|
|
52
|
-
//
|
|
61
|
+
// Conservative keyword set (deterministic), matched in every language at
|
|
62
|
+
// once: a Japanese page may well link an English transcript. Keep this
|
|
63
|
+
// list strict to avoid false positives.
|
|
53
64
|
const TRANSCRIPT_TOKENS = [
|
|
54
65
|
'transcript',
|
|
55
66
|
'transcription',
|
|
56
67
|
'texte intégral',
|
|
57
68
|
'compte rendu',
|
|
58
|
-
'verbatim'
|
|
69
|
+
'verbatim',
|
|
70
|
+
'transkript',
|
|
71
|
+
'transkription',
|
|
72
|
+
'abschrift',
|
|
73
|
+
'textfassung',
|
|
74
|
+
'transcripción',
|
|
75
|
+
'transcripcion',
|
|
76
|
+
'トランスクリプト',
|
|
77
|
+
'文字起こし',
|
|
78
|
+
'書き起こし'
|
|
59
79
|
];
|
|
60
80
|
|
|
61
81
|
// Minimum transcript body length to be considered "substantial" when used as evidence.
|
|
@@ -88,7 +108,7 @@ function runInPage(ctx) {
|
|
|
88
108
|
try {
|
|
89
109
|
if (!el) return '';
|
|
90
110
|
return el.textContent || '';
|
|
91
|
-
} catch
|
|
111
|
+
} catch {
|
|
92
112
|
return '';
|
|
93
113
|
}
|
|
94
114
|
}
|
|
@@ -284,12 +304,49 @@ function runInPage(ctx) {
|
|
|
284
304
|
const occurrences = [];
|
|
285
305
|
let applicableCount = 0;
|
|
286
306
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
307
|
+
// Every match in scope, hidden or not (see @implementation-notes).
|
|
308
|
+
function queryAllUnfiltered(sel) {
|
|
309
|
+
const engineOptions = ctx.engineOptions || {};
|
|
310
|
+
const deep =
|
|
311
|
+
engineOptions.includeShadowDom !== false && typeof helpers.queryAllDeep === 'function';
|
|
312
|
+
const list = Array.from((deep ? helpers.queryAllDeep(sel) : helpers.queryAll(sel)) || []);
|
|
313
|
+
return typeof helpers.isExcluded === 'function'
|
|
314
|
+
? list.filter((el) => !helpers.isExcluded(el))
|
|
315
|
+
: list;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function hiddenByBrowserStylesheet(el) {
|
|
319
|
+
return (
|
|
320
|
+
String(el.tagName || '').toLowerCase() === 'audio' &&
|
|
321
|
+
!(el.hasAttribute && el.hasAttribute('controls'))
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The eligibility that decides whether the media element is in scope.
|
|
326
|
+
function getMediaEligibility(el) {
|
|
327
|
+
if (!hiddenByBrowserStylesheet(el)) return getEligibility(el);
|
|
328
|
+
if (el.hasAttribute('hidden')) {
|
|
329
|
+
return { eligible: false, reasons: ['hiddenAttr'], targetSet: 'acc', accEligible: false };
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
String(el.getAttribute('aria-hidden') || '')
|
|
333
|
+
.trim()
|
|
334
|
+
.toLowerCase() === 'true'
|
|
335
|
+
) {
|
|
336
|
+
return { eligible: false, reasons: ['ariaHidden'], targetSet: 'acc', accEligible: false };
|
|
337
|
+
}
|
|
338
|
+
let parent = el.parentElement;
|
|
339
|
+
if (!parent) {
|
|
340
|
+
const rootNode = el.getRootNode ? el.getRootNode() : null;
|
|
341
|
+
parent = rootNode && rootNode.host ? rootNode.host : null;
|
|
342
|
+
}
|
|
343
|
+
return parent ? getEligibility(parent) : getEligibility(el);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nodes = queryAllUnfiltered('audio,video');
|
|
290
347
|
|
|
291
348
|
for (const el of nodes) {
|
|
292
|
-
const eligInfo =
|
|
349
|
+
const eligInfo = getMediaEligibility(el);
|
|
293
350
|
if (!eligInfo || !eligInfo.eligible) continue;
|
|
294
351
|
|
|
295
352
|
applicableCount += 1;
|