@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -2,6 +2,27 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check link-name-present
7
+ * @atomic true
8
+ * @summary Links must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to <a href>, <area href> and elements with role="link" that are
13
+ * included in the accessibility tree. An <a> without an href is not a link
14
+ * and is not matched.
15
+ * @expectation
16
+ * The element has a non-empty accessible name. A programmatic name is
17
+ * taken first (aria-labelledby, aria-label, an associated <label>, title),
18
+ * and failing that the element falls back to its own subtree text,
19
+ * counting each descendant's own name (an <img alt>, aria-label or title),
20
+ * the shape behind the common <a><img alt="..."></a> logo link. The
21
+ * content fallback is suppressed when an explicit, known role that is not
22
+ * name-from-content is present; an unrecognized role token falls back to
23
+ * the implicit role.
24
+ */
25
+
5
26
  const id = 'link-name-present';
6
27
 
7
28
  const meta = {
@@ -38,7 +59,7 @@ function runInPage(ctx) {
38
59
  let applicableCount = 0;
39
60
 
40
61
  function getConservativeSubtreeText(container) {
41
- // "Name from content" — recurses into descendants and uses each one's
62
+ // "Name from content", recurses into descendants and uses each one's
42
63
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
43
64
  // it has one, not just literal text nodes. See getContentNameInfo's
44
65
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -139,39 +160,34 @@ function runInPage(ctx) {
139
160
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
140
161
  : null;
141
162
 
142
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
143
- const html = helpers.getOuterHtmlSnippet
144
- ? helpers.getOuterHtmlSnippet(el)
145
- : el.outerHTML || '';
146
163
  const tag = (el.tagName || '').toLowerCase();
147
164
 
148
- occurrences.push({
149
- selector: stableSelector,
150
- html,
151
-
152
- // Human fallbacks (allowed)
153
- summary: 'This link has no accessible name.',
154
- hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
155
-
156
- // Validator requires these keys to exist in the English dictionary
157
- i18n: {
158
- summaryKey: 'linkNamePresent_summary_fail',
159
- hintKey: 'linkNamePresent_hint_fail',
160
- params: { element: tag }
161
- },
162
-
163
- data: {
164
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
165
- details: {
166
- reasonCode: 'name_missing',
167
- metrics: {
168
- programmaticNameLength: programmaticName.trim().length,
169
- contentNameLength: contentName.trim().length
170
- },
171
- refs: { accessibleName: nameInfo || null }
165
+ occurrences.push(
166
+ helpers.reportOccurrence(el, {
167
+ // Human fallbacks (allowed)
168
+ summary: 'This link has no accessible name.',
169
+ hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
170
+
171
+ // Validator requires these keys to exist in the English dictionary
172
+ i18n: {
173
+ summaryKey: 'linkNamePresent_summary_fail',
174
+ hintKey: 'linkNamePresent_hint_fail',
175
+ params: { element: tag }
176
+ },
177
+
178
+ data: {
179
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
180
+ details: {
181
+ reasonCode: 'name_missing',
182
+ metrics: {
183
+ programmaticNameLength: programmaticName.trim().length,
184
+ contentNameLength: contentName.trim().length
185
+ },
186
+ refs: { accessibleName: nameInfo || null }
187
+ }
172
188
  }
173
- }
174
- });
189
+ })
190
+ );
175
191
  }
176
192
  }
177
193
 
@@ -12,7 +12,7 @@
12
12
  * Applies to <ul>/<ol> elements that have at least one direct element
13
13
  * child.
14
14
  * @expectation
15
- * Every direct element child is <li>, <script>, or <template> — UNLESS it
15
+ * Every direct element child is <li>, <script>, or <template>. UNLESS it
16
16
  * has an explicit `role` attribute, in which case the explicit role wins
17
17
  * over the tag entirely: a child is valid iff that role is "listitem"
18
18
  * (so `<li role="presentation">`/`<li role="menuitem">` are invalid
@@ -20,19 +20,19 @@
20
20
  * given `role="listitem"` is valid). A wrapper <div> used for styling
21
21
  * (no role at all) still breaks list semantics the same as before.
22
22
  * @implementation-notes
23
- * - Checked via el.children, which already excludes text/comment nodes —
23
+ * - Checked via el.children, which already excludes text/comment nodes,
24
24
  * no whitespace-node filtering needed.
25
25
  * - Distinct, atomic decision from listitem-parent-valid (the
26
26
  * inverse relationship: does a given <li> have a valid parent).
27
27
  * - Direct children that are not exposed to the accessibility tree (e.g.
28
28
  * display:none, [hidden], aria-hidden="true") are excluded from
29
- * consideration entirely — an element not reachable by assistive
29
+ * consideration entirely. An element not reachable by assistive
30
30
  * technology can't break the list semantics a screen reader announces.
31
31
  * Common cases: a stray `<input type="hidden">` as a direct <ul> child
32
32
  * (UA-stylesheet display:none by spec), or `<span style="display:none">`
33
33
  * hydration markers interleaved with real `<li>`s.
34
34
  * - Explicit-role-overrides-tag: if a child has an explicit role, only
35
- * `['listitem']` is consulted — the tag name is never checked. Only
35
+ * `['listitem']` is consulted, the tag name is never checked. Only
36
36
  * without an explicit role does the tag name matter. Catches cases a
37
37
  * tag-only check misses: `<li role="none">` hosting a list's own
38
38
  * visually-hidden label, `<li role="menuitem">` menu items, or a real
@@ -71,7 +71,7 @@ const meta = {
71
71
  function runInPage(ctx) {
72
72
  const { helpers, rule } = ctx;
73
73
 
74
- // Declared inside runInPage — see scripts/build-core.js header
74
+ // Declared inside runInPage, see scripts/build-core.js header
75
75
  // ("runInPage MUST be self-contained").
76
76
  const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
77
77
 
@@ -111,7 +111,7 @@ function runInPage(ctx) {
111
111
  const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
112
112
  const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
113
113
 
114
- // An explicit role always wins over the tag — see header comment.
114
+ // An explicit role always wins over the tag, see header comment.
115
115
  const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
116
116
 
117
117
  if (!valid) invalidTags.push(tag);
@@ -122,23 +122,21 @@ function runInPage(ctx) {
122
122
  const dedupedInvalidTags = [...new Set(invalidTags)];
123
123
 
124
124
  const tag = el.tagName.toLowerCase();
125
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
126
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
127
-
128
- occurrences.push({
129
- selector: stableSelector,
130
- html,
131
- summary: 'This list contains a direct child that is not a list item.',
132
- hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
133
- i18n: {
134
- summaryKey: 'listChildrenValid_summary_fail',
135
- hintKey: 'listChildrenValid_hint_fail',
136
- params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
137
- },
138
- data: {
139
- details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
140
- }
141
- });
125
+
126
+ occurrences.push(
127
+ helpers.reportOccurrence(el, {
128
+ summary: 'This list contains a direct child that is not a list item.',
129
+ hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
130
+ i18n: {
131
+ summaryKey: 'listChildrenValid_summary_fail',
132
+ hintKey: 'listChildrenValid_hint_fail',
133
+ params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
137
+ }
138
+ })
139
+ );
142
140
  }
143
141
 
144
142
  if (applicableCount === 0) {
@@ -2,11 +2,31 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check listbox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="listbox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="listbox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute 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="listbox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom listbox widget is not reliably
21
+ * exposed as its name. On a labelable element (<select multiple
22
+ * role="listbox">) an associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'listbox-name-present';
6
26
 
7
27
  const meta = {
8
- title: 'Accessible name is present',
9
- description: 'Checks that elements expose a non-empty accessible name.',
28
+ title: 'Listboxes have an accessible name',
29
+ description: 'Checks that elements with role="listbox" expose a non-empty accessible name.',
10
30
  i18n: {
11
31
  titleKey: 'listboxNamePresent_title',
12
32
  descriptionKey: 'listboxNamePresent_description'
@@ -51,7 +71,7 @@ function runInPage(ctx) {
51
71
  }
52
72
 
53
73
  function getConservativeSubtreeText(document, container) {
54
- // "Name from content" — recurses into descendants and uses each one's
74
+ // "Name from content", recurses into descendants and uses each one's
55
75
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
56
76
  // it has one, not just literal text nodes. See getContentNameInfo's
57
77
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -68,7 +88,7 @@ function runInPage(ctx) {
68
88
 
69
89
  // A <label> contributes a name via its own aria-label/aria-labelledby
70
90
  // (checked first, same ARIA-over-content precedence any element's
71
- // accessible name gives — e.g. <label aria-label="Search"><svg
91
+ // accessible name gives, e.g. <label aria-label="Search"><svg
72
92
  // aria-hidden="true">...</svg></label> names its control "Search" even
73
93
  // though the label's only child content is aria-hidden) or, failing
74
94
  // that, its rendered content (getConservativeSubtreeText).
@@ -83,10 +103,10 @@ function runInPage(ctx) {
83
103
  if (content) return content;
84
104
  // Final fallback per the general accname text-alternative algorithm,
85
105
  // which applies to any element being asked for its name regardless of
86
- // why (own aria-label, an aria-labelledby reference, or — here — native
106
+ // why (own aria-label, an aria-labelledby reference, or, here, native
87
107
  // <label for> association): title, when nothing else yields a name.
88
108
  // Purely additive (only fills in a name where there was none before),
89
- // so it carries no false-positive risk — see dialog-name-present.js's
109
+ // so it carries no false-positive risk, see dialog-name-present.js's
90
110
  // identical <iframe>-title-fallback fix for the concrete real-world
91
111
  // trigger this same accname step covers elsewhere.
92
112
  return getAttr(lab, 'title');
@@ -96,7 +116,7 @@ function runInPage(ctx) {
96
116
  const raw = getAttr(el, 'aria-labelledby');
97
117
  if (!raw) return '';
98
118
  // Delegates to the shared getTextFromIdRefs helper instead of computing
99
- // name-from-content of the referenced element — see dialog-name-
119
+ // name-from-content of the referenced element, see dialog-name-
100
120
  // present.js's identical fix for the full rationale (an <iframe>
101
121
  // aria-labelledby target's only name source is its title attribute,
102
122
  // which name-from-content alone can never see).
@@ -227,24 +247,22 @@ function runInPage(ctx) {
227
247
  }
228
248
  })()
229
249
  : null;
230
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
231
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
232
250
 
233
- occurrences.push({
234
- selector: stableSelector,
235
- html,
236
- summary: 'This element has no accessible name.',
237
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
238
- i18n: {
239
- summaryKey: 'listboxNamePresent_summary_fail',
240
- hintKey: 'listboxNamePresent_hint_fail',
241
- params: { controlType: 'listbox' }
242
- },
243
- data: {
244
- details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
245
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
246
- }
247
- });
251
+ occurrences.push(
252
+ helpers.reportOccurrence(el, {
253
+ summary: 'This element has no accessible name.',
254
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this listbox's accessible name.",
255
+ i18n: {
256
+ summaryKey: 'listboxNamePresent_summary_fail',
257
+ hintKey: 'listboxNamePresent_hint_fail',
258
+ params: { controlType: 'listbox' }
259
+ },
260
+ data: {
261
+ details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
262
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
263
+ }
264
+ })
265
+ );
248
266
  }
249
267
 
250
268
  if (applicableCount === 0) {
@@ -24,16 +24,16 @@
24
24
  * e.g. aria-helpers.js getExplicitRole).
25
25
  * - An explicit role on the parent WINS over its tag name, in either
26
26
  * direction: a <ul role="menu"> no longer exposes role "list" (its own
27
- * native role is fully replaced by the explicit one — the same "any
27
+ * native role is fully replaced by the explicit one, the same "any
28
28
  * explicit role overrides the element's native role" ARIA principle
29
29
  * applied elsewhere in this engine), so an <li> inside it is invalid
30
30
  * despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
31
31
  * role="presentation"/"none" on the parent is still a valid
32
- * (list-semantics-suppressing) parent — the accepted parent roles are
32
+ * (list-semantics-suppressing) parent, the accepted parent roles are
33
33
  * presentation, none, and list.
34
34
  * - The SAME "explicit role wins" principle applies to the <li> ELEMENT
35
35
  * ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
36
- * is exposed to AT with that role, never "listitem" — the whole point
36
+ * is exposed to AT with that role, never "listitem". The whole point
37
37
  * of this check (list items need a valid list-container parent) doesn't
38
38
  * apply when the element isn't claiming listitem semantics in the first
39
39
  * place. Any `<li>` with an explicit `role` attribute is excluded from
@@ -108,7 +108,7 @@ function runInPage(ctx) {
108
108
  let valid;
109
109
  if (explicitRole) {
110
110
  // An explicit role always wins over the tag's native role, in either
111
- // direction — see the header comment.
111
+ // direction, see the header comment.
112
112
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
113
113
  } else {
114
114
  valid = parentTag === 'ul' || parentTag === 'ol';
@@ -116,23 +116,20 @@ function runInPage(ctx) {
116
116
 
117
117
  if (valid) continue;
118
118
 
119
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
120
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
121
-
122
- occurrences.push({
123
- selector: stableSelector,
124
- html,
125
- summary: 'This list item is not contained by a list container.',
126
- hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
127
- i18n: {
128
- summaryKey: 'listitemParentValid_summary_fail',
129
- hintKey: 'listitemParentValid_hint_fail',
130
- params: { parentElement: parentTag }
131
- },
132
- data: {
133
- details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
134
- }
135
- });
119
+ occurrences.push(
120
+ helpers.reportOccurrence(el, {
121
+ summary: 'This list item is not contained by a list container.',
122
+ hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
123
+ i18n: {
124
+ summaryKey: 'listitemParentValid_summary_fail',
125
+ hintKey: 'listitemParentValid_hint_fail',
126
+ params: { parentElement: parentTag }
127
+ },
128
+ data: {
129
+ details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
130
+ }
131
+ })
132
+ );
136
133
  }
137
134
 
138
135
  if (applicableCount === 0) {
@@ -2,6 +2,24 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check menuitem-name-present
7
+ * @atomic true
8
+ * @summary Menu items must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="menuitem", role="menuitemcheckbox" or
13
+ * role="menuitemradio" (the attribute must name one of those roles alone,
14
+ * not a fallback list) that are included in the accessibility tree.
15
+ * @expectation
16
+ * The element has a non-empty accessible name from aria-label, from an
17
+ * aria-labelledby that resolves to non-empty text, from title, or, all
18
+ * three roles being name-from-content, from its own subtree text, where a
19
+ * descendant's own name (an <img alt>, aria-label or title) counts as that
20
+ * descendant's contribution rather than only its text nodes.
21
+ */
22
+
5
23
  const id = 'menuitem-name-present';
6
24
 
7
25
  const meta = {
@@ -52,7 +70,7 @@ function runInPage(ctx) {
52
70
  }
53
71
 
54
72
  function getConservativeSubtreeText(document, container) {
55
- // "Name from content" — recurses into descendants and uses each one's
73
+ // "Name from content", recurses into descendants and uses each one's
56
74
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
57
75
  // it has one, not just literal text nodes. See getContentNameInfo's
58
76
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -71,7 +89,7 @@ function runInPage(ctx) {
71
89
  const raw = getAttr(el, 'aria-labelledby');
72
90
  if (!raw) return '';
73
91
  // Delegates to the shared getTextFromIdRefs helper instead of computing
74
- // name-from-content of the referenced element — see dialog-name-
92
+ // name-from-content of the referenced element, see dialog-name-
75
93
  // present.js's identical fix for the full rationale (an <iframe>
76
94
  // aria-labelledby target's only name source is its title attribute,
77
95
  // which name-from-content alone can never see).
@@ -150,24 +168,22 @@ function runInPage(ctx) {
150
168
  }
151
169
  })()
152
170
  : null;
153
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
154
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
155
-
156
- occurrences.push({
157
- selector: stableSelector,
158
- html,
159
- summary: 'This menu item has no accessible name.',
160
- hint: 'Provide visible text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
161
- i18n: {
162
- summaryKey: 'menuitemNamePresent_summary_fail',
163
- hintKey: 'menuitemNamePresent_hint_fail',
164
- params: { role }
165
- },
166
- data: {
167
- details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
168
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
169
- }
170
- });
171
+
172
+ occurrences.push(
173
+ helpers.reportOccurrence(el, {
174
+ summary: 'This menu item has no accessible name.',
175
+ hint: 'Provide visible text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
176
+ i18n: {
177
+ summaryKey: 'menuitemNamePresent_summary_fail',
178
+ hintKey: 'menuitemNamePresent_hint_fail',
179
+ params: { role }
180
+ },
181
+ data: {
182
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
183
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
184
+ }
185
+ })
186
+ );
171
187
  }
172
188
 
173
189
  if (applicableCount === 0) {
@@ -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,28 +126,32 @@ 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
 
129
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
130
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
137
+ if (delay === 0) break; // an immediate redirect is not a timed interruption
131
138
 
132
- occurrences.push({
133
- selector: stableSelector,
134
- html,
135
- summary:
136
- 'This page uses a meta refresh, which is an automatic context change not initiated by the user.',
137
- hint: 'Remove the meta refresh; trigger the redirect/refresh only in response to a user action instead.',
138
- i18n: {
139
- summaryKey: 'metaRefreshNoExceptions_summary_fail',
140
- hintKey: 'metaRefreshNoExceptions_hint_fail',
141
- params: {}
142
- },
143
- data: {
144
- details: { reasonCode: 'META_REFRESH_PRESENT' }
145
- }
146
- });
139
+ occurrences.push(
140
+ helpers.reportOccurrence(el, {
141
+ summary:
142
+ 'This page uses a meta refresh, which is an automatic context change not initiated by the user.',
143
+ hint: 'Remove the meta refresh; trigger the redirect/refresh only in response to a user action instead.',
144
+ i18n: {
145
+ summaryKey: 'metaRefreshNoExceptions_summary_fail',
146
+ hintKey: 'metaRefreshNoExceptions_hint_fail',
147
+ params: {}
148
+ },
149
+ data: {
150
+ details: { reasonCode: 'META_REFRESH_PRESENT' }
151
+ }
152
+ })
153
+ );
154
+ break;
147
155
  }
148
156
 
149
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,35 +112,36 @@ 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)
123
-
124
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
125
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
126
-
127
- occurrences.push({
128
- selector: stableSelector,
129
- html,
130
- summary: 'This page refreshes itself automatically after a delay.',
131
- hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
132
- i18n: {
133
- summaryKey: 'metaRefreshTimingAbsent_summary_fail',
134
- hintKey: 'metaRefreshTimingAbsent_hint_fail',
135
- params: { delay: String(delay) }
136
- },
137
- data: {
138
- details: { reasonCode: 'META_REFRESH_DELAYED', delay }
139
- }
140
- });
127
+ if (!(delay > 0)) break;
128
+ if (delay > EXEMPT_DELAY_SECONDS) break; // WCAG 2.2.1 Exception 3 (>20 hours)
129
+
130
+ occurrences.push(
131
+ helpers.reportOccurrence(el, {
132
+ summary: 'This page refreshes itself automatically after a delay.',
133
+ hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
134
+ i18n: {
135
+ summaryKey: 'metaRefreshTimingAbsent_summary_fail',
136
+ hintKey: 'metaRefreshTimingAbsent_hint_fail',
137
+ params: { delay: String(delay) }
138
+ },
139
+ data: {
140
+ details: { reasonCode: 'META_REFRESH_DELAYED', delay }
141
+ }
142
+ })
143
+ );
144
+ break;
141
145
  }
142
146
 
143
147
  if (applicableCount === 0) {