@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 = 'tab-name-present';
@@ -10,10 +12,16 @@ const meta = {
10
12
  descriptionKey: 'tabNamePresent_description'
11
13
  },
12
14
  helpUrl: null,
13
- tags: ['wcag2a','wcag412','navigation','atomic','automatic','name','tab'],
15
+ tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name', 'tab'],
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,53 +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 ''; }
41
- }
42
-
43
- function hasAttr(el, name) {
44
- try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
45
- }
46
-
47
- function isExplicitProgrammatic(el) {
48
- // Programmatic name mechanisms we treat as authoritative (presence-only):
49
- // - aria-label (non-empty)
50
- // - aria-labelledby (non-empty)
51
- // - title (non-empty) [weak but allowed for presence]
52
- const al = getAttr(el, 'aria-label');
53
- if (al) return true;
54
- const alb = getAttr(el, 'aria-labelledby');
55
- if (alb) return true;
56
- const t = getAttr(el, 'title');
57
- if (t) return true;
58
- return false;
59
- }
60
-
61
- function buildLabelForMap(doc) {
62
- const map = new Map(); // id -> label element (first)
63
- try {
64
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
65
- for (let i = 0; i < labels.length; i += 1) {
66
- const lab = labels[i];
67
- if (!lab || !lab.getAttribute) continue;
68
- const f = normalizeWs(lab.getAttribute('for'));
69
- if (!f) continue;
70
- if (!map.has(f)) map.set(f, lab);
71
- }
72
- } catch {}
73
- return map;
48
+ } catch {
49
+ return '';
50
+ }
74
51
  }
75
52
 
76
53
  function getConservativeSubtreeText(document, container) {
@@ -85,7 +62,7 @@ function runInPage(ctx) {
85
62
  const info = helpers.getContentNameInfo(container, ctx);
86
63
  return info && info.present ? info.value : '';
87
64
  }
88
- const t = (container && container.textContent) ? String(container.textContent) : '';
65
+ const t = container && container.textContent ? String(container.textContent) : '';
89
66
  return t.replace(/\s+/g, ' ').trim();
90
67
  }
91
68
 
@@ -106,17 +83,9 @@ function runInPage(ctx) {
106
83
  return '';
107
84
  }
108
85
 
