@surea11y/core 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -9,20 +9,24 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.4, 3.2.5
11
11
  * @applicability
12
- * Applies to any <meta http-equiv="refresh"> element with a non-empty
13
- * content attribute.
12
+ * Applies to the first <meta http-equiv="refresh"> element, in document
13
+ * order, with a valid content attribute, per HTML's shared declarative
14
+ * refresh steps, a document only ever acts on its first valid meta
15
+ * refresh, so a later one (valid or not) is inert and out of scope.
14
16
  * @expectation
15
- * No meta refresh is present, regardless of delay. This is the
16
- * stricter AAA-level counterpart of meta-refresh-timing-absent
17
- * (the A-level rule, which only forbids a positive delay and allows
18
- * delay="0" as an immediate redirect). At AAA, WCAG 2.2.4
19
- * (Interruptions) and 3.2.5 (Change on Request) require that automatic
20
- * context changes — including an immediate meta-refresh redirect —
21
- * happen only at the user's request, with no exception for a zero
22
- * delay.
17
+ * Running the shared declarative refresh steps against that element's
18
+ * content value results in a delay of exactly 0. An immediate (delay=0)
19
+ * redirect still passes at AAA, same as the A-level rule. There is
20
+ * nothing for a user to be interrupted mid-read by when nothing is
21
+ * displayed first. Any positive delay fails, with none of the A-level
22
+ * rule's >20-hour exemption: at AAA, WCAG 2.2.4 (Interruptions) and
23
+ * 3.2.5 (Change on Request) require that a *timed* automatic context
24
+ * change happen only at the user's request, regardless of how long the
25
+ * timer is.
23
26
  * @implementation-notes
24
- * - Distinct, atomic decision from meta-refresh-timing-absent:
25
- * that rule's delay="0" pass case is this rule's fail case.
27
+ * - Distinct, atomic decision from meta-refresh-timing-absent: that
28
+ * rule's >20-hour exemption for a positive delay is this rule's fail
29
+ * case (no exemption at AAA), but delay=0 passes in both rules.
26
30
  * - A <meta> nested inside <noscript> is excluded: it only ever takes
27
31
  * effect when scripting is disabled, which is never the case for any
28
32
  * context capable of running accessibility tooling in the first place
