@surea11y/core 1.7.0 → 1.8.1
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 +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +2 -2
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +26 -11
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- 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 +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- 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.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -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 +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- 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-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 +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- 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 +109 -5
- 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/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/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 +14285 -2219
- 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/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 +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -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;
|
|
@@ -20,9 +20,14 @@
|
|
|
20
20
|
* event equivalents), or `onfocus`/`onblur` (the standard substitute
|
|
21
21
|
* for hover-triggered behavior: focus/blur are the keyboard-
|
|
22
22
|
* navigable analog to mouseover/mouseout, per WCAG technique G90).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* A handler is reachable only if keyboard events can reach it:
|
|
24
|
+
* `onfocus`/`onblur` when the element itself can take focus, and a key
|
|
25
|
+
* handler when the element or one of its descendants can (key events
|
|
26
|
+
* bubble, focus events do not). Otherwise the element's mouse-driven
|
|
27
|
+
* behavior (a hover tooltip, a custom dropdown, a drag interaction) has
|
|
28
|
+
* no way to be triggered by a keyboard-only user, and it is flagged with
|
|
29
|
+
* a reason saying whether the keyboard handlers are missing or cannot
|
|
30
|
+
* run.
|
|
26
31
|
* @implementation-notes
|
|
27
32
|
* - Authored as `type: 'manual'` (cantTell-capped, never fail), not
|
|
28
33
|
* `automatic`: this can only see inline `on*="..."` HTML attributes.
|
|
@@ -93,6 +98,35 @@ function runInPage(ctx) {
|
|
|
93
98
|
return (v == null ? '' : String(v)).trim();
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
const FOCUS_ATTRS = ['onfocus', 'onblur'];
|
|
102
|
+
const FOCUSABLE_CANDIDATES =
|
|
103
|
+
'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]';
|
|
104
|
+
|
|
105
|
+
function canTakeFocus(el) {
|
|
106
|
+
if (!helpers.getFocusableInfo) return true;
|
|
107
|
+
try {
|
|
108
|
+
const info = helpers.getFocusableInfo(el, ctx);
|
|
109
|
+
return !!(info && info.focusable);
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Focus and blur fire only on the element that takes focus. Key events are
|
|
116
|
+
// dispatched to the focused element and bubble, so a key handler also runs
|
|
117
|
+
// for a focusable descendant.
|
|
118
|
+
function keyboardCanReach(el, keyboardAttrs) {
|
|
119
|
+
if (canTakeFocus(el)) return true;
|
|
120
|
+
if (keyboardAttrs.every((a) => FOCUS_ATTRS.indexOf(a) !== -1)) return false;
|
|
121
|
+
let descendants;
|
|
122
|
+
try {
|
|
123
|
+
descendants = Array.from(el.querySelectorAll(FOCUSABLE_CANDIDATES));
|
|
124
|
+
} catch {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return descendants.some((d) => canTakeFocus(d));
|
|
128
|
+
}
|
|
129
|
+
|
|
96
130
|
const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
|
|
97
131
|
const nodes = helpers.queryAllSmart
|
|
98
132
|
? helpers.queryAllSmart(selector)
|
|
@@ -114,13 +148,40 @@ function runInPage(ctx) {
|
|
|
114
148
|
|
|
115
149
|
applicableCount += 1;
|
|
116
150
|
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
151
|
+
const presentKeyboardAttrs = KEYBOARD_EQUIV_ATTRS.filter((a) => trim(el.getAttribute(a)));
|
|
152
|
+
if (presentKeyboardAttrs.length && keyboardCanReach(el, presentKeyboardAttrs)) continue;
|
|
153
|
+
const unreachable = presentKeyboardAttrs.length > 0;
|
|
119
154
|
|
|
120
155
|
const eligInfo = helpers.getEligibilityInfo
|
|
121
156
|
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
122
157
|
: null;
|
|
123
158
|
|
|
159
|
+
if (unreachable) {
|
|
160
|
+
occurrences.push(
|
|
161
|
+
helpers.reportOccurrence(el, {
|
|
162
|
+
summary: `This element has ${presentMouseAttrs.join(', ')} and ${presentKeyboardAttrs.join(', ')}, but it cannot take keyboard focus, so the keyboard handlers never run.`,
|
|
163
|
+
hint: 'Make the element focusable (use a native control, or add tabindex="0"), or move the handlers to a focusable element, so this functionality is also reachable by keyboard.',
|
|
164
|
+
i18n: {
|
|
165
|
+
summaryKey: 'mouseOnlyEventHandlers_summary_cantTell_notFocusable',
|
|
166
|
+
hintKey: 'mouseOnlyEventHandlers_hint_cantTell_notFocusable',
|
|
167
|
+
params: {
|
|
168
|
+
attrs: presentMouseAttrs.join(', '),
|
|
169
|
+
keyboardAttrs: presentKeyboardAttrs.join(', ')
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
data: {
|
|
173
|
+
details: {
|
|
174
|
+
reasonCode: 'MOUSE_ONLY_HANDLER_KEYBOARD_EQUIVALENT_NOT_FOCUSABLE',
|
|
175
|
+
mouseAttrs: presentMouseAttrs,
|
|
176
|
+
keyboardAttrs: presentKeyboardAttrs
|
|
177
|
+
},
|
|
178
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
124
185
|
occurrences.push(
|
|
125
186
|
helpers.reportOccurrence(el, {
|
|
126
187
|
summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
|
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check no-autoplay-audio
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Sound that plays automatically should have a pause/stop or volume-control mechanism
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.4.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Any <audio autoplay> or <video autoplay> element that is not `muted`.
|
|
13
|
+
* Also any <bgsound>, and any <embed> or <object> that loads sound or
|
|
14
|
+
* video, or a plugin (Flash) that may play it: its `type` is audio/*,
|
|
15
|
+
* video/* or a plugin type, or its `src`/`data` ends in a sound or video
|
|
16
|
+
* file extension. An <embed> or <object> with `autostart` or `autoplay`
|
|
17
|
+
* set to false (attribute or <param>) is left out.
|
|
13
18
|
* @expectation
|
|
14
19
|
* SC 1.4.2 only applies when audio plays automatically for MORE than 3
|
|
15
20
|
* seconds; clip duration is not knowable from static markup (jsdom does
|
|
@@ -27,13 +32,22 @@
|
|
|
27
32
|
* - Elements with `muted` present are not flagged: muted playback is not
|
|
28
33
|
* audible, so the SC's condition ("plays automatically... audio")
|
|
29
34
|
* does not apply.
|
|
35
|
+
* - <embed>, <object> and <bgsound> have no `controls` or `muted` to
|
|
36
|
+
* read, so each one found is asked about. <bgsound> is obsolete
|
|
37
|
+
* and current browsers ignore it, but it still plays in older ones.
|
|
38
|
+
* - Sound started by a script cannot be detected.
|
|
30
39
|
* - Custom (JS-built) controls that don't use the native `controls`
|
|
31
40
|
* attribute cannot be detected statically. That's a documented limitation,
|
|
32
41
|
* same class as `iframe-focusable-content`'s `contentDocument` gap.
|
|
33
42
|
* - Not gated on `isAccTreeEligible`: unlike most rules, a `display:none`
|
|
34
43
|
* or `aria-hidden` audio/video element still plays audible sound in a
|
|
35
44
|
* real browser, so visual/AT-tree eligibility is not a relevant filter
|
|
36
|
-
* here.
|
|
45
|
+
* here. For the same reason the rule does not use queryAllSmart, whose
|
|
46
|
+
* hidden-content filter would drop such elements: an <audio> without
|
|
47
|
+
* `controls` is always one, since browsers hide it with their own
|
|
48
|
+
* stylesheet (`display: none`). It queries the DOM directly (and open
|
|
49
|
+
* shadow roots, unless includeShadowDom is false), honouring only the
|
|
50
|
+
* scan scope and excludeSelectors.
|
|
37
51
|
*/
|
|
38
52
|
|
|
39
53
|
const id = 'no-autoplay-audio';
|
|
@@ -41,7 +55,7 @@ const id = 'no-autoplay-audio';
|
|
|
41
55
|
const meta = {
|
|
42
56
|
title: 'Autoplaying audio should provide a pause/stop or volume-control mechanism',
|
|
43
57
|
description:
|
|
44
|
-
'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, for manual review against the 3-second exemption in WCAG 1.4.2.',
|
|
58
|
+
'Flags <audio>/<video> elements that autoplay unmuted with no native controls attribute, and <embed>, <object> or <bgsound> elements that may play sound, for manual review against the 3-second exemption in WCAG 1.4.2.',
|
|
45
59
|
i18n: {
|
|
46
60
|
titleKey: 'noAutoplayAudio_title',
|
|
47
61
|
descriptionKey: 'noAutoplayAudio_description'
|
|
@@ -68,9 +82,18 @@ const meta = {
|
|
|
68
82
|
function runInPage(ctx) {
|
|
69
83
|
const { helpers, rule } = ctx;
|
|
70
84
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
// Every match in scope, hidden or not (see @implementation-notes).
|
|
86
|
+
function queryAllUnfiltered(sel) {
|
|
87
|
+
const engineOptions = ctx.engineOptions || {};
|
|
88
|
+
const deep =
|
|
89
|
+
engineOptions.includeShadowDom !== false && typeof helpers.queryAllDeep === 'function';
|
|
90
|
+
const list = Array.from((deep ? helpers.queryAllDeep(sel) : helpers.queryAll(sel)) || []);
|
|
91
|
+
return typeof helpers.isExcluded === 'function'
|
|
92
|
+
? list.filter((el) => !helpers.isExcluded(el))
|
|
93
|
+
: list;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const nodes = queryAllUnfiltered('audio[autoplay], video[autoplay]');
|
|
74
97
|
|
|
75
98
|
const occurrences = [];
|
|
76
99
|
let applicableCount = 0;
|
|
@@ -110,6 +133,70 @@ function runInPage(ctx) {
|
|
|
110
133
|
}
|
|
111
134
|
}
|
|
112
135
|
|
|
136
|
+
// <embed>, <object> and <bgsound>: no controls or muted attribute to read.
|
|
137
|
+
const MEDIA_EXT =
|
|
138
|
+
/\.(mp3|wav|wave|ogg|oga|opus|m4a|aac|flac|wma|mid|midi|mp4|m4v|webm|ogv|mov|avi|wmv|mpg|mpeg|swf)(?:[?#]|$)/i;
|
|
139
|
+
const PLUGIN_TYPES = /^(application\/x-shockwave-flash|application\/futuresplash)$/i;
|
|
140
|
+
|
|
141
|
+
function attr(el, name) {
|
|
142
|
+
return String(el.getAttribute(name) || '').trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function mayPlaySound(el, urlAttr) {
|
|
146
|
+
const type = attr(el, 'type').toLowerCase().split(';')[0].trim();
|
|
147
|
+
if (type) return /^(audio|video)\//.test(type) || PLUGIN_TYPES.test(type);
|
|
148
|
+
return MEDIA_EXT.test(attr(el, urlAttr));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startsDisabled(el) {
|
|
152
|
+
const isOff = (v) => /^(false|0|no)$/i.test(String(v || '').trim());
|
|
153
|
+
if (isOff(el.getAttribute('autostart')) || isOff(el.getAttribute('autoplay'))) return true;
|
|
154
|
+
return Array.from(el.children || []).some((c) => {
|
|
155
|
+
if ((c.tagName || '').toLowerCase() !== 'param') return false;
|
|
156
|
+
const name = attr(c, 'name').toLowerCase();
|
|
157
|
+
return (
|
|
158
|
+
(name === 'autostart' || name === 'autoplay' || name === 'play') &&
|
|
159
|
+
isOff(c.getAttribute('value'))
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The fallback inside an <object> already asked about is the same sound.
|
|
165
|
+
const askedObjects = [];
|
|
166
|
+
|
|
167
|
+
for (const el of queryAllUnfiltered('embed, object, bgsound')) {
|
|
168
|
+
if (!el || !el.getAttribute) continue;
|
|
169
|
+
if (askedObjects.some((o) => o !== el && o.contains(el))) continue;
|
|
170
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
171
|
+
if (tag === 'embed' && !mayPlaySound(el, 'src')) continue;
|
|
172
|
+
if (tag === 'object' && !mayPlaySound(el, 'data')) continue;
|
|
173
|
+
if (tag !== 'bgsound' && startsDisabled(el)) continue;
|
|
174
|
+
|
|
175
|
+
applicableCount += 1;
|
|
176
|
+
if (tag === 'object') askedObjects.push(el);
|
|
177
|
+
|
|
178
|
+
const baseOccurrence = {
|
|
179
|
+
selector: helpers.buildSelector ? helpers.buildSelector(el) : 'html',
|
|
180
|
+
html: helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '',
|
|
181
|
+
summary: 'This element may play sound as soon as the page loads.',
|
|
182
|
+
hint: 'Check whether it plays sound on its own. If the sound lasts more than 3 seconds, users need a way to pause or stop it, or to change its volume without changing the system volume.',
|
|
183
|
+
i18n: {
|
|
184
|
+
summaryKey: 'noAutoplayAudio_summary_cantTell_embedded',
|
|
185
|
+
hintKey: 'noAutoplayAudio_hint_cantTell_embedded',
|
|
186
|
+
params: { element: tag }
|
|
187
|
+
},
|
|
188
|
+
data: {
|
|
189
|
+
details: { reasonCode: 'EMBEDDED_SOUND_SOURCE', mediaTag: tag }
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
194
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
195
|
+
} else {
|
|
196
|
+
occurrences.push(baseOccurrence);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
113
200
|
if (applicableCount === 0) {
|
|
114
201
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
115
202
|
}
|