109
- function getInputValueName(el) {
110
- try {
111
- const type = getAttr(el, 'type').toLowerCase();
112
- if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
113
- const v = getAttr(el, 'value');
114
- return v;
115
- } catch { return ''; }
116
- }
117
-
118
86
  function isEligibleAcc(helpers, el, ctx) {
119
- const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
87
+ const fn =
88
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
120
89
  if (!fn) return true;
121
90
  try {
122
91
  const r = fn(el, ctx);
@@ -127,13 +96,13 @@ function runInPage(ctx) {
127
96
  }
128
97
  }
129
98
 
130
-
131
99
  const occurrences = [];
132
100
  let applicableCount = 0;
133
101
 
134
102
  const selector = '[role="tab"]';
135
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
136
-
103
+ const nodes = helpers.queryAllSmart
104
+ ? helpers.queryAllSmart(selector)
105
+ : helpers.queryAll(selector);
137
106
 
138
107
  function evaluate(el) {
139
108
  const ariaLabel = getAttr(el, 'aria-label');
@@ -164,10 +133,16 @@ function runInPage(ctx) {
164
133
  if (res.ok) continue;
165
134
 
166
135
  const eligInfo = getEligibilityInfo
167
- ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
168
- : null;
136
+ ? (() => {
137
+ try {
138
+ return getEligibilityInfo(el, ctx, { targetSet: 'acc' });
139
+ } catch {
140
+ return null;
141
+ }
142
+ })()
143
+ : null;
169
144
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
170
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
145
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
171
146
 
172
147
  occurrences.push({
173
148
  selector: stableSelector,
@@ -186,12 +161,16 @@ function runInPage(ctx) {
186
161
  });
187
162
  }
188
163
 
189
-
190
164
  if (applicableCount === 0) {
191
165
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
192
166
  }
193
167
  if (occurrences.length) {
194
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
168
+ return {
169
+ ruleId: rule.ruleId,
170
+ outcome: 'fail',
171
+ severity: rule.defaultSeverity || 'minor',
172
+ occurrences
173
+ };
195
174
  }
196
175
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
197
176
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -14,8 +16,7 @@
14
16
  * the referencing cell, and (d) is not the cell itself.
15
17
  * @implementation-notes
16
18
  * - One occurrence per offending cell (not per bad token), listing every
17
- * invalid reference — matches a widely-used reference engine's
18
- * td-headers-attr reporting granularity.
19
+ * invalid reference.
19
20
  * - Not rule-gated on isAccTreeEligible: this remains a static-markup
20
21
  * property, while engine-level hidden-subtree filtering still applies
21
22
  * unless engineOptions.includeHiddenElements is true.
@@ -25,7 +26,8 @@ const id = 'table-headers-attr-valid';
25
26
 
26
27
  const meta = {
27
28
  title: 'Table cell "headers" attribute must reference valid header cells',
28
- description: 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
29
+ description:
30
+ 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
29
31
  i18n: {
30
32
  titleKey: 'tableHeadersAttrValid_title',
31
33
  descriptionKey: 'tableHeadersAttrValid_description'
@@ -34,7 +36,13 @@ const meta = {
34
36
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
35
37
  wcagSc: ['1.3.1'],
36
38
  normativeMappings: [
37
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
39
+ {
40
+ standard: 'WCAG',
41
+ version: '2.2',
42
+ requirement: '1.3.1',
43
+ title: 'Info and Relationships',
44
+ conformanceLevel: 'A'
45
+ }
38
46
  ],
39
47
  defaultSeverity: 'serious',
40
48
  category: 'perceivable',
@@ -46,7 +54,9 @@ const meta = {
46
54
  function runInPage(ctx) {
47
55
  const { document, helpers, rule } = ctx;
48
56
 
49
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('td[headers], th[headers]') : helpers.queryAll('td[headers], th[headers]');
57
+ const nodes = helpers.queryAllSmart
58
+ ? helpers.queryAllSmart('td[headers], th[headers]')
59
+ : helpers.queryAll('td[headers], th[headers]');
50
60
 
51
61
  const occurrences = [];
52
62
  let applicableCount = 0;
@@ -65,7 +75,7 @@ function runInPage(ctx) {
65
75
  const invalid = [];
66
76
 
67
77
  for (const headerId of ids) {
68
- let ref = null;
78
+ let ref;
69
79
  try {
70
80
  ref = document.getElementById(headerId);
71
81
  } catch {
@@ -91,9 +101,11 @@ function runInPage(ctx) {
91
101
 
92
102
  if (!invalid.length) continue;
93
103
 
104
+ const dedupedInvalidIds = [...new Set(invalid.map((i) => i.id))];
105
+
94
106
  const tag = el.tagName.toLowerCase();
95
107
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
96
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
108
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
97
109
 
98
110
  occurrences.push({
99
111
  selector: stableSelector,
@@ -103,7 +115,7 @@ function runInPage(ctx) {
103
115
  i18n: {
104
116
  summaryKey: 'tableHeadersAttrValid_summary_fail',
105
117
  hintKey: 'tableHeadersAttrValid_hint_fail',
106
- params: { element: tag, invalidIds: invalid.map((i) => i.id).join(', ') }
118
+ params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
107
119
  },
108
120
  data: {
109
121
  details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
@@ -115,9 +127,14 @@ function runInPage(ctx) {
115
127
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
116
128
  }
117
129
  if (occurrences.length) {
118
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
130
+ return {
131
+ ruleId: rule.ruleId,
132
+ outcome: 'fail',
133
+ severity: rule.defaultSeverity || 'serious',
134
+ occurrences
135
+ };
119
136
  }
120
137
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
121
138
  }
122
139
 
123
- module.exports = { id, meta, runInPage };
140
+ 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
  /**
@@ -37,7 +39,8 @@ const id = 'table-th-has-data-cells';
37
39
 
38
40
  const meta = {
39
41
  title: '<th> elements must describe at least one data cell',
40
- description: 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
42
+ description:
43
+ 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
41
44
  i18n: {
42
45
  titleKey: 'tableThHasDataCells_title',
43
46
  descriptionKey: 'tableThHasDataCells_description'
@@ -46,7 +49,13 @@ const meta = {
46
49
  tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
47
50
  wcagSc: ['1.3.1'],
48
51
  normativeMappings: [
49
- { standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
52
+ {
53
+ standard: 'WCAG',
54
+ version: '2.2',
55
+ requirement: '1.3.1',
56
+ title: 'Info and Relationships',
57
+ conformanceLevel: 'A'
58
+ }
50
59
  ],
51
60
  defaultSeverity: 'moderate',
52
61
  category: 'perceivable',
@@ -56,7 +65,7 @@ const meta = {
56
65
  };
57
66
 
58
67
  function runInPage(ctx) {
59
- const { document, helpers, rule } = ctx;
68
+ const { helpers, rule } = ctx;
60
69
 
61
70
  const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
62
71
 
@@ -66,7 +75,7 @@ function runInPage(ctx) {
66
75
  for (const table of tables) {
67
76
  if (!table || !table.querySelectorAll) continue;
68
77
 
69
- let ths = [];
78
+ let ths;
70
79
  try {
71
80
  ths = table.querySelectorAll('th');
72
81
  } catch {
@@ -76,7 +85,7 @@ function runInPage(ctx) {
76
85
 
77
86
  applicableCount += 1;
78
87
 
79
- let hasDataCell = false;
88
+ let hasDataCell;
80
89
  try {
81
90
  hasDataCell = table.querySelectorAll('td').length > 0;
82
91
  } catch {
@@ -87,7 +96,9 @@ function runInPage(ctx) {
87
96
  for (const th of ths) {
88
97
  if (!th) continue;
89
98
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(th) : (th.outerHTML || '');
99
+ const html = helpers.getOuterHtmlSnippet
100
+ ? helpers.getOuterHtmlSnippet(th)
101
+ : th.outerHTML || '';
91
102
 
92
103
  occurrences.push({
93
104
  selector: stableSelector,
@@ -110,9 +121,14 @@ function runInPage(ctx) {
110
121
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
111
122
  }
112
123
  if (occurrences.length) {
113
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
124
+ return {
125
+ ruleId: rule.ruleId,
126
+ outcome: 'fail',
127
+ severity: rule.defaultSeverity || 'moderate',
128
+ occurrences
129
+ };
114
130
  }
115
131
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
116
132
  }
117
133
 
118
- module.exports = { id, meta, runInPage };
134
+ 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
  /**
@@ -60,13 +62,23 @@ const id = 'target-size-minimum';
60
62
  const meta = {
61
63
  title: 'Pointer targets meet minimum size (AA)',
62
64
  description:
63
- 'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
65
+ 'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
64
66
  i18n: {
65
67
  titleKey: 'targetSizeMinimum_title',
66
68
  descriptionKey: 'targetSizeMinimum_description'
67
69
  },
68
70
  helpUrl: null,
69
- tags: ['wcag22aa', 'wcag258', 'navigation', 'operable', 'pointer', 'target-size', 'atomic', 'automatic', 'dom'],
71
+ tags: [
72
+ 'wcag22aa',
73
+ 'wcag258',
74
+ 'navigation',
75
+ 'operable',
76
+ 'pointer',
77
+ 'target-size',
78
+ 'atomic',
79
+ 'automatic',
80
+ 'dom'
81
+ ],
70
82
  wcagSc: ['2.5.8'],
71
83
  normativeMappings: [
72
84
  {
@@ -115,8 +127,10 @@ function runInPage(ctx) {
115
127
  // with its own .querySelectorAll to call directly.
116
128
  function qsa(sel) {
117
129
  try {
118
- if (helpers && typeof helpers.queryAllSmart === 'function') return Array.from(helpers.queryAllSmart(sel) || []);
119
- if (helpers && typeof helpers.queryAll === 'function') return Array.from(helpers.queryAll(sel) || []);
130
+ if (helpers && typeof helpers.queryAllSmart === 'function')
131
+ return Array.from(helpers.queryAllSmart(sel) || []);
132
+ if (helpers && typeof helpers.queryAll === 'function')
133
+ return Array.from(helpers.queryAll(sel) || []);
120
134
  return [];
121
135
  } catch {
122
136
  return [];
@@ -135,10 +149,11 @@ function runInPage(ctx) {
135
149
 
136
150
  function htmlSnippet(el) {
137
151
  try {
138
- if (helpers && typeof helpers.getOuterHtmlSnippet === 'function') return helpers.getOuterHtmlSnippet(el);
152
+ if (helpers && typeof helpers.getOuterHtmlSnippet === 'function')
153
+ return helpers.getOuterHtmlSnippet(el);
139
154
  } catch {}
140
155
  try {
141
- return (el && el.outerHTML) ? String(el.outerHTML) : '';
156
+ return el && el.outerHTML ? String(el.outerHTML) : '';
142
157
  } catch {}
143
158
  return '';
144
159
  }
@@ -153,8 +168,14 @@ function runInPage(ctx) {
153
168
  if (!el || el.nodeType !== 1) return false;
154
169
 
155
170
  const tag = (el.tagName || '').toLowerCase();
156
- const role = (el.getAttribute && String(el.getAttribute('role') || '').trim().toLowerCase()) || '';
157
- const isLinkLike = (tag === 'a' && el.getAttribute && el.getAttribute('href')) || role === 'link';
171
+ const role =
172
+ (el.getAttribute &&
173
+ String(el.getAttribute('role') || '')
174
+ .trim()
175
+ .toLowerCase()) ||
176
+ '';
177
+ const isLinkLike =
178
+ (tag === 'a' && el.getAttribute && el.getAttribute('href')) || role === 'link';
158
179
  if (!isLinkLike) return false;
159
180
 
160
181
  const cs = getStyle(el);
@@ -164,11 +185,11 @@ function runInPage(ctx) {
164
185
  // Inline exception is for inline text runs; many design systems use inline-block for links,
165
186
  // so treat inline-block variants as eligible for this exception.
166
187
  const isInline =
167
- display === 'inline' ||
168
- display === 'inline-block' ||
169
- display === 'inline-flex' ||
170
- display === 'inline-grid' ||
171
- display === 'inline-table';
188
+ display === 'inline' ||
189
+ display === 'inline-block' ||
190
+ display === 'inline-flex' ||
191
+ display === 'inline-grid' ||
192
+ display === 'inline-table';
172
193
 
173
194
  if (!isInline) return false;
174
195
 
@@ -182,7 +203,10 @@ function runInPage(ctx) {
182
203
  // Fallback: sometimes links are inside spans within a paragraph-like region
183
204
  const inlineContainer = closest(el, 'span, em, strong, small, label');
184
205
  if (inlineContainer) {
185
- const outerTextBlock = closest(inlineContainer, 'p, li, dd, dt, blockquote, figcaption, caption, td, th');
206
+ const outerTextBlock = closest(
207
+ inlineContainer,
208
+ 'p, li, dd, dt, blockquote, figcaption, caption, td, th'
209
+ );
186
210
  if (outerTextBlock) return true;
187
211
  }
188
212
 
@@ -234,7 +258,7 @@ function runInPage(ctx) {
234
258
  try {
235
259
  if (det.hasAttribute('open')) return false;
236
260
 
237
- const tag = (el && el.tagName) ? String(el.tagName).toLowerCase() : '';
261
+ const tag = el && el.tagName ? String(el.tagName).toLowerCase() : '';
238
262
  // summary remains operable even when <details> is closed
239
263
  if (tag === 'summary') return false;
240
264
 
@@ -256,9 +280,9 @@ function runInPage(ctx) {
256
280
 
257
281
  function getStyle(el) {
258
282
  try {
259
- return (document && document.defaultView && document.defaultView.getComputedStyle)
260
- ? document.defaultView.getComputedStyle(el)
261
- : null;
283
+ return document && document.defaultView && document.defaultView.getComputedStyle
284
+ ? document.defaultView.getComputedStyle(el)
285
+ : null;
262
286
  } catch {
263
287
  return null;
264
288
  }
@@ -288,7 +312,11 @@ function runInPage(ctx) {
288
312
 
289
313
  // aria-disabled elements are typically treated as not operable
290
314
  try {
291
- const ad = el.getAttribute && String(el.getAttribute('aria-disabled') || '').trim().toLowerCase();
315
+ const ad =
316
+ el.getAttribute &&
317
+ String(el.getAttribute('aria-disabled') || '')
318
+ .trim()
319
+ .toLowerCase();
292
320
  if (ad === 'true') return false;
293
321
  } catch {}
294
322
 
@@ -321,7 +349,8 @@ function runInPage(ctx) {
321
349
 
322
350
  function elementFromPoint(x, y) {
323
351
  try {
324
- if (document && typeof document.elementFromPoint === 'function') return document.elementFromPoint(x, y);
352
+ if (document && typeof document.elementFromPoint === 'function')
353
+ return document.elementFromPoint(x, y);
325
354
  } catch {}
326
355
  return null;
327
356
  }
@@ -347,7 +376,8 @@ function runInPage(ctx) {
347
376
  }
348
377
  }
349
378
 
350
- const CANDIDATE_SELECTOR = 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
379
+ const CANDIDATE_SELECTOR =
380
+ 'button, summary, a[href], area[href], input, select, textarea, [role="button"], [role="link"]';
351
381
 
352
382
  // --- candidate collection ---
353
383
  const candidates = qsa(CANDIDATE_SELECTOR);
@@ -481,14 +511,20 @@ function runInPage(ctx) {
481
511
  const tag = (el.tagName || '').toLowerCase();
482
512
  if (tag !== 'input') return false;
483
513
 
484
- const type = (el.getAttribute && String(el.getAttribute('type') || '').trim().toLowerCase()) || '';
514
+ const type =
515
+ (el.getAttribute &&
516
+ String(el.getAttribute('type') || '')
517
+ .trim()
518
+ .toLowerCase()) ||
519
+ '';
485
520
  if (type !== 'checkbox' && type !== 'radio') return false;
486
521
 
487
522
  const cs = getStyle(el);
488
523
  let appearance = '';
489
524
  try {
490
525
  if (cs && typeof cs.getPropertyValue === 'function') {
491
- appearance = cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
526
+ appearance =
527
+ cs.getPropertyValue('appearance') || cs.getPropertyValue('-webkit-appearance') || '';
492
528
  } else if (cs) {
493
529
  appearance = cs.appearance || cs.webkitAppearance || '';
494
530
  }
@@ -528,8 +564,8 @@ function runInPage(ctx) {
528
564
  }
529
565
  }
530
566
 
531
- const occurrences = [];
532
- let hasUncertainConflicts = false;
567
+ const failOccurrences = [];
568
+ const cantTellOccurrences = [];
533
569
 
534
570
  for (const it of undersized) {
535
571
  // Inline-text exception: do not fail purely on size/spacing for inline links in text.
@@ -548,20 +584,69 @@ function runInPage(ctx) {
548
584
  const info = hasSpacingConflict(it);
549
585
 
550
586
  if (!info.conflict && info.confident === false) {
551
- // Ambiguous perimeter-sampling result near the decision threshold.
552
- hasUncertainConflicts = true;
587
+ // Ambiguous perimeter-sampling result near the decision threshold —
588
+ // previously recorded only as a page-level boolean with no per-target
589
+ // occurrence at all, so this specific target was unrecoverable from
590
+ // the result once any other target on the page had a confident
591
+ // fail (see helpers.resolveTieredOutcome's header comment). Now
592
+ // reported as its own cantTell-tier occurrence instead.
593
+ cantTellOccurrences.push({
594
+ selector: buildSelector(it.el),
595
+ html: htmlSnippet(it.el),
596
+ occurrenceOutcome: 'cantTell',
597
+ summary:
598
+ 'Target may be too small and too close to another target, but the overlap is near the detection threshold and could not be confidently measured.',
599
+ hint: 'Manually verify the effective spacing between this target and its neighbor; increase target size or spacing if the overlap is real.',
600
+ i18n: {
601
+ summaryKey: 'targetSizeMinimum_summary_cantTell_ambiguousSpacing',
602
+ hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
603
+ params: {}
604
+ },
605
+ data: {
606
+ details: {
607
+ measured: { width: it.rect.width, height: it.rect.height },
608
+ reasonCode: 'undersized-ambiguous-spacing',
609
+ conflictHitCount: info.hitCount,
610
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
611
+ }
612
+ }
613
+ });
553
614
  continue;
554
615
  }
555
616
 
556
617
  if (info.conflict) {
557
618
  if (isPlausiblyEssentialOrEquivalent(it.el)) {
558
- hasUncertainConflicts = true;
619
+ // Confident spacing conflict, but the target may be exempt as part
620
+ // of an essential graphic/image-map region — same "previously
621
+ // unrecoverable" gap as above, now reported instead of dropped.
622
+ cantTellOccurrences.push({
623
+ selector: buildSelector(it.el),
624
+ html: htmlSnippet(it.el),
625
+ occurrenceOutcome: 'cantTell',
626
+ summary:
627
+ 'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
628
+ hint: 'Verify whether this target’s size is genuinely essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
629
+ i18n: {
630
+ summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
631
+ hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
632
+ params: {}
633
+ },
634
+ data: {
635
+ details: {
636
+ measured: { width: it.rect.width, height: it.rect.height },
637
+ reasonCode: 'undersized-plausibly-essential',
638
+ conflictHitCount: info.hitCount,
639
+ conflictWith: info.conflictEl ? buildSelector(info.conflictEl) : null
640
+ }
641
+ }
642
+ });
559
643
  continue;
560
644
  }
561
645
 
562
- occurrences.push({
646
+ failOccurrences.push({
563
647
  selector: buildSelector(it.el),
564
648
  html: htmlSnippet(it.el),
649
+ occurrenceOutcome: 'fail',
565
650
  summary: 'Target is too small and too close to another target.',
566
651
  hint: 'Increase target size to at least 24 by 24 CSS pixels, or add sufficient spacing.',
567
652
  i18n: {
@@ -581,25 +666,15 @@ function runInPage(ctx) {
581
666
  }
582
667
  }
583
668
 
584
- if (occurrences.length > 0) {
585
- return {
586
- ruleId: RULE_ID,
587
- outcome: 'fail',
588
- severity: (rule && rule.defaultSeverity) || 'minor',
589
- occurrences
590
- };
591
- }
592
-
593
- if (hasUncertainConflicts) {
594
- return {
595
- ruleId: RULE_ID,
596
- outcome: 'cantTell',
597
- severity: 'minor',
598
- occurrences: []
599
- };
600
- }
601
-
602
- return { ruleId: RULE_ID, outcome: 'pass', severity: 'minor', occurrences: [] };
669
+ // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
670
+ // a fail-tier finding never silently discards cantTell-tier findings from
671
+ // the same run — both are returned together when the outcome is 'fail'.
672
+ const resolved = helpers.resolveTieredOutcome(
673
+ failOccurrences,
674
+ cantTellOccurrences,
675
+ (rule && rule.defaultSeverity) || 'minor'
676
+ );
677
+ return { ruleId: RULE_ID, ...resolved };
603
678
  }
604
679
 
605
680
  module.exports = { id, meta, runInPage };