@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
@@ -20,6 +20,16 @@
20
20
  * regardless of the device's actual orientation, which defeats WCAG
21
21
  * 1.3.4's requirement that content not restrict its view to a single
22
22
  * display orientation unless that orientation is essential.
23
+ * Such a rotation fails.
24
+ *
25
+ * A second shape is asked about (cantTell): an orientation media block
26
+ * that hides the page's content with `display: none` or `visibility:
27
+ * hidden`, the usual form of WCAG F100 (content replaced by a "rotate
28
+ * your device" message in one orientation). The hidden element counts as
29
+ * the page's content when it is `html` or `body`, the `main` landmark, an
30
+ * ancestor of it, or, on a page without a main landmark, an element
31
+ * holding at least half of the body's text. Whether the orientation is
32
+ * essential, and whether the page stays usable, is left to a person.
23
33
  * @implementation-notes
24
34
  * - The rotation DEGREE is what makes this the exploit signature, not
25
35
  * merely the presence of a `rotate()` function: a small decorative icon
@@ -38,11 +48,16 @@
38
48
  * security model) and are skipped, same class of limitation as any
39
49
  * check that can only see same-origin/inspectable content (compare
40
50
  * `iframe-focusable-content`).
41
- * - Not per-element: this is a whole-document/whole-stylesheet concern,
42
- * so occurrences are reported against `document.documentElement`,
51
+ * - A rotation lock is not decided per element: this is a
52
+ * whole-document/whole-stylesheet concern, so its occurrences are
53
+ * reported against `document.documentElement`,
43
54
  * matching the "whole-document checks" precedent documented in
44
55
  * `docs/RULE_AUTHORING.md` §11.2 (e.g. `aria-hidden-body`,
45
- * `meta-viewport-zoom-enabled`).
56
+ * `meta-viewport-zoom-enabled`). A hidden-content finding is reported
57
+ * against the hidden element.
58
+ * - The rotation lock stays a failure, as in ACT b33eff, which assumes the
59
+ * orientation is not essential; a person reviewing an essential lock (a
60
+ * piano keyboard, a cheque scan) can set that finding aside.
46
61
  */
47
62
 
48
63
  const id = 'css-orientation-lock';
@@ -50,7 +65,7 @@ const id = 'css-orientation-lock';
50
65
  const meta = {
51
66
  title: 'CSS must not lock the page to a single orientation',
52
67
  description:
53
- 'Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation.',
68
+ "Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation, and asks about any such rule that hides the page's main content.",
54
69
  i18n: {
55
70
  titleKey: 'cssOrientationLock_title',
56
71
  descriptionKey: 'cssOrientationLock_description'
@@ -228,17 +243,50 @@ function runInPage(ctx) {
228
243
  return m.includes('orientation') && (m.includes('portrait') || m.includes('landscape'));
229
244
  }
230
245
 
231
- function scanRuleList(rules, mediaText, findings) {
246
+ function hidesContent(styleDecl) {
247
+ if (!styleDecl || typeof styleDecl.getPropertyValue !== 'function') return false;
248
+ const display = trim(styleDecl.getPropertyValue('display')).toLowerCase();
249
+ const visibility = trim(styleDecl.getPropertyValue('visibility')).toLowerCase();
250
+ return display === 'none' || visibility === 'hidden' || visibility === 'collapse';
251
+ }
252
+
253
+ function scanRuleList(rules, mediaText, findings, hidings) {
232
254
  if (!rules) return;
233
255
  for (const r of rules) {
234
- if (!r) continue;
235
- if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
256
+ if (!r || r.type !== CSS_STYLE_RULE) continue;
257
+ if (isLockingRotation(r.style)) {
236
258
  findings.push({ mediaText, selectorText: trim(r.selectorText) });
237
259
  }
260
+ if (r.selectorText && hidesContent(r.style)) {
261
+ hidings.push({ mediaText, selectorText: trim(r.selectorText) });
262
+ }
238
263
  }
239
264
  }
240
265
 
266
+ // Whether an element hidden by an orientation block holds the page's
267
+ // content: the root, the body, the main landmark or an ancestor of it, or,
268
+ // with no main landmark, most of the body's text.
269
+ function textLength(el) {
270
+ return String((el && el.textContent) || '').replace(/\s+/g, '').length;
271
+ }
272
+ let mainEl = null;
273
+ try {
274
+ mainEl = document.querySelector('main, [role="main"]');
275
+ } catch {
276
+ mainEl = null;
277
+ }
278
+ const bodyTextLength = document.body ? textLength(document.body) : 0;
279
+
280
+ function holdsPageContent(el) {
281
+ if (!el || el.nodeType !== 1) return false;
282
+ const tag = String(el.localName || '').toLowerCase();
283
+ if (tag === 'html' || tag === 'body') return true;
284
+ if (mainEl) return el === mainEl || (typeof el.contains === 'function' && el.contains(mainEl));
285
+ return bodyTextLength > 0 && textLength(el) * 2 >= bodyTextLength;
286
+ }
287
+
241
288
  const findings = [];
289
+ const hidings = [];
242
290
  let sheetCount = 0;
243
291
  let unreadableSheetCount = 0;
244
292
 
@@ -261,7 +309,7 @@ function runInPage(ctx) {
261
309
  if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
262
310
  const mediaText = rule2.media ? rule2.media.mediaText : '';
263
311
  if (!isOrientationMedia(mediaText)) continue;
264
- scanRuleList(rule2.cssRules, mediaText, findings);
312
+ scanRuleList(rule2.cssRules, mediaText, findings, hidings);
265
313
  }
266
314
  }
267
315
  } catch {
@@ -270,8 +318,90 @@ function runInPage(ctx) {
270
318
 
271
319
  const scanTarget = document.documentElement || document.body || null;
272
320
 
321
+ function unreadableSheetsOccurrence(count) {
322
+ return helpers.reportOccurrence(scanTarget, {
323
+ summary: `${count} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
324
+ hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
325
+ i18n: {
326
+ summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
327
+ hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
328
+ params: { count: String(count) }
329
+ },
330
+ uncertainty: {
331
+ code: 'not-computable',
332
+ needed: 'The contents of the stylesheets this scan could not read.',
333
+ evidence: { unreadableSheetCount: count, reasonCode: 'STYLESHEETS_NOT_READABLE' }
334
+ },
335
+ data: {
336
+ details: {
337
+ reasonCode: 'STYLESHEETS_NOT_READABLE',
338
+ unreadableSheetCount: count
339
+ }
340
+ }
341
+ });
342
+ }
343
+
344
+ // Orientation blocks hiding the page's content (F100): one question per
345
+ // hidden element.
346
+ const hiddenContent = [];
347
+ const seenHidden = new Set();
348
+ for (const h of hidings) {
349
+ let matched;
350
+ try {
351
+ matched = Array.from(document.querySelectorAll(h.selectorText));
352
+ } catch {
353
+ matched = [];
354
+ }
355
+ for (const el of matched) {
356
+ if (seenHidden.has(el) || !holdsPageContent(el)) continue;
357
+ seenHidden.add(el);
358
+ hiddenContent.push({ el, mediaText: h.mediaText, selectorText: h.selectorText });
359
+ }
360
+ }
361
+ const hiddenContentOccurrences = hiddenContent.map((f) =>
362
+ helpers.reportOccurrence(f.el, {
363
+ occurrenceOutcome: 'cantTell',
364
+ summary: `A "${f.mediaText}" media query hides "${f.selectorText}", which holds the page's main content, so the page may not be usable in that orientation.`,
365
+ hint: 'Check that the page can be viewed and operated in both portrait and landscape. If this media query replaces the content with a message asking the user to rotate the device, show the content instead, unless one orientation is essential.',
366
+ i18n: {
367
+ summaryKey: 'cssOrientationLock_summary_cantTell_hidesContent',
368
+ hintKey: 'cssOrientationLock_hint_cantTell_hidesContent',
369
+ params: { mediaText: f.mediaText, selectorText: f.selectorText }
370
+ },
371
+ uncertainty: {
372
+ code: 'judgement-required',
373
+ needed:
374
+ 'Whether the page stays usable in that orientation, and whether one orientation is essential.',
375
+ evidence: {
376
+ mediaText: f.mediaText,
377
+ selectorText: f.selectorText,
378
+ reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT'
379
+ }
380
+ },
381
+ data: {
382
+ details: {
383
+ reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT',
384
+ mediaText: f.mediaText,
385
+ selectorText: f.selectorText
386
+ }
387
+ }
388
+ })
389
+ );
390
+
273
391
  // A lock found in a readable sheet is still a lock, so `fail` outranks the
