@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'link-name-present';
@@ -13,7 +15,13 @@ const meta = {
13
15
  tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
14
16
  wcagSc: ['4.1.2'],
15
17
  normativeMappings: [
16
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
18
+ {
19
+ standard: 'WCAG',
20
+ version: '2.2',
21
+ requirement: '4.1.2',
22
+ title: 'Name, Role, Value',
23
+ conformanceLevel: 'A'
24
+ }
17
25
  ],
18
26
  defaultSeverity: 'serious',
19
27
  category: 'robust',
@@ -24,7 +32,7 @@ const meta = {
24
32
  };
25
33
 
26
34
  function runInPage(ctx) {
27
- const { document, helpers, rule } = ctx;
35
+ const { helpers, rule } = ctx;
28
36
 
29
37
  const occurrences = [];
30
38
  let applicableCount = 0;
@@ -40,48 +48,68 @@ function runInPage(ctx) {
40
48
  const info = helpers.getContentNameInfo(container, ctx);
41
49
  return info && info.present ? info.value : '';
42
50
  }
43
- const t = (container && container.textContent) ? String(container.textContent) : '';
51
+ const t = container && container.textContent ? String(container.textContent) : '';
44
52
  return t.replace(/\s+/g, ' ').trim();
45
53
  }
46
54
 
47
55
  const selector = 'a[href], area[href], [role="link"]';
48
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
56
+ const nodes = helpers.queryAllSmart
57
+ ? helpers.queryAllSmart(selector)
58
+ : helpers.queryAll(selector);
49
59
 
50
60
  for (const el of nodes) {
51
61
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
52
62
  const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
53
- const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
63
+ const eligible =
64
+ typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
54
65
  if (!eligible) continue;
55
66
 
56
67
  applicableCount += 1;
57
68
 
58
69
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
59
- const programmaticName = (nameInfo && typeof nameInfo.value === 'string') ? nameInfo.value : '';
70
+ const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
60
71
 
61
72
  // A native <a href>/<area href> (or [role="link"]) whose role has been
62
73
  // overridden to one of these roles is no longer semantically a link —
63
74
  // per the WAI-ARIA Accessible Name and Description Computation spec
64
75
  // these roles are name-from-author-only, and their rendered content
65
- // represents a VALUE, not a NAME (mirrors axe-core's controlValueRoles,
66
- // verified against its source; see button-name-present.js's identical
76
+ // represents a VALUE, not a NAME (see button-name-present.js's identical
67
77
  // fix for a real-world example — <button role="combobox">List</button> —
68
78
  // of the same class of bug on a different host tag).
69
79
  const role = el.getAttribute ? el.getAttribute('role') : null;
70
- const roleNorm = String(role || '').replace(/\s+/g, ' ').trim().toLowerCase();
71
- const VALUE_ROLES = ['textbox', 'progressbar', 'scrollbar', 'slider', 'spinbutton', 'combobox', 'listbox'];
80
+ const roleNorm = String(role || '')
81
+ .replace(/\s+/g, ' ')
82
+ .trim()
83
+ .toLowerCase();
84
+ const VALUE_ROLES = [
85
+ 'textbox',
86
+ 'progressbar',
87
+ 'scrollbar',
88
+ 'slider',
89
+ 'spinbutton',
90
+ 'combobox',
91
+ 'listbox'
92
+ ];
72
93
  const isContentNameCandidate = !VALUE_ROLES.includes(roleNorm);
73
94
 
74
- const contentName = (programmaticName.trim().length === 0 && isContentNameCandidate) ? getConservativeSubtreeText(el) : '';
95
+ const contentName =
96
+ programmaticName.trim().length === 0 && isContentNameCandidate
97
+ ? getConservativeSubtreeText(el)
98
+ : '';
75
99
  const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
76
100
 
77
101
  if (finalName.length === 0) {
78
102
  // Only compute the richer eligibility-info payload (used solely for
79
103
  // the occurrence's visibilityFilter) once we know an occurrence is
80
104
  // actually being built, rather than for every applicable element.
81
- const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
105
+ const eligInfo = helpers.getEligibilityInfo
106
+ ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
107
+ : null;
82
108
 
83
109
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
84
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
110
+ const html = helpers.getOuterHtmlSnippet
111
+ ? helpers.getOuterHtmlSnippet(el)
112
+ : el.outerHTML || '';
85
113
  const tag = (el.tagName || '').toLowerCase();
86
114
 
87
115
  occurrences.push({
@@ -118,7 +146,12 @@ function runInPage(ctx) {
118
146
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
119
147
  }
120
148
  if (occurrences.length) {
121
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
149
+ return {
150
+ ruleId: rule.ruleId,
151
+ outcome: 'fail',
152
+ severity: rule.defaultSeverity || 'minor',
153
+ occurrences
154
+ };
122
155
  }
123
156
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
124
157
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -24,35 +26,25 @@
24
26
  * inverse relationship: does a given <li> have a valid parent).
25
27
  * - Direct children that are not exposed to the accessibility tree (e.g.
26
28
  * display:none, [hidden], aria-hidden="true") are excluded from
27
- * consideration entirely — matching a widely-used reference engine's own
28
- * `only-listitems` check, which likewise skips non-AT-visible children. Confirmed against
29
- * two real sites: Spotify's newsroom has a stray `<input type="hidden">`
30
- * as a direct <ul> child (UA-stylesheet display:none by spec for
31
- * input[type=hidden]), and Stanford's main nav interleaves `<li>` with
32
- * `<span style="display:none">` hydration markers. Neither is reachable
33
- * by assistive technology, so neither breaks the list semantics a screen
34
- * reader actually announces.
35
- * - The explicit-role-overrides-tag behavior above was added after reading
36
- * a widely-used reference engine's ACTUAL `only-listitems` evaluate function
37
- * directly (`invalidChildrenEvaluate`/`getInvalidSelector` — note this is a
38
- * DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
39
- * elsewhere in that engine's source; don't confuse the two when re-verifying). Its
40
- * exact rule: if a child has an explicit role, only `validRoles` (here,
41
- * `['listitem']`) is consulted — the tag name is never checked. Only
42
- * without an explicit role does the tag name (`validNodeNames`) matter.
43
- * Found via two real sites: Notion's footer nav lists include a
44
- * `<li role="none">` used purely to host the list's own visually-hidden
45
- * label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
46
- * both an all-`<li role="menuitem">` menu list and a footer list mixing
47
- * real `<li>`s with one `<li role="presentation">` — that reference engine
48
- * correctly flags all of these, surea11y's tag-only check missed them entirely.
29
+ * consideration entirely — an element not reachable by assistive
30
+ * technology can't break the list semantics a screen reader announces.
31
+ * Common cases: a stray `<input type="hidden">` as a direct <ul> child
32
+ * (UA-stylesheet display:none by spec), or `<span style="display:none">`
33
+ * hydration markers interleaved with real `<li>`s.
34
+ * - Explicit-role-overrides-tag: if a child has an explicit role, only
35
+ * `['listitem']` is consulted — the tag name is never checked. Only
36
+ * without an explicit role does the tag name matter. Catches cases a
37
+ * tag-only check misses: `<li role="none">` hosting a list's own
38
+ * visually-hidden label, `<li role="menuitem">` menu items, or a real
39
+ * `<li>` mixed with an `<li role="presentation">`.
49
40
  */
50
41
 
51
42
  const id = 'list-children-valid';
52
43
 
53
44
  const meta = {
54
45
  title: 'Lists must only directly contain list items',
55
- description: 'Checks that <ul>/<ol> elements only have <li>, <script>, or <template> as direct children.',
46
+ description:
47
+ 'Checks that <ul>/<ol> elements only have <li>, <script>, or <template> as direct children.',
56
48
  i18n: {
57
49
  titleKey: 'listChildrenValid_title',
58
50
  descriptionKey: 'listChildrenValid_description'
@@ -61,7 +53,13 @@ const meta = {
61
53
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
62
54
  wcagSc: ['1.3.1'],
63
55
  normativeMappings: [
64
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
56
+ {
57
+ standard: 'WCAG',
58
+ version: '2.2',
59
+ requirement: '1.3.1',
60
+ title: 'Info and Relationships',
61
+ conformanceLevel: 'A'
62
+ }
65
63
  ],
66
64
  defaultSeverity: 'serious',
67
65
  category: 'perceivable',
@@ -71,15 +69,18 @@ const meta = {
71
69
  };
72
70
 
73
71
  function runInPage(ctx) {
74
- const { document, helpers, rule } = ctx;
72
+ const { helpers, rule } = ctx;
75
73
 
76
74
  // Declared inside runInPage — see scripts/build-core.js header
77
75
  // ("runInPage MUST be self-contained").
78
76
  const ALLOWED_CHILD_TAGS = new Set(['li', 'script', 'template']);
79
77
 
80
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('ul, ol') : helpers.queryAll('ul, ol');
78
+ const nodes = helpers.queryAllSmart
79
+ ? helpers.queryAllSmart('ul, ol')
80
+ : helpers.queryAll('ul, ol');
81
81
 
82
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
82
+ const isAccTreeEligible =
83
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
83
84
 
84
85
  function isExposedToAt(el) {
85
86
  if (!isAccTreeEligible) return true;
@@ -110,8 +111,7 @@ function runInPage(ctx) {
110
111
  const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
111
112
  const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
112
113
 
113
- // An explicit role always wins over the tag — see the header
114
- // comment's Notion/LinkedIn examples.
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);
@@ -119,9 +119,11 @@ function runInPage(ctx) {
119
119
 
120
120
  if (!invalidTags.length) continue;
121
121
 
122
+ const dedupedInvalidTags = [...new Set(invalidTags)];
123
+
122
124
  const tag = el.tagName.toLowerCase();
123
125
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
124
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
126
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
125
127
 
126
128
  occurrences.push({
127
129
  selector: stableSelector,
@@ -131,7 +133,7 @@ function runInPage(ctx) {
131
133
  i18n: {
132
134
  summaryKey: 'listChildrenValid_summary_fail',
133
135
  hintKey: 'listChildrenValid_hint_fail',
134
- params: { element: tag, invalidChildren: invalidTags.join(', ') }
136
+ params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
135
137
  },
136
138
  data: {
137
139
  details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
@@ -143,9 +145,14 @@ function runInPage(ctx) {
143
145
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
144
146
  }
145
147
  if (occurrences.length) {
146
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
148
+ return {
149
+ ruleId: rule.ruleId,
150
+ outcome: 'fail',
151
+ severity: rule.defaultSeverity || 'serious',
152
+ occurrences
153
+ };
147
154
  }
148
155
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
149
156
  }
150
157
 
151
- module.exports = { id, meta, runInPage };
158
+ module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'listbox-name-present';
@@ -10,10 +12,16 @@ const meta = {
10
12
  descriptionKey: 'listboxNamePresent_description'
11
13
  },
12
14
  helpUrl: null,
13
- tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "listbox"],
15
+ tags: ['wcag2a', 'wcag412', 'forms', 'atomic', 'automatic', 'name', 'listbox'],
14
16
  wcagSc: ['4.1.2'],
15
17
  normativeMappings: [
16
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
18
+ {
19
+ standard: 'WCAG',
20
+ version: '2.2',
21
+ requirement: '4.1.2',
22
+ title: 'Name, Role, Value',
23
+ conformanceLevel: 'A'
24
+ }
17
25
  ],
18
26
  defaultSeverity: 'serious',
19
27
  category: 'robust',
@@ -24,20 +32,22 @@ const meta = {
24
32
 
25
33
  function runInPage(ctx) {
26
34
  const { document, helpers, rule } = ctx;
27
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
28
- ? helpers.getEligibilityInfo
29
- : null;
30
-
35
+ const getEligibilityInfo =
36
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
31
37
 
32
38
  function normalizeWs(s) {
33
- return String(s || '').replace(/\s+/g, ' ').trim();
39
+ return String(s || '')
40
+ .replace(/\s+/g, ' ')
41
+ .trim();
34
42
  }
35
43
 
36
44
  function getAttr(el, name) {
37
45
  try {
38
46
  if (!el || !el.getAttribute) return '';
39
47
  return normalizeWs(el.getAttribute(name));
40
- } catch { return ''; }
48
+ } catch {
49
+ return '';
50
+ }
41
51
  }
42
52
 
43
53
  function getConservativeSubtreeText(document, container) {
@@ -52,7 +62,7 @@ function runInPage(ctx) {
52
62
  const info = helpers.getContentNameInfo(container, ctx);
53
63
  return info && info.present ? info.value : '';
54
64
  }
55
- const t = (container && container.textContent) ? String(container.textContent) : '';
65
+ const t = container && container.textContent ? String(container.textContent) : '';
56
66
  return t.replace(/\s+/g, ' ').trim();
57
67
  }
58
68
 
@@ -100,7 +110,8 @@ function runInPage(ctx) {
100
110
  }
101
111
 
102
112
  function isEligibleAcc(helpers, el, ctx) {
103
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
113
+ const fn =
114
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
104
115
  if (!fn) return true;
105
116
  try {
106
117
  const r = fn(el, ctx);
@@ -126,12 +137,13 @@ function runInPage(ctx) {
126
137
  return map;
127
138
  }
128
139
 
129
-
130
140
  const occurrences = [];
131
141
  let applicableCount = 0;
132
142
 
133
- const selector = "[role=\"listbox\"]";
134
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
143
+ const selector = '[role="listbox"]';
144
+ const nodes = helpers.queryAllSmart
145
+ ? helpers.queryAllSmart(selector)
146
+ : helpers.queryAll(selector);
135
147
 
136
148
  // Precompute label[for] map for listbox elements that are labelable
137
149
  // native form controls (e.g. <select multiple role="listbox">).
@@ -200,16 +212,22 @@ function runInPage(ctx) {
200
212
  if (res.ok) continue;
201
213
 
202
214
  const eligInfo = getEligibilityInfo
203
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
204
- : null;
215
+ ? (() => {
216
+ try {
217
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
218
+ } catch {
219
+ return null;
220
+ }
221
+ })()
222
+ : null;
205
223
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
206
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
224
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
207
225
 
208
226
  occurrences.push({
209
227
  selector: stableSelector,
210
228
  html,
211
229
  summary: 'This element has no accessible name.',
212
- hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
230
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
213
231
  i18n: {
214
232
  summaryKey: 'listboxNamePresent_summary_fail',
215
233
  hintKey: 'listboxNamePresent_hint_fail',
@@ -222,12 +240,16 @@ function runInPage(ctx) {
222
240
  });
223
241
  }
224
242
 
225
-
226
243
  if (applicableCount === 0) {
227
244
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
228
245
  }
229
246
  if (occurrences.length) {
230
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
247
+ return {
248
+ ruleId: rule.ruleId,
249
+ outcome: 'fail',
250
+ severity: rule.defaultSeverity || 'minor',
251
+ occurrences
252
+ };
231
253
  }
232
254
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
233
255
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -25,19 +27,27 @@
25
27
  * native role is fully replaced by the explicit one — the same "any
26
28
  * explicit role overrides the element's native role" ARIA principle
27
29
  * applied elsewhere in this engine), so an <li> inside it is invalid
28
- * despite the <ul> tag — found on a real site, Nike's desktop nav
29
- * dropdown (`<ul class="desktop-category" role="menu"><li>...`).
30
- * Conversely role="presentation"/"none" on the parent is still a valid
31
- * (list-semantics-suppressing) parent, matching a widely-used reference
32
- * engine's own `listitem` check (`['presentation', 'none', 'list'].includes(parentRole)`,
33
- * confirmed by reading its source directly).
30
+ * despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
31
+ * role="presentation"/"none" on the parent is still a valid
32
+ * (list-semantics-suppressing) parent — the accepted parent roles are
33
+ * presentation, none, and list.
34
+ * - The SAME "explicit role wins" principle applies to the <li> ELEMENT
35
+ * ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
36
+ * is exposed to AT with that role, never "listitem" — the whole point
37
+ * of this check (list items need a valid list-container parent) doesn't
38
+ * apply when the element isn't claiming listitem semantics in the first
39
+ * place. Any `<li>` with an explicit `role` attribute is excluded from
40
+ * candidacy. `role="listitem"` itself is a no-op restatement (not an
41
+ * override), so it still falls through to the normal parent-validity
42
+ * check below.
34
43
  */
35
44
 
36
45
  const id = 'listitem-parent-valid';
37
46
 
38
47
  const meta = {
39
48
  title: 'List items must be inside a list container',
40
- description: 'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
49
+ description:
50
+ 'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
41
51
  i18n: {
42
52
  titleKey: 'listitemParentValid_title',
43
53
  descriptionKey: 'listitemParentValid_description'
@@ -46,7 +56,13 @@ const meta = {
46
56
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
47
57
  wcagSc: ['1.3.1'],
48
58
  normativeMappings: [
49
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
59
+ {
60
+ standard: 'WCAG',
61
+ version: '2.2',
62
+ requirement: '1.3.1',
63
+ title: 'Info and Relationships',
64
+ conformanceLevel: 'A'
65
+ }
50
66
  ],
51
67
  defaultSeverity: 'serious',
52
68
  category: 'perceivable',
@@ -56,7 +72,7 @@ const meta = {
56
72
  };
57
73
 
58
74
  function runInPage(ctx) {
59
- const { document, helpers, rule } = ctx;
75
+ const { helpers, rule } = ctx;
60
76
 
61
77
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('li') : helpers.queryAll('li');
62
78
 
@@ -68,6 +84,20 @@ function runInPage(ctx) {
68
84
  const parent = el.parentElement;
69
85
  if (!parent) continue;
70
86
 
87
+ // An explicit role on the <li> ITSELF overrides its native "listitem"
88
+ // role entirely, the same "any explicit role wins over the tag's
89
+ // native role" principle this check already applies to the PARENT
90
+ // (see the header comment). An <li role="tab">/
91
+ // role="menuitem">/role="presentation"> etc. is exposed to AT with
92
+ // THAT role, never "listitem" -- so the "list item needs a valid list
93
+ // container parent" concern this rule exists for doesn't apply to it
94
+ // at all; there's no listitem semantics being claimed to validate.
95
+ // role="listitem" itself is a no-op restatement, not an override, so
96
+ // it still falls through to the normal parent check below.
97
+ const ownRoleAttr = el.getAttribute ? String(el.getAttribute('role') || '').trim() : '';
98
+ const ownExplicitRole = ownRoleAttr ? (ownRoleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
99
+ if (ownExplicitRole && ownExplicitRole !== 'listitem') continue;
100
+
71
101
  applicableCount += 1;
72
102
 
73
103
  const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
@@ -78,7 +108,7 @@ function runInPage(ctx) {
78
108
  let valid;
79
109
  if (explicitRole) {
80
110
  // An explicit role always wins over the tag's native role, in either
81
- // direction — see the header comment's Nike example.
111
+ // direction — see the header comment.
82
112
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
83
113
  } else {
84
114
  valid = parentTag === 'ul' || parentTag === 'ol';
@@ -87,7 +117,7 @@ function runInPage(ctx) {
87
117
  if (valid) continue;
88
118
 
89
119
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
120
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
121
 
92
122
  occurrences.push({
93
123
  selector: stableSelector,
@@ -109,9 +139,14 @@ function runInPage(ctx) {
109
139
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
110
140
  }
111
141
  if (occurrences.length) {
112
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
142
+ return {
143
+ ruleId: rule.ruleId,
144
+ outcome: 'fail',
145
+ severity: rule.defaultSeverity || 'serious',
146
+ occurrences
147
+ };
113
148
  }
114
149
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
115
150
  }
116
151
 
117
- module.exports = { id, meta, runInPage };
152
+ module.exports = { id, meta, runInPage };