@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.
Files changed (103) hide show
  1. package/CHANGELOG.md +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +2 -2
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +26 -11
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14285 -2219
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. 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 GENERIC_LINK_TEXT = new Set([
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(/[.,;:!?]+$/g, '')
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.has(normalized);
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). Includes common EN/FR terms.
52
- // Keep this list strict to avoid false positives.
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 (e) {
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
- const nodes = helpers.queryAllSmart
288
- ? helpers.queryAllSmart('audio,video')
289
- : helpers.queryAll('audio,video');
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 = getEligibility(el);
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
- * Otherwise the element's mouse-driven behavior (a hover tooltip, a
24
- * custom dropdown, a drag interaction) has no way to be triggered by a
25
- * keyboard-only user.
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 hasKeyboardEquiv = KEYBOARD_EQUIV_ATTRS.some((a) => trim(el.getAttribute(a)));
118
- if (hasKeyboardEquiv) continue;
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 Autoplaying, unmuted <audio>/<video> should provide a pause/stop or volume-control mechanism
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
- const nodes = helpers.queryAllSmart
72
- ? helpers.queryAllSmart('audio[autoplay], video[autoplay]')
73
- : helpers.queryAll('audio[autoplay], video[autoplay]');
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
  }