@@ -35,7 +39,7 @@ const id = 'meta-refresh-no-exceptions';
35
39
  const meta = {
36
40
  title: 'Page must not use a meta refresh at all (AAA)',
37
41
  description:
38
- 'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay — the stricter AAA-level counterpart of the A-level positive-delay-only check.',
42
+ 'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay. This is the stricter AAA-level counterpart of the A-level positive-delay-only check.',
39
43
  i18n: {
40
44
  titleKey: 'metaRefreshNoExceptions_title',
41
45
  descriptionKey: 'metaRefreshNoExceptions_description'
@@ -122,10 +126,16 @@ function runInPage(ctx) {
122
126
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
123
127
  const raw = String(el.getAttribute('content') || '').trim();
124
128
  if (!raw) continue;
125
- if (parseRefreshDelay(raw) === null) continue;
129
+ const delay = parseRefreshDelay(raw);
130
+ if (delay === null) continue;
126
131
 
132
+ // Only the first valid meta refresh in document order is ever acted
133
+ // on by a browser -- any later one is inert and stops being evaluated
134
+ // entirely once a winner has been found.
127
135
  applicableCount += 1;
128
136
 
137
+ if (delay === 0) break; // an immediate redirect is not a timed interruption
138
+
129
139
  occurrences.push(
130
140
  helpers.reportOccurrence(el, {
131
141
  summary:
@@ -141,6 +151,7 @@ function runInPage(ctx) {
141
151
  }
142
152
  })
143
153
  );
154
+ break;
144
155
  }
145
156
 
146
157
  if (applicableCount === 0) {
@@ -9,8 +9,11 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.1
11
11
  * @applicability
12
- * Applies to <meta http-equiv="refresh"> elements that carry a non-empty
13
- * content attribute with a parseable leading delay value.
12
+ * Applies to the first <meta http-equiv="refresh"> element, in document
13
+ * order, whose content attribute has a parseable leading delay value.
14
+ * Per HTML's shared declarative refresh steps, a document only ever
15
+ * acts on its first valid meta refresh; any later one (valid or not)
16
+ * is inert markup a browser never processes, so it is not evaluated.
14
17
  * @expectation
15
18
  * The delay is 0 (an immediate redirect, which users cannot be caught by
16
19
  * mid-read), or exceeds 20 hours. Any other positive delay refreshes or
@@ -19,13 +22,13 @@
19
22
  * be possible.
20
23
  * @implementation-notes
21
24
  * - An unparseable content value (no leading numeric delay) is not
22
- * flagged — this rule only reports a clearly-detected timed refresh,
25
+ * flagged, this rule only reports a clearly-detected timed refresh,
23
26
  * matching this engine's no-false-positives policy.
24
27
  * - WCAG 2.2.1 Exception 3 exempts time limits longer than 20 hours (the
25
28
  * rationale being that a delay this long gives users enough real-world
26
29
  * time to act, so "adjustable" ceases to be a meaningful requirement).
27
30
  * A delay over 72000 seconds is therefore not flagged.
28
- * - A <meta> nested inside <noscript> is excluded — see
31
+ * - A <meta> nested inside <noscript> is excluded, see
29
32
  * meta-refresh-no-exceptions's header comment for why.
30
33
  */
31
34
 
@@ -109,17 +112,20 @@ function runInPage(ctx) {
109
112
 
110
113
  for (const el of nodes) {
111
114
  if (!el || !el.getAttribute) continue;
112
- if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
115
+ if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled, see meta-refresh-no-exceptions.js's header comment
113
116
  const raw = String(el.getAttribute('content') || '').trim();
114
117
  if (!raw) continue;
115
118
 
116
119
  const delay = parseRefreshDelay(raw);
117
120
  if (delay === null) continue;
118
121
 
122
+ // Only the first valid meta refresh in document order is ever acted
123
+ // on by a browser -- any later one is inert and stops being evaluated
124
+ // entirely once a winner has been found.
119
125
  applicableCount += 1;
120
126
 
121
- if (!(delay > 0)) continue;
122
- if (delay > EXEMPT_DELAY_SECONDS) continue; // WCAG 2.2.1 Exception 3 (>20 hours)
127
+ if (!(delay > 0)) break;
128
+ if (delay > EXEMPT_DELAY_SECONDS) break; // WCAG 2.2.1 Exception 3 (>20 hours)
123
129
 
124
130
  occurrences.push(
125
131
  helpers.reportOccurrence(el, {
@@ -135,6 +141,7 @@ function runInPage(ctx) {
135
141
  }
136
142
  })
137
143
  );
144
+ break;
138
145
  }
139
146
 
140
147
  if (applicableCount === 0) {
@@ -2,6 +2,25 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check meter-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="meter" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 1.1.1
11
+ * @applicability
12
+ * Applies to elements carrying role="meter" (the attribute must name that
13
+ * role alone, not a fallback list) that are included in the accessibility
14
+ * tree. An element with the matching implicit role but no role attribute
15
+ * is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="meter" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom meter widget is not reliably
21
+ * exposed as its name.
22
+ */
23
+
5
24
  const id = 'meter-name-present';
6
25
 
7
26
  const meta = {
@@ -54,7 +73,7 @@ function runInPage(ctx) {
54
73
  const raw = getAttr(el, 'aria-labelledby');
55
74
  if (!raw) return '';
56
75
  // Delegates to the shared getTextFromIdRefs helper instead of computing
57
- // name-from-content of the referenced element — see dialog-name-
76
+ // name-from-content of the referenced element, see dialog-name-
58
77
  // present.js's identical fix for the full rationale (an <iframe>
59
78
  // aria-labelledby target's only name source is its title attribute,
60
79
  // which name-from-content alone can never see).
@@ -106,8 +125,8 @@ function runInPage(ctx) {
106
125
  if (title) return { ok: true, method: 'title' };
107
126
 
108
127
  // role="meter" is name-from-author-only per WAI-ARIA: aria-label,
109
- // aria-labelledby, or title — no content-based naming method at all.
110
- // It must NOT fall back to subtree content — visible text near/inside a
128
+ // aria-labelledby, or title, no content-based naming method at all.
129
+ // It must NOT fall back to subtree content, visible text near/inside a
111
130
  // custom meter widget is not reliably exposed as its accessible name.
112
131
  return { ok: false, method: 'none' };
113
132
  }
@@ -137,7 +156,7 @@ function runInPage(ctx) {
137
156
  occurrences.push(
138
157
  helpers.reportOccurrence(el, {
139
158
  summary: 'This meter has no accessible name.',
140
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this meter's accessible name.",
159
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this meter's accessible name.",
141
160
  i18n: {
142
161
  summaryKey: 'meterNamePresent_summary_fail',
143
162
  hintKey: 'meterNamePresent_hint_fail',
@@ -14,13 +14,13 @@
14
14
  * ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
15
15
  * combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
16
16
  * slider, spinbutton, searchbox, treeitem). The container is applicable
17
- * regardless of whether it is itself focusable — focusability is only
17
+ * regardless of whether it is itself focusable, focusability is only
18
18
  * used to decide whether a *descendant* nests an interactive control.
19
19
  * @expectation
20
20
  * The element does not contain, as a descendant, another *operable*
21
21
  * interactive control (e.g. a <button> wrapping a <select>, or a link
22
22
  * containing a checkbox). Nested interactive controls are not reliably
23
- * announced or operable via assistive technology — activating the outer
23
+ * announced or operable via assistive technology, activating the outer
24
24
  * control and the inner one become ambiguous, and some AT only exposes
25
25
  * one of the two.
26
26
  * @implementation-notes
@@ -89,7 +89,7 @@ const meta = {
89
89
  function runInPage(ctx) {
90
90
  const { helpers, rule } = ctx;
91
91
 
92
- // Declared inside runInPage — see scripts/build-core.js header
92
+ // Declared inside runInPage, see scripts/build-core.js header
93
93
  // ("runInPage MUST be self-contained").
94
94
  const INTERACTIVE_SELECTOR = [
95
95
  'a[href]',
@@ -228,8 +228,8 @@ function runInPage(ctx) {
228
228
  // A composite-owned child (option in a listbox/combobox, tab in a
229
229
  // tablist, ...) is not a nested interactive control: its container
230
230
  // owns it and drives its focus (roving tabindex or
231
- // aria-activedescendant). Treat it as an attribution boundary — do not
232
- // count it, and do not descend past it. Any control genuinely nested
231
+ // aria-activedescendant). Treat it as an attribution boundary, do not
232
+ // count it, and do not descend past it. Any control nested
233
233
  // inside it is attributed to the child itself (examined as its own
234
234
  // container in the main loop), keeping the report at the nearest
235
235
  // interactive ancestor rather than bubbling up to the composite.
@@ -2,11 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check option-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="option" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="option" (the attribute must name that
13
+ * role alone, not a fallback list) that are included in the accessibility
14
+ * tree. An element with the matching implicit role but no role attribute
15
+ * is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, from title, or,
19
+ * role="option" being name-from-content, from its own subtree text, where
20
+ * a descendant's own name (an <img alt>, aria-label or title) counts as
21
+ * that descendant's contribution rather than only its text nodes.
22
+ */
23
+
5
24
  const id = 'option-name-present';
6
25
 
7
26
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
27
+ title: 'Options have an accessible name',
28
+ description: 'Checks that elements with role="option" expose a non-empty accessible name.',
10
29
  i18n: {
11
30
  titleKey: 'optionNamePresent_title',
12
31
  descriptionKey: 'optionNamePresent_description'
@@ -51,7 +70,7 @@ function runInPage(ctx) {
51
70
  }
52
71
 
53
72
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
55
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
75
  // it has one, not just literal text nodes. See getContentNameInfo's
57
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -70,7 +89,7 @@ function runInPage(ctx) {
70
89
  const raw = getAttr(el, 'aria-labelledby');
71
90
  if (!raw) return '';
72
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
73
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
74
93
  // present.js's identical fix for the full rationale (an <iframe>
75
94
  // aria-labelledby target's only name source is its title attribute,
76
95
  // which name-from-content alone can never see).
@@ -2,11 +2,29 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check page-title-present
7
+ * @atomic true
8
+ * @summary The document must have a non-empty title
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.2
11
+ * @applicability
12
+ * Applies to a run over a whole document. A run narrowed by
13
+ * contextSelector, or by engineOptions.fragment, is notApplicable: whether
14
+ * the page has a title is not a property any subtree can answer.
15
+ * @expectation
16
+ * The document has a <title> element, and document.title with whitespace
17
+ * collapsed is non-empty. The element is looked for anywhere in the
18
+ * document, not only inside <head>: a <title> the parser leaves outside
19
+ * <head> is still the document title in every browser. Whether that title
20
+ * describes the page is page-title-patterns' question.
21
+ */
22
+
5
23
  const id = 'page-title-present';
6
24
 
7
25
  const meta = {
8
- title: 'Page title is present and non-empty',
9
- description: 'Checks that the document has a non-empty <title> element (WCAG 2.2 SC 2.4.2).',
26
+ title: 'Page has a non-empty title',
27
+ description: 'Checks that the page includes a non-empty <title> element.',
10
28
  i18n: {
11
29
  titleKey: 'pageTitlePresent_title',
12
30
  descriptionKey: 'pageTitlePresent_description'
@@ -47,7 +65,7 @@ function runInPage(ctx) {
47
65
  // Not restricted to `head > title`: per HTML parsing, a <title> encountered
48
66
  // after <head> has closed is not re-parented into <head>, yet the browser
49
67
  // (and document.title, used below) still recognizes it as the document's
50
- // title — matching that here avoids a false "missing title" report for a
68
+ // title, matching that here avoids a false "missing title" report for a
51
69
  // <title> that legitimately ended up outside <head>.
52
70
  const titleEl = document.querySelector('title');
53
71
  const titleText = (document.title || '').replace(/\s+/g, ' ').trim();
@@ -0,0 +1,330 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check presentational-children-focusable-absent
7
+ * @atomic true
8
+ * @summary A role whose children are presentational must not contain content in sequential focus navigation
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements whose semantic role is one of the WAI-ARIA roles
13
+ * defined as having presentational children (button, checkbox, img,
14
+ * menuitemcheckbox, menuitemradio, meter, option, progressbar, radio,
15
+ * scrollbar, separator, slider, switch, tab, plus doc-pagebreak and
16
+ * graphics-symbol from the DPUB-ARIA/Graphics-ARIA modules, which
17
+ * inherit the same trait). The role can be explicit (role="tab") or
18
+ * native (<button>, <meter>, <progress>, <option>).
19
+ * @expectation
20
+ * No descendant of the element is part of sequential focus navigation.
21
+ * The presentational-children mechanism removes every descendant from
22
+ * the accessibility tree, so a descendant that still takes a tab stop
23
+ * receives focus with no role and no name to announce.
24
+ * @implementation-notes
25
+ * - "Presentational children" is the implicit, role-driven mechanism from
26
+ * WAI-ARIA §5.2.7, NOT an explicit role="presentation"/"none" attribute.
27
+ * An element carrying that attribute has a semantic role of
28
+ * presentation/none, which is not in the list above, so it is out of
29
+ * scope here (presentation-role-conflict covers that case).
30
+ * - Only tab stops count, not everything focusable: a descendant with
31
+ * tabindex="-1" is programmatically focusable but is not part of
32
+ * sequential focus navigation, so it takes no tab stop and is not
33
+ * reported. helpers.getFocusableInfo's `tabbable` flag is exactly this
34
+ * distinction, and also accounts for :disabled, inert and invalid
35
+ * tabindex values.
36
+ * - Native tags are mapped only where the element can actually have
37
+ * descendants: <input type="checkbox">, <img> and <hr> also carry a
38
+ * presentational-children role, but they are void, so no descendant can
39
+ * ever exist to report. Including them would only inflate the applicable
40
+ * count.
41
+ * - The walk stops at each reported tab stop, and at a nested element that
42
+ * has a presentational-children role of its own without being a tab stop,
43
+ * that one owns whatever is inside it, and is examined as its own
44
+ * container. A tab stop therefore lands on the nearest role that dropped
45
+ * it from the accessibility tree, the element an author would fix, rather
46
+ * than on every enclosing one. A nested role that IS a tab stop
47
+ * (role="button" tabindex="0" inside a <button>) is reported here: focus
48
+ * lands inside this element.
49
+ * - Anything inside an aria-hidden="true" subtree is left to
50
+ * aria-hidden-focus, which reports that exact defect (a tab stop with no
51
+ * accessibility-tree node) as its own normative decision. Non-rendered
52
+ * descendants (display:none, hidden, visibility:hidden) take no tab stop
53
+ * at all and are skipped.
54
+ * - Browsers implement presentational children inconsistently, some
55
+ * expose the descendants anyway, especially when they are focusable, so
56
+ * the announced result varies by browser. The markup contradiction is
57
+ * the same in every one of them, which is what this rule reports.
58
+ */
59
+
60
+ const id = 'presentational-children-focusable-absent';
61
+
62
+ const meta = {
63
+ title: 'Roles with presentational children must not contain focusable content',
64
+ description:
65
+ 'Checks that an element whose role makes its children presentational (button, checkbox, img, option, radio, slider, switch, tab, ...) contains no descendant that takes a tab stop.',
66
+ i18n: {
67
+ titleKey: 'presentationalChildrenFocusableAbsent_title',
68
+ descriptionKey: 'presentationalChildrenFocusableAbsent_description'
69
+ },
70
+ helpUrl: null,
71
+ tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'focus', 'atomic', 'automatic'],
72
+ wcagSc: ['4.1.2'],
73
+ normativeMappings: [
74
+ {
75
+ standard: 'WCAG',
76
+ version: '2.2',
77
+ requirement: '4.1.2',
78
+ title: 'Name, Role, Value',
79
+ conformanceLevel: 'A'
80
+ }
81
+ ],
82
+ defaultSeverity: 'serious',
83
+ category: 'robust',
84
+ type: 'automatic',
85
+ defaultConfidence: 'high',
86
+ coverage: { facetsBySc: { '4.1.2': ['presentational-children-focusable-absent'] } }
87
+ };
88
+
89
+ function runInPage(ctx) {
90
+ const { helpers, rule } = ctx;
91
+
92
+ // Declared inside runInPage, see scripts/build-core.js header
93
+ // ("runInPage MUST be self-contained").
94
+ //
95
+ // WAI-ARIA roles with "Children Presentational: True", plus the two
96
+ // module roles that inherit the trait (doc-pagebreak from DPUB-ARIA,
97
+ // graphics-symbol from Graphics-ARIA).
98
+ const PRESENTATIONAL_CHILDREN_ROLES = [
99
+ 'button',
100
+ 'checkbox',
101
+ 'doc-pagebreak',
102
+ 'graphics-symbol',
103
+ 'img',
104
+ 'menuitemcheckbox',
105
+ 'menuitemradio',
106
+ 'meter',
107
+ 'option',
108
+ 'progressbar',
109
+ 'radio',
110
+ 'scrollbar',
111
+ 'separator',
112
+ 'slider',
113
+ 'switch',
114
+ 'tab'
115
+ ];
116
+
117
+ // Native tags whose implicit role is in the set above and that can hold
118
+ // descendants, see the void-element note in the header comment.
119
+ const NATIVE_ROLE_BY_TAG = {
120
+ button: 'button',
121
+ meter: 'meter',
122
+ option: 'option',
123
+ progress: 'progressbar'
124
+ };
125
+
126
+ const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
127
+
128
+ const getFocusableInfo =
129
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
130
+
131
+ const isDomVisibleEligible =
132
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
133
+ ? helpers.isDomVisibleEligible
134
+ : null;
135
+
136
+ const getEligibilityInfo =
137
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
138
+
139
+ const isAccTreeEligible =
140
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
141
+
142
+ const roleSet = new Set(PRESENTATIONAL_CHILDREN_ROLES);
143
+
144
+ function lower(v) {
145
+ return (v == null ? '' : String(v)).trim().toLowerCase();
146
+ }
147
+
148
+ // The role attribute holds a fallback list; the first token that names a
149
+ // real role wins. A role="tab" resolves here, a role="figure tab" does
150
+ // not (figure wins and has no presentational children), and a list of
151
+ // nothing but unknown tokens falls back to the native role.
152
+ function getPresentationalChildrenRole(el) {
153
+ const raw = el.getAttribute ? el.getAttribute('role') : null;
154
+ if (raw) {
155
+ const tokens = lower(raw).split(/\s+/);
156
+ for (const token of tokens) {
157
+ if (!token) continue;
158
+ if (roleSet.has(token)) return token;
159
+ const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
160
+ if (known) return '';
161
+ }
162
+ }
163
+ const tag = lower(el.tagName);
164
+ return Object.prototype.hasOwnProperty.call(NATIVE_ROLE_BY_TAG, tag)
165
+ ? NATIVE_ROLE_BY_TAG[tag]
166
+ : '';
167
+ }
168
+
169
+ function isExposed(node) {
170
+ if (!isAccTreeEligible) return true;
171
+ try {
172
+ const r = isAccTreeEligible(node, ctx);
173
+ if (typeof r === 'boolean') return r;
174
+ return !!(r && r.eligible);
175
+ } catch {
176
+ return true;
177
+ }
178
+ }
179
+
180
+ // Flat-tree ancestor walk, shared with every other rule via
181
+ // ctx.helpers.composedParent so a shadow host is crossed the same way here
182
+ // as elsewhere.
183
+ const composedParent =
184
+ helpers && typeof helpers.composedParent === 'function'
185
+ ? helpers.composedParent
186
+ : function (n) {
187
+ return n && n.parentElement ? n.parentElement : null;
188
+ };
189
+
190
+ // isAccTreeEligible keeps an aria-hidden element that holds
191
+ // tabbable content in the accessibility tree (reason
192
+ // "ariaHiddenOverriddenTabbable", modelling the browsers that expose it
193
+ // anyway), which is precisely the shape aria-hidden-focus owns, so the
194
+ // attribute is checked on its own here rather than read off eligibility.
195
+ function inAriaHiddenSubtree(node) {
196
+ let cur = node;
197
+ let guard = 0;
198
+ while (cur && guard++ < 200) {
199
+ if (lower(cur.getAttribute && cur.getAttribute('aria-hidden')) === 'true') return true;
200
+ cur = composedParent(cur);
201
+ }
202
+ return false;
203
+ }
204
+
205
+ function isRendered(node) {
206
+ if (!isDomVisibleEligible) return true;
207
+ try {
208
+ const vis = isDomVisibleEligible(node, ctx, {
209
+ visibilityMode: 'styleOnly',
210
+ disableGeometry: true
211
+ });
212
+ return !(vis && vis.eligible === false);
213
+ } catch {
214
+ return true;
215
+ }
216
+ }
217
+
218
+ function isTabStop(node) {
219
+ if (!getFocusableInfo) return false;
220
+ try {
221
+ const info = getFocusableInfo(node, ctx);
222
+ return !!(info && info.tabbable);
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // Shallowest tab stops inside `root`, stopping at each one and at any
229
+ // nested presentational-children role, so every tab stop is attributed to
230
+ // the nearest role that removes it from the accessibility tree.
231
+ function collectTabStops(root) {
232
+ const out = [];
233
+ const top = root && root.children;
234
+ if (!top || !top.length) return out;
235
+ const stack = [];
236
+ for (let i = top.length - 1; i >= 0; i--) stack.push(top[i]);
237
+ while (stack.length) {
238
+ const node = stack.pop();
239
+ if (!node || node.nodeType !== 1) continue;
240
+ if (lower(node.getAttribute && node.getAttribute('aria-hidden')) === 'true') continue;
241
+ if (!isRendered(node)) continue;
242
+ if (isTabStop(node)) {
243
+ out.push(node);
244
+ continue;
245
+ }
246
+ // A nested role with presentational children owns whatever tab stops
247
+ // are inside it (it is examined as its own container in the main
248
+ // loop). It is only a boundary when it is not itself a tab stop,
249
+ // a focusable one lands focus inside THIS element and belongs here.
250
+ if (getPresentationalChildrenRole(node)) continue;
251
+ const kids = node.children;
252
+ if (kids && kids.length) {
253
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
254
+ }
255
+ }
256
+ return out;
257
+ }
258
+
259
+ const SELECTOR = '[role], button, meter, option, progress';
260
+ const nodes = helpers.queryAllSmart
261
+ ? helpers.queryAllSmart(SELECTOR)
262
+ : helpers.queryAll(SELECTOR);
263
+
264
+ const occurrences = [];
265
+ let applicableCount = 0;
266
+
267
+ for (const el of nodes) {
268
+ if (!el || el.nodeType !== 1) continue;
269
+
270
+ const role = getPresentationalChildrenRole(el);
271
+ if (!role) continue;
272
+ // A container that is hidden, or sits in an aria-hidden subtree, is
273
+ // aria-hidden-focus's concern, see the note on inAriaHiddenSubtree.
274
+ if (!isExposed(el) || inAriaHiddenSubtree(el)) continue;
275
+
276
+ applicableCount += 1;
277
+
278
+ const tabStops = collectTabStops(el);
279
+ if (!tabStops.length) continue;
280
+
281
+ const tabStopTags = tabStops.map((n) => (n && n.tagName ? lower(n.tagName) : 'unknown'));
282
+ const dedupedTabStopTags = [...new Set(tabStopTags)];
283
+
284
+ const eligInfo = getEligibilityInfo
285
+ ? (() => {
286
+ try {
287
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
288
+ } catch {
289
+ return null;
290
+ }
291
+ })()
292
+ : null;
293
+
294
+ occurrences.push(
295
+ helpers.reportOccurrence(el, {
296
+ summary: `This role="${role}" element makes its children presentational, but it contains content that is still part of sequential focus navigation (${dedupedTabStopTags.join(', ')}).`,
297
+ hint: 'Move the focusable content outside this element, or remove the role that makes the children presentational, since focus landing inside it has no role or name to announce.',
298
+ i18n: {
299
+ summaryKey: 'presentationalChildrenFocusableAbsent_summary_fail',
300
+ hintKey: 'presentationalChildrenFocusableAbsent_hint_fail',
301
+ params: { role, focusableElements: dedupedTabStopTags.join(', ') }
302
+ },
303
+ data: {
304
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
305
+ details: {
306
+ reasonCode: 'PRESENTATIONAL_CHILDREN_FOCUSABLE_CONTENT',
307
+ role,
308
+ element: lower(el.tagName),
309
+ focusableElements: tabStopTags
310
+ }
311
+ }
312
+ })
313
+ );
314
+ }
315
+
316
+ if (applicableCount === 0) {
317
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
318
+ }
319
+ if (occurrences.length) {
320
+ return {
321
+ ruleId: rule.ruleId,
322
+ outcome: 'fail',
323
+ severity: rule.defaultSeverity || 'serious',
324
+ occurrences
325
+ };
326
+ }
327
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
328
+ }
329
+
330
+ module.exports = { id, meta, runInPage };