@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,20 +1,21 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check skip-link
5
7
  * @atomic true
6
8
  * @summary A "skip" link must resolve to a real, usable target
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to <a href="#fragment"> elements whose accessible name
10
12
  * matches a common "skip to ..." / "jump to ..." authoring convention
11
13
  * (case-insensitive "skip" or "jump to" in the name) — the recognizable
12
14
  * pattern for a skip-navigation link, not every same-page anchor link
13
- * on the page. "jump to" added 2026-07-23 after a real page (Wish.com)
14
- * surfaced a skip link reading "Jump to section" with a genuinely
15
- * missing target — invisible to the original "skip"-only pattern,
16
- * while a widely-used reference engine's own (purely positional, not text-based) matching
17
- * caught it. Text-pattern matching itself stays deliberate (see
15
+ * on the page. "jump to" is included alongside "skip" since real skip
16
+ * links use both conventions (e.g. a "Jump to section" link, which a
17
+ * purely positional match would catch but a "skip"-only text pattern
18
+ * would miss). Text-pattern matching itself stays deliberate (see
18
19
  * implementation-notes) — this only widens the known-convention list.
19
20
  * @expectation
20
21
  * The link's fragment resolves to a real element in the document
@@ -37,7 +38,8 @@ const id = 'skip-link';
37
38
 
38
39
  const meta = {
39
40
  title: 'Skip link must have a resolvable, usable target',
40
- description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
41
+ description:
42
+ 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
41
43
  i18n: {
42
44
  titleKey: 'skipLink_title',
43
45
  descriptionKey: 'skipLink_description'
@@ -57,7 +59,9 @@ function runInPage(ctx) {
57
59
  const { document, helpers, rule } = ctx;
58
60
 
59
61
  function normalizeWs(s) {
60
- return String(s || '').replace(/\s+/g, ' ').trim();
62
+ return String(s || '')
63
+ .replace(/\s+/g, ' ')
64
+ .trim();
61
65
  }
62
66
 
63
67
  function getAccessibleNameText(el) {
@@ -105,7 +109,9 @@ function runInPage(ctx) {
105
109
 
106
110
  const geometrySupported = hasReliableGeometrySupport();
107
111
 
108
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
112
+ const nodes = helpers.queryAllSmart
113
+ ? helpers.queryAllSmart('a[href]')
114
+ : helpers.queryAll('a[href]');
109
115
 
110
116
  const occurrences = [];
111
117
  let applicableCount = 0;
@@ -147,18 +153,29 @@ function runInPage(ctx) {
147
153
  const accEligibility = toEligibility(
148
154
  helpers.getEligibilityInfo
149
155
  ? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
150
- : (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
156
+ : helpers.isAccTreeEligible
157
+ ? helpers.isAccTreeEligible(target, ctx)
158
+ : { eligible: true, reasons: [] }
151
159
  );
152
160
 
153
161
  let geometryEligibility = null;
154
162
  let geometryReasonCode = null;
155
163
  if (geometrySupported && helpers.isDomVisibleEligible) {
156
164
  geometryEligibility = toEligibility(
157
- helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
165
+ helpers.isDomVisibleEligible(target, ctx, {
166
+ visibilityMode: 'styleAndGeometry',
167
+ ignoreOpacity: true
168
+ })
158
169
  );
159
- if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
170
+ if (
171
+ !geometryEligibility.eligible &&
172
+ geometryEligibility.reasons.includes('noClientRects')
173
+ ) {
160
174
  geometryReasonCode = 'NO_CLIENT_RECTS';
161
- } else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
175
+ } else if (
176
+ !geometryEligibility.eligible &&
177
+ geometryEligibility.reasons.includes('zeroArea')
178
+ ) {
162
179
  geometryReasonCode = 'ZERO_AREA_TARGET';
163
180
  }
164
181
  }
@@ -168,7 +185,9 @@ function runInPage(ctx) {
168
185
  if (!unusableByAcc && !unusableByGeometry) continue;
169
186
 
170
187
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
188
+ const html = helpers.getOuterHtmlSnippet
189
+ ? helpers.getOuterHtmlSnippet(el)
190
+ : el.outerHTML || '';
172
191
 
173
192
  occurrences.push({
174
193
  selector: stableSelector,
@@ -202,13 +221,13 @@ function runInPage(ctx) {
202
221
  }
203
222
 
204
223
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
205
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
224
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
206
225
 
207
226
  occurrences.push({
208
227
  selector: stableSelector,
209
228
  html,
210
- summary: 'This skip link\'s target does not exist.',
211
- hint: 'Point the skip link\'s href at an id that exists in the document, or add the missing target element.',
229
+ summary: "This skip link's target does not exist.",
230
+ hint: "Point the skip link's href at an id that exists in the document, or add the missing target element.",
212
231
  i18n: {
213
232
  summaryKey: 'skipLink_summary_cantTell',
214
233
  hintKey: 'skipLink_hint_cantTell',
@@ -224,7 +243,12 @@ function runInPage(ctx) {
224
243
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
225
244
  }
226
245
  if (occurrences.length) {
227
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
246
+ return {
247
+ ruleId: rule.ruleId,
248
+ outcome: 'cantTell',
249
+ severity: rule.defaultSeverity || 'minor',
250
+ occurrences
251
+ };
228
252
  }
229
253
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
230
254
  }
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -11,199 +13,240 @@
11
13
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
12
14
  */
13
15
 
14
- const id = "svg-text-alternative-quality";
16
+ const id = 'svg-text-alternative-quality';
15
17
 
16
18
  const meta = {
17
- title: "<svg> text alternative must be appropriate (manual review)",
18
- description: "Flags applicable <svg> graphics with a detected text alternative for human review of appropriateness.",
19
- i18n: {
20
- titleKey: "svg_textAltQuality_title",
21
- descriptionKey: "svg_textAltQuality_description"
22
- },
23
- helpUrl: null,
24
- tags: ["wcag2a", "wcag111", "nontext", "svg", "manual", "atomic"],
25
- wcagSc: ['1.1.1'],
26
- normativeMappings: [
27
- {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
28
- ],
29
- defaultSeverity: 'minor',
30
- category: 'perceivable',
31
- type: 'manual',
32
- defaultConfidence: 'medium',
33
- coverage: {
34
- facetsBySc: {
35
- '1.1.1': ['text-alternative-quality']
36
- }
19
+ title: '<svg> text alternative must be appropriate (manual review)',
20
+ description:
21
+ 'Flags applicable <svg> graphics with a detected text alternative for human review of appropriateness.',
22
+ i18n: {
23
+ titleKey: 'svg_textAltQuality_title',
24
+ descriptionKey: 'svg_textAltQuality_description'
25
+ },
26
+ helpUrl: null,
27
+ tags: ['wcag2a', 'wcag111', 'nontext', 'svg', 'manual', 'atomic'],
28
+ wcagSc: ['1.1.1'],
29
+ normativeMappings: [
30
+ {
31
+ standard: 'WCAG',
32
+ version: '2.2',
33
+ requirement: '1.1.1',
34
+ title: 'Non-text Content',
35
+ conformanceLevel: 'A'
37
36
  }
37
+ ],
38
+ defaultSeverity: 'minor',
39
+ category: 'perceivable',
40
+ type: 'manual',
41
+ defaultConfidence: 'medium',
42
+ coverage: {
43
+ facetsBySc: {
44
+ '1.1.1': ['text-alternative-quality']
45
+ }
46
+ }
38
47
  };
39
48
 
40
49
  function runInPage(ctx) {
41
- const {document, root, helpers, rule} = ctx;
42
- const safeRoot = root || document;
43
-
44
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
45
- const queryAll = helpers && typeof helpers.queryAll === 'function'
46
- ? helpers.queryAll
47
- : (sel) => {
48
- try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
49
- catch { return []; }
50
+ const { document, root, helpers, rule } = ctx;
51
+ const safeRoot = root || document;
52
+
53
+ const queryAllSmart =
54
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
55
+ const queryAll =
56
+ helpers && typeof helpers.queryAll === 'function'
57
+ ? helpers.queryAll
58
+ : (sel) => {
59
+ try {
60
+ return safeRoot && safeRoot.querySelectorAll
61
+ ? Array.from(safeRoot.querySelectorAll(sel))
62
+ : [];
63
+ } catch {
64
+ return [];
65
+ }
50
66
  };
51
67
 
52
- const buildSelector = helpers && typeof helpers.buildSelector === 'function'
53
- ? helpers.buildSelector
54
- : (el) => {
55
- try {
56
- if (!el || !el.tagName) return 'html';
57
- const tag = (el.tagName || 'html').toLowerCase();
58
- return el.id ? `${tag}#${el.id}` : tag;
59
- } catch {
60
- return 'html';
61
- }
68
+ const buildSelector =
69
+ helpers && typeof helpers.buildSelector === 'function'
70
+ ? helpers.buildSelector
71
+ : (el) => {
72
+ try {
73
+ if (!el || !el.tagName) return 'html';
74
+ const tag = (el.tagName || 'html').toLowerCase();
75
+ return el.id ? `${tag}#${el.id}` : tag;
76
+ } catch {
77
+ return 'html';
78
+ }
62
79
  };
63
80
 
64
- const getOuterHtmlSnippet = helpers && typeof helpers.getOuterHtmlSnippet === 'function'
65
- ? helpers.getOuterHtmlSnippet
66
- : (el) => { try { return (el && el.outerHTML) ? String(el.outerHTML).slice(0, 2000) : ''; } catch { return ''; } };
67
-
68
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
69
- ? helpers.getEligibilityInfo
70
- : null;
71
-
72
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
73
- ? helpers.isAccTreeEligible
74
- : null;
75
-
76
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
77
- ? helpers.getFocusableInfo
78
- : null;
79
-
80
- function isRolePresentationExcluded(el) {
81
- const role = (() => {
82
- try {
83
- return String(el.getAttribute('role') || '').trim().toLowerCase();
84
- } catch {
85
- return '';
86
- }
87
- })();
88
- if (role !== 'presentation' && role !== 'none') return false;
89
-
90
- // Exclude only when NOT focusable (mirrors img-alt-present policy)
91
- let focusable = false;
92
- if (getFocusableInfo) {
93
- const fi = (() => {
94
- try {
95
- return getFocusableInfo(el, ctx);
96
- } catch {
97
- return null;
98
- }
99
- })();
100
- focusable = !!(fi && fi.focusable);
101
- } else {
102
- const tabindex = el.getAttribute('tabindex');
103
- focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
104
- }
105
- return !focusable;
106
- }
81
+ const getOuterHtmlSnippet =
82
+ helpers && typeof helpers.getOuterHtmlSnippet === 'function'
83
+ ? helpers.getOuterHtmlSnippet
84
+ : (el) => {
85
+ try {
86
+ return el && el.outerHTML ? String(el.outerHTML).slice(0, 2000) : '';
87
+ } catch {
88
+ return '';
89
+ }
90
+ };
91
+
92
+ const getEligibilityInfo =
93
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
107
94
 
95
+ const isAccTreeEligible =
96
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
108
97
 
109
- const els = (() => {
98
+ const getFocusableInfo =
99
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
100
+
101
+ function isRolePresentationExcluded(el) {
102
+ const role = (() => {
103
+ try {
104
+ return String(el.getAttribute('role') || '')
105
+ .trim()
106
+ .toLowerCase();
107
+ } catch {
108
+ return '';
109
+ }
110
+ })();
111
+ if (role !== 'presentation' && role !== 'none') return false;
112
+
113
+ // Exclude only when NOT focusable (mirrors img-alt-present policy)
114
+ let focusable;
115
+ if (getFocusableInfo) {
116
+ const fi = (() => {
110
117
  try {
111
- return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
118
+ return getFocusableInfo(el, ctx);
112
119
  } catch {
113
- return queryAll('svg');
120
+ return null;
114
121
  }
115
- })();
116
-
117
- if (!els.length) {
118
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
122
+ })();
123
+ focusable = !!(fi && fi.focusable);
124
+ } else {
125
+ const tabindex = el.getAttribute('tabindex');
126
+ focusable =
127
+ tabindex != null &&
128
+ String(tabindex).trim() !== '' &&
129
+ !Number.isNaN(Number(String(tabindex).trim()));
119
130
  }
131
+ return !focusable;
132
+ }
133
+
134
+ const els = (() => {
135
+ try {
136
+ return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
137
+ } catch {
138
+ return queryAll('svg');
139
+ }
140
+ })();
120
141
 
121
- const occurrences = [];
122
- let applicableCount = 0;
123
-
124
- const trim = (v) => (v == null ? '' : String(v)).trim();
125
-
126
- for (const el of els) {
127
- if (!el || !el.getAttribute) continue;
142
+ if (!els.length) {
143
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
144
+ }
128
145
 
129
- // acc eligibility
130
- if (isAccTreeEligible) {
131
- const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
132
- if (elig && elig.eligible === false) continue;
133
- }
146
+ const occurrences = [];
147
+ let applicableCount = 0;
134
148
 
135
- if (isRolePresentationExcluded(el)) continue;
149
+ const trim = (v) => (v == null ? '' : String(v)).trim();
136
150
 
137
- // Detect mechanisms once
138
- let titleText = '';
139
- let descText = '';
140
- let ariaLabel = '';
141
- let ariaLabelledBy = '';
142
- let labelledByText = '';
151
+ for (const el of els) {
152
+ if (!el || !el.getAttribute) continue;
143
153
 
154
+ // acc eligibility
155
+ if (isAccTreeEligible) {
156
+ const elig = (() => {
144
157
  try {
145
- const titleEl = el.querySelector ? el.querySelector('title') : null;
146
- const descEl = el.querySelector ? el.querySelector('desc') : null;
147
- titleText = trim(titleEl && titleEl.textContent);
148
- descText = trim(descEl && descEl.textContent);
149
-
150
- ariaLabel = trim(el.getAttribute('aria-label'));
151
- ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
152
- } catch {}
153
-
154
- if (!ariaLabel && ariaLabelledBy && helpers && typeof helpers.getTextFromIdRefs === 'function') {
155
- try {
156
- const t = helpers.getTextFromIdRefs(ariaLabelledBy, ctx);
157
- labelledByText = trim(t && t.text);
158
- } catch {}
158
+ return isAccTreeEligible(el, ctx);
159
+ } catch {
160
+ return { eligible: true, reasons: [] };
159
161
  }
162
+ })();
163
+ if (elig && elig.eligible === false) continue;
164
+ }
160
165
 
161
- const hasNonEmptyTitle = !!titleText;
162
- const hasNonEmptyDesc = !!descText;
163
- const hasAriaLabel = !!ariaLabel;
164
- const hasResolvedLabelledBy = !!labelledByText;
165
-
166
- const hasMechanism = hasNonEmptyTitle || hasNonEmptyDesc || hasAriaLabel || hasResolvedLabelledBy;
167
- if (!hasMechanism) continue;
168
-
169
- applicableCount += 1;
170
-
171
- const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
172
-
173
- const baseOccurrence = {
174
- summary: 'Review text alternative for <svg> for accuracy and appropriateness.',
175
- hint: 'Confirm the <title>/<desc> or ARIA name conveys the meaning/purpose of the graphic in context.',
176
- i18n: {
177
- summaryKey: 'svg_textAltQuality_summary_cantTell',
178
- hintKey: 'svg_textAltQuality_hint_cantTell',
179
- params: { element: 'svg' }
180
- },
181
- data: {
182
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
183
- details: {
184
- hasNonEmptyTitle,
185
- hasNonEmptyDesc,
186
- ariaLabel: ariaLabel || null,
187
- ariaLabelledBy: ariaLabelledBy || null,
188
- ariaLabelledByText: labelledByText ? labelledByText.slice(0, 120) : null // deterministic truncation
189
- }
190
- }
191
- };
166
+ if (isRolePresentationExcluded(el)) continue;
167
+
168
+ // Detect mechanisms once
169
+ let titleText = '';
170
+ let descText = '';
171
+ let ariaLabel = '';
172
+ let ariaLabelledBy = '';
173
+ let labelledByText = '';
174
+
175
+ try {
176
+ const titleEl = el.querySelector ? el.querySelector('title') : null;
177
+ const descEl = el.querySelector ? el.querySelector('desc') : null;
178
+ titleText = trim(titleEl && titleEl.textContent);
179
+ descText = trim(descEl && descEl.textContent);
180
+
181
+ ariaLabel = trim(el.getAttribute('aria-label'));
182
+ ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
183
+ } catch {}
184
+
185
+ if (
186
+ !ariaLabel &&
187
+ ariaLabelledBy &&
188
+ helpers &&
189
+ typeof helpers.getTextFromIdRefs === 'function'
190
+ ) {
191
+ try {
192
+ const t = helpers.getTextFromIdRefs(ariaLabelledBy, ctx);
193
+ labelledByText = trim(t && t.text);
194
+ } catch {}
195
+ }
192
196
 
193
- if (helpers && typeof helpers.reportOccurrence === 'function') {
194
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
195
- } else {
196
- const selectorStr = (() => { try { return buildSelector(el); } catch { return 'html'; } })();
197
- const html = getOuterHtmlSnippet(el);
198
- occurrences.push({ selector: selectorStr, html, ...baseOccurrence });
197
+ const hasNonEmptyTitle = !!titleText;
198
+ const hasNonEmptyDesc = !!descText;
199
+ const hasAriaLabel = !!ariaLabel;
200
+ const hasResolvedLabelledBy = !!labelledByText;
201
+
202
+ const hasMechanism =
203
+ hasNonEmptyTitle || hasNonEmptyDesc || hasAriaLabel || hasResolvedLabelledBy;
204
+ if (!hasMechanism) continue;
205
+
206
+ applicableCount += 1;
207
+
208
+ const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
209
+
210
+ const baseOccurrence = {
211
+ summary: 'Review text alternative for <svg> for accuracy and appropriateness.',
212
+ hint: 'Confirm the <title>/<desc> or ARIA name conveys the meaning/purpose of the graphic in context.',
213
+ i18n: {
214
+ summaryKey: 'svg_textAltQuality_summary_cantTell',
215
+ hintKey: 'svg_textAltQuality_hint_cantTell',
216
+ params: { element: 'svg' }
217
+ },
218
+ data: {
219
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
220
+ details: {
221
+ hasNonEmptyTitle,
222
+ hasNonEmptyDesc,
223
+ ariaLabel: ariaLabel || null,
224
+ ariaLabelledBy: ariaLabelledBy || null,
225
+ ariaLabelledByText: labelledByText ? labelledByText.slice(0, 120) : null // deterministic truncation
199
226
  }
200
- }
227
+ }
228
+ };
201
229
 
202
- if (applicableCount === 0) {
203
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
230
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
231
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
232
+ } else {
233
+ const selectorStr = (() => {
234
+ try {
235
+ return buildSelector(el);
236
+ } catch {
237
+ return 'html';
238
+ }
239
+ })();
240
+ const html = getOuterHtmlSnippet(el);
241
+ occurrences.push({ selector: selectorStr, html, ...baseOccurrence });
204
242
  }
243
+ }
244
+
245
+ if (applicableCount === 0) {
246
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
247
+ }
205
248
 
206
- return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
249
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
207
250
  }
208
251
 
209
- module.exports = {id, meta, runInPage};
252
+ module.exports = { id, meta, runInPage };
@@ -1,10 +1,12 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
4
6
  * @check tabindex
5
7
  * @atomic true
6
8
  * @summary tabindex should not be greater than 0
7
- * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
10
  * @applicability
9
11
  * Applies to elements with a tabindex attribute whose value parses as
10
12
  * a valid integer.
@@ -42,7 +44,9 @@ const meta = {
42
44
  function runInPage(ctx) {
43
45
  const { helpers, rule } = ctx;
44
46
 
45
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex]') : helpers.queryAll('[tabindex]');
47
+ const nodes = helpers.queryAllSmart
48
+ ? helpers.queryAllSmart('[tabindex]')
49
+ : helpers.queryAll('[tabindex]');
46
50
 
47
51
  const occurrences = [];
48
52
  let applicableCount = 0;
@@ -59,7 +63,7 @@ function runInPage(ctx) {
59
63
  if (n <= 0) continue;
60
64
 
61
65
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
62
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
66
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
63
67
 
64
68
  occurrences.push({
65
69
  selector: stableSelector,
@@ -81,7 +85,12 @@ function runInPage(ctx) {
81
85
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
82
86
  }
83
87
  if (occurrences.length) {
84
- return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
88
+ return {
89
+ ruleId: rule.ruleId,
90
+ outcome: 'cantTell',
91
+ severity: rule.defaultSeverity || 'minor',
92
+ occurrences
93
+ };
85
94
  }
86
95
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
87
96
  }