274
392
  // uncertainty below.
393
+ if (!findings.length && hiddenContentOccurrences.length) {
394
+ const unreadable =
395
+ unreadableSheetCount > 0 ? [unreadableSheetsOccurrence(unreadableSheetCount)] : [];
396
+ return {
397
+ ruleId: rule.ruleId,
398
+ outcome: 'cantTell',
399
+ severity: rule.defaultSeverity || 'serious',
400
+ confidence: 'low',
401
+ occurrences: hiddenContentOccurrences.concat(unreadable)
402
+ };
403
+ }
404
+
275
405
  if (!findings.length) {
276
406
  if (unreadableSheetCount > 0) {
277
407
  return {
@@ -279,28 +409,7 @@ function runInPage(ctx) {
279
409
  outcome: 'cantTell',
280
410
  severity: rule.defaultSeverity || 'serious',
281
411
  confidence: 'low',
282
- occurrences: [
283
- helpers.reportOccurrence(scanTarget, {
284
- summary: `${unreadableSheetCount} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
285
- hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
286
- i18n: {
287
- summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
288
- hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
289
- params: { count: String(unreadableSheetCount) }
290
- },
291
- uncertainty: {
292
- code: 'not-computable',
293
- needed: 'The contents of the stylesheets this scan could not read.',
294
- evidence: { unreadableSheetCount, reasonCode: 'STYLESHEETS_NOT_READABLE' }
295
- },
296
- data: {
297
- details: {
298
- reasonCode: 'STYLESHEETS_NOT_READABLE',
299
- unreadableSheetCount
300
- }
301
- }
302
- })
303
- ]
412
+ occurrences: [unreadableSheetsOccurrence(unreadableSheetCount)]
304
413
  };
305
414
  }
306
415
  if (sheetCount === 0) {
@@ -333,6 +442,19 @@ function runInPage(ctx) {
333
442
  })
334
443
  );
335
444
 
445
+ if (hiddenContentOccurrences.length) {
446
+ // See helpers.resolveTieredOutcome: the lock fails, and the hidden-content
447
+ // questions are kept beside it.
448
+ return {
449
+ ruleId: rule.ruleId,
450
+ ...helpers.resolveTieredOutcome(
451
+ occurrences,
452
+ hiddenContentOccurrences,
453
+ rule.defaultSeverity || 'serious'
454
+ )
455
+ };
456
+ }
457
+
336
458
  return {
337
459
  ruleId: rule.ruleId,
338
460
  outcome: 'fail',
@@ -9,16 +9,21 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <dl> elements that have at least one direct element child.
12
+ * Applies to <dl> elements that have at least one direct element child or
13
+ * non-whitespace text directly inside them.
13
14
  * @expectation
14
15
  * Every direct element child is <dt>, <dd>, <script>, <template>, <style>,
15
16
  * or a <div> whose own children are drawn from that same set (a single
16
17
  * level of wrapping div is allowed, matching how authors commonly group
17
- * dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
18
- * must contain BOTH (an unbalanced dt-without-dd or dd-without-dt is
19
- * invalid). A flattened set with neither is vacuously fine, not a
20
- * violation (see implementation-notes). Any other direct or wrapped child
21
- * breaks the description-list semantics assistive technologies rely on.
18
+ * dt/dd pairs). Non-whitespace text directly inside the <dl> or a wrapping
19
+ * <div> is an invalid child too. If the flattened set contains any <dt> or
20
+ * <dd> at all, it must contain BOTH (an unbalanced dt-without-dd or
21
+ * dd-without-dt is invalid), and read in order it must be groups of one
22
+ * or more <dt> followed by one or more <dd>: a <dd> before the first <dt>
23
+ * has no term, and a <dt> after the last <dd> has no definition. A
24
+ * flattened set with neither is vacuously fine, not a violation (see
25
+ * implementation-notes). Any other direct or wrapped child breaks the
26
+ * description-list semantics assistive technologies rely on.
22
27
  * @implementation-notes
23
28
  * - Only one level of <div> wrapping is flattened, a <div> nested inside
24
29
  * another wrapping <div> is not flattened further and its contents are
@@ -27,6 +32,11 @@
27
32
  * with NEITHER dt nor dd, whether from an empty wrapping <div>, only
28
33
  * <script>/<template>/<style> content, or a childless <dl>, is not flagged; only
29
34
  * an unbalanced dt/dd pairing is a real structural problem.
35
+ * - The order check reads the flattened dt/dd sequence across wrapping
36
+ * <div> boundaries, so a term and its definition split into two sibling
37
+ * <div>s still pass: assistive technologies get the same terms and
38
+ * definitions. The stricter HTML content model (each <div> holds a whole
39
+ * group) is not checked here.
30
40
  * - Distinct, atomic decision from dlitem-parent-valid (the
31
41
  * inverse relationship: does a given <dt>/<dd> have a valid parent).
32
42
  */
@@ -72,17 +82,29 @@ function runInPage(ctx) {
72
82
  const occurrences = [];
73
83
  let applicableCount = 0;
74
84
 
85
+ // Non-whitespace text directly inside `parent` (the <dl> or a wrapping
86
+ // <div>).
87
+ function hasDirectText(parent) {
88
+ for (const node of parent.childNodes || []) {
89
+ if (node && node.nodeType === 3 && /\S/.test(node.nodeValue || '')) return true;
90
+ }
91
+ return false;
92
+ }
93
+
75
94
  for (const el of nodes) {
76
95
  if (!el || !el.children) continue;
77
- if (!el.children.length) continue;
96
+ const dlHasText = hasDirectText(el);
97
+ if (!el.children.length && !dlHasText) continue;
78
98
 
79
99
  applicableCount += 1;
80
100
 
81
101
  // Flatten one level of wrapping <div> (common dt/dd grouping pattern).
82
102
  const flattened = [];
103
+ let hasText = dlHasText;
83
104
  for (const child of el.children) {
84
105
  if (!child || !child.tagName) continue;
85
106
  if (child.tagName.toLowerCase() === 'div') {
107
+ if (hasDirectText(child)) hasText = true;
86
108
  for (const grandchild of child.children || []) {
87
109
  if (grandchild && grandchild.tagName) flattened.push(grandchild);
88
110
  }
@@ -94,50 +116,72 @@ function runInPage(ctx) {
94
116
  let hasDt = false;
95
117
  let hasDd = false;
96
118
  const invalidTags = [];
119
+ // The dt/dd sequence in document order, for the group-order check.
120
+ const sequence = [];
97
121
  for (const node of flattened) {
98
122
  const tag = node.tagName.toLowerCase();
99
123
  if (tag === 'dt') {
100
124
  hasDt = true;
125
+ sequence.push(tag);
101
126
  continue;
102
127
  }
103
128
  if (tag === 'dd') {
104
129
  hasDd = true;
130
+ sequence.push(tag);
105
131
  continue;
106
132
  }
107
133
  if (!PASSTHROUGH_TAGS.has(tag)) invalidTags.push(tag);
108
134
  }
135
+ if (hasText) invalidTags.push('#text');
109
136
  const dedupedInvalidTags = [...new Set(invalidTags)];
110
137
 
138
+ // Groups of one or more dt then one or more dd: the sequence starts
139
+ // with a dt and ends with a dd.
140
+ const badOrder =
141
+ hasDt && hasDd && (sequence[0] !== 'dt' || sequence[sequence.length - 1] !== 'dd');
142
+
111
143
  // The dt/dd pairing is only required "when not empty", a <dl> with
112
144
  // NEITHER dt nor dd (whether childless after flattening, only
113
145
  // passthrough script/template/style content, or an empty wrapping div)
114
146
  // is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
115
- // present without any dd, or vice versa) is a real structural problem.
147
+ // present without any dd, or vice versa) or a misordered one is a
148
+ // real structural problem.
116
149
  const reasonCode = invalidTags.length
117
150
  ? 'DL_INVALID_CHILD'
118
151
  : (hasDt || hasDd) && !(hasDt && hasDd)
119
152
  ? 'DL_NO_DT_DD'
120
- : null;
153
+ : badOrder
154
+ ? 'DL_DT_DD_ORDER'
155
+ : null;
121
156
  if (!reasonCode) continue;
122
157
 
123
- const summary = invalidTags.length
124
- ? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
125
- : 'This description list has no <dt>/<dd> term-definition group.';
126
- const hint = invalidTags.length
127
- ? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
128
- : 'Add at least one <dt>/<dd> pair inside this <dl>.';
158
+ const TEXT = {
159
+ DL_INVALID_CHILD: {
160
+ summary:
161
+ 'This description list contains a direct or wrapped child that is not part of a dt/dd group.',
162
+ hint: 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.',
163
+ key: 'invalidChild'
164
+ },
165
+ DL_NO_DT_DD: {
166
+ summary: 'This description list has no <dt>/<dd> term-definition group.',
167
+ hint: 'Add at least one <dt>/<dd> pair inside this <dl>.',
168
+ key: 'noDtDd'
169
+ },
170
+ DL_DT_DD_ORDER: {
171
+ summary:
172
+ 'In this description list, a <dd> comes before any <dt>, or the last <dt> has no <dd> after it.',
173
+ hint: 'Order each group as one or more <dt> followed by one or more <dd>.',
174
+ key: 'order'
175
+ }
176
+ }[reasonCode];
129
177
 
130
178
  occurrences.push(
131
179
  helpers.reportOccurrence(el, {
132
- summary,
133
- hint,
180
+ summary: TEXT.summary,
181
+ hint: TEXT.hint,
134
182
  i18n: {
135
- summaryKey: invalidTags.length
136
- ? 'definitionListChildrenValid_summary_fail_invalidChild'
137
- : 'definitionListChildrenValid_summary_fail_noDtDd',
138
- hintKey: invalidTags.length
139
- ? 'definitionListChildrenValid_hint_fail_invalidChild'
140
- : 'definitionListChildrenValid_hint_fail_noDtDd',
183
+ summaryKey: `definitionListChildrenValid_summary_fail_${TEXT.key}`,
184
+ hintKey: `definitionListChildrenValid_hint_fail_${TEXT.key}`,
141
185
  params: { invalidChildren: dedupedInvalidTags.join(', ') }
142
186
  },
143
187
  data: {
@@ -5,34 +5,36 @@
5
5
  /**
6
6
  * @check deprecated-elements-not-used
7
7
  * @atomic true
8
- * @summary Obsolete non-stoppable elements must not be used
8
+ * @summary Scrolling <marquee> content needs a way to pause, stop, or hide it
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.2
11
11
  * @applicability
12
- * Applies to any <blink> or <marquee> element present in scope. These are
13
- * obsolete, non-standard HTML elements whose defining behavior (blinking
14
- * or auto-scrolling text) has no built-in user mechanism to pause, stop,
15
- * or hide it.
12
+ * Applies to any scan scope; whether it contains a <marquee> element is
13
+ * always an answerable question. <marquee> is an obsolete, non-standard
14
+ * HTML element that browsers still render as auto-scrolling text, with no
15
+ * built-in user mechanism to pause, stop, or hide it.
16
16
  * @expectation
17
- * Neither element is present. Since their movement can never be paused,
18
- * stopped, or hidden by the user, presence is itself the violation. This
19
- * rule has no partial-pass case (it reports only when the element is
20
- * found).
17
+ * Each <marquee> is reported as cantTell: the scrolling itself is certain,
18
+ * but a page can offer its own pause or stop control (a button calling the
19
+ * element's stop() method, for example), and then failure F16 does not
20
+ * apply. Whether such a control exists is for a person to check. A scope
21
+ * with no <marquee> passes; there is no separate not-applicable case.
21
22
  * @implementation-notes
22
- * - Not rule-gated on isAccTreeEligible: presence in markup is itself the
23
- * violation, independent of visibility (moving/blinking content inside a
24
- * hidden ancestor could still become visible later without a code
25
- * change, so hiding it today does not remove the underlying defect).
26
- * Engine-level hidden-subtree filtering still applies unless
27
- * engineOptions.includeHiddenElements is true.
23
+ * - <blink> is not reported. No current browser makes it blink: it renders
24
+ * as an unknown inline element, so it holds no blinking content for 2.2.2
25
+ * to govern.
26
+ * - Not rule-gated on isAccTreeEligible: presence in markup is what the rule
27
+ * asks about, independent of visibility. Engine-level hidden-subtree
28
+ * filtering still applies unless engineOptions.includeHiddenElements is
29
+ * true.
28
30
  */
29
31
 
30
32
  const id = 'deprecated-elements-not-used';
31
33
 
32
34
  const meta = {
33
- title: 'Obsolete non-stoppable elements (<blink>, <marquee>) must not be used',
35
+ title: 'Scrolling <marquee> content must be possible to pause, stop, or hide',
34
36
  description:
35
- 'Checks that deprecated, non-standard HTML elements whose blinking/scrolling content cannot be paused, stopped, or hidden by the user (<blink>, <marquee>) are not present.',
37
+ 'Asks, for each obsolete <marquee> element, whether the page offers a way to pause, stop, or hide its auto-scrolling content, since the element itself has none.',
36
38
  i18n: {
37
39
  titleKey: 'deprecatedElements_title',
38
40
  descriptionKey: 'deprecatedElements_description'
@@ -60,47 +62,50 @@ function runInPage(ctx) {
60
62
  const { helpers, rule } = ctx;
61
63
 
62
64
  const nodes = helpers.queryAllSmart
63
- ? helpers.queryAllSmart('blink, marquee')
64
- : helpers.queryAll('blink, marquee');
65
+ ? helpers.queryAllSmart('marquee')
66
+ : helpers.queryAll('marquee');
65
67
 
66
68
  const occurrences = [];
67
- let applicableCount = 0;
68
69
 
69
70
  for (const el of nodes) {
70
71
  if (!el || !el.tagName) continue;
71
72
 
72
- applicableCount += 1;
73
-
74
73
  const tag = el.tagName.toLowerCase();
75
74
 
76
75
  occurrences.push(
77
76
  helpers.reportOccurrence(el, {
78
- summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
79
- hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
77
+ summary:
78
+ 'This <marquee> scrolls its content, and the element itself gives the user no way to pause, stop, or hide it.',
79
+ hint: 'Check that the page offers a control that pauses, stops, or hides this content. Better: replace it with static content, or with an animation that has a pause/stop control.',
80
+ occurrenceOutcome: 'cantTell',
80
81
  i18n: {
81
- summaryKey: 'deprecatedElements_summary_fail',
82
- hintKey: 'deprecatedElements_hint_fail',
82
+ summaryKey: 'deprecatedElements_summary_cantTell',
83
+ hintKey: 'deprecatedElements_hint_cantTell',
83
84
  params: { element: tag }
84
85
  },
86
+ uncertainty: {
87
+ code: 'runtime-dependent',
88
+ needed:
89
+ 'Whether the page provides a control that pauses, stops, or hides the scrolling content.',
90
+ evidence: { element: tag }
91
+ },
85
92
  data: {
86
- details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
93
+ details: { reasonCode: 'MARQUEE_PAUSE_MECHANISM_UNKNOWN', element: tag }
87
94
  }
88
95
  })
89
96
  );
90
97
  }
91
98
 
92
- if (applicableCount === 0) {
93
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
94
- }
95
- if (occurrences.length) {
96
- return {
97
- ruleId: rule.ruleId,
98
- outcome: 'fail',
99
- severity: rule.defaultSeverity || 'serious',
100
- occurrences
101
- };
99
+ // No <marquee> in scope is itself the passing case (see @expectation above).
100
+ if (!occurrences.length) {
101
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
102
102
  }
103
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
103
+ return {
104
+ ruleId: rule.ruleId,
105
+ outcome: 'cantTell',
106
+ severity: rule.defaultSeverity || 'serious',
107
+ occurrences
108
+ };
104
109
  }
105
110
 
106
111
  module.exports = { id, meta, runInPage };
@@ -5,14 +5,16 @@
5
5
  /**
6
6
  * @check dialog-name-present
7
7
  * @atomic true
8
- * @summary Elements with role="dialog"/"alertdialog" must have an accessible name
8
+ * @summary Dialogs (role="dialog"/"alertdialog" or a native <dialog>) must have an accessible name
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
- * Applies to elements carrying role="dialog" or role="alertdialog" (the
13
- * attribute must name one of those roles alone, not a fallback list) that
14
- * are included in the accessibility tree. A native <dialog> without an
15
- * explicit role is out of scope.
12
+ * Applies to elements included in the accessibility tree whose role is
13
+ * dialog or alertdialog: an element whose role attribute resolves to one
14
+ * of those roles (the first token that names a concrete ARIA role wins,
15
+ * so role="alertdialog dialog" is an alertdialog), and a native <dialog>
16
+ * whose role attribute is absent or names no concrete role (its implicit
17
+ * role is dialog). A closed <dialog> is hidden and so not in scope.
16
18
  * @expectation
17
19
  * The element has a non-empty accessible name from aria-label, from an
18
20
  * aria-labelledby that resolves to non-empty text, or from title. Both
@@ -25,7 +27,7 @@ const id = 'dialog-name-present';
25
27
  const meta = {
26
28
  title: 'Dialogs have an accessible name',
27
29
  description:
28
- 'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
30
+ 'Checks that dialogs (elements with role="dialog" or role="alertdialog", and native <dialog> elements) expose a non-empty accessible name.',
29
31
  i18n: {
30
32
  titleKey: 'dialogNamePresent_title',
31
33
  descriptionKey: 'dialogNamePresent_description'
@@ -115,7 +117,8 @@ function runInPage(ctx) {
115
117
  const occurrences = [];
116
118
  let applicableCount = 0;
117
119
 
118
- const selector = '[role="dialog"],[role="alertdialog"]';
120
+ // Filtered below by the resolved role.
121
+ const selector = 'dialog,[role]';
119
122
  const nodes = helpers.queryAllSmart
120
123
  ? helpers.queryAllSmart(selector)
121
124
  : helpers.queryAll(selector);
@@ -137,12 +140,28 @@ function runInPage(ctx) {
137
140
  return { ok: false, method: 'none' };
138
141
  }
139
142
 
143
+ // The role the browser uses: the first token of the role attribute that
144
+ // names a concrete ARIA role, else the element's implicit role (dialog for
145
+ // a native <dialog>; the others are not in scope here).
146
+ const aria = helpers && helpers.aria;
147
+ function resolveRole(el) {
148
+ const tokens = getAttr(el, 'role').toLowerCase().split(' ').filter(Boolean);
149
+ for (const t of tokens) {
150
+ const concrete =
151
+ aria && typeof aria.isValidConcreteRole === 'function'
152
+ ? aria.isValidConcreteRole(t)
153
+ : t === 'dialog' || t === 'alertdialog';
154
+ if (concrete) return t;
155
+ }
156
+ return String(el.tagName || '').toLowerCase() === 'dialog' ? 'dialog' : '';
157
+ }
158
+
140
159
  for (const el of nodes) {
141
160
  if (!el) continue;
142
- if (!isEligibleAcc(helpers, el, ctx)) continue;
143
161
 
144
- const role = getAttr(el, 'role').toLowerCase();
162
+ const role = resolveRole(el);
145
163
  if (role !== 'dialog' && role !== 'alertdialog') continue;
164
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
146
165
 
147
166
  applicableCount += 1;
148
167
 
@@ -14,7 +14,8 @@
14
14
  * element renders or not, which is why ACT 3ea0c8 evaluates hidden
15
15
  * elements too.
16
16
  * @expectation
17
- * No other element in the same tree carries the same id value. Ids are
17
+ * No other element in the same tree carries the same id value, compared
18
+ * exactly as written (id="a " and id="a" are different ids). Ids are
18
19
  * scoped per document tree and per shadow tree, so the same id inside
19
20
  * two different shadow roots is not a duplicate.
20
21
  * @implementation-notes
@@ -126,7 +127,10 @@ function runInPage(ctx) {
126
127
 
127
128
  for (const el of all) {
128
129
  if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
129
- const value = String(el.getAttribute('id') || '').trim();
130
+ // Compared as written: "a " and "a" are different ids in the DOM, and
131
+ // getElementById does not trim. Whitespace inside an id is a separate
132
+ // validity error, not a duplicate.
133
+ const value = String(el.getAttribute('id') || '');
130
134
  if (!value) continue;
131
135
 
132
136
  applicableCount += 1;
@@ -20,10 +20,10 @@
20
20
  * describes one resource, so two frames answering to it must embed the
21
21
  * same one.
22
22
  * @implementation-notes
23
- * - Distinct from iframe-title-unique, which asks the stricter question of
24
- * whether the title ATTRIBUTE repeats at all, and answers it from static
25
- * markup. This rule keys on the computed accessible name and judges the
26
- * resource behind it.
23
+ * - Frames are grouped by the computed accessible name, not the title
24
+ * attribute, so a title that aria-label or aria-labelledby overrides does
25
+ * not count. It replaces iframe-title-unique, deprecated in 1.8.0, which
26
+ * grouped them by the title attribute.
27
27
  * - src values are compared as resolved absolute URLs with the fragment
28
28
  * removed and a trailing slash normalised away, so a directory written
29
29
  * both with and without one is a single resource.