@surea11y/core 1.1.2 → 1.3.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 (160) hide show
  1. package/CHANGELOG.md +48 -4
  2. package/LICENSE +373 -21
  3. package/README.md +70 -1
  4. package/bin/core.js +240 -11
  5. package/docs/API_STABILITY.md +61 -0
  6. package/docs/BASELINE.md +66 -0
  7. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  8. package/docs/CI_INTEGRATIONS.md +103 -0
  9. package/docs/CLI.md +80 -1
  10. package/docs/ENGINE_OPTIONS.md +4 -0
  11. package/docs/INTEGRATION.md +19 -1
  12. package/docs/OUTPUT_SCHEMA.md +2 -2
  13. package/docs/REPORT.md +33 -0
  14. package/docs/RULE_AUTHORING.md +31 -0
  15. package/docs/RULE_CATALOG.md +1 -1
  16. package/docs/SARIF.md +59 -0
  17. package/package.json +15 -4
  18. package/src/baseline.js +0 -0
  19. package/src/catalogs/composites.wcag.js +414 -450
  20. package/src/checks/automatic/area-alt-present.js +59 -25
  21. package/src/checks/automatic/aria-allowed-attr.js +193 -33
  22. package/src/checks/automatic/aria-allowed-role.js +21 -7
  23. package/src/checks/automatic/aria-braille-equivalent.js +32 -10
  24. package/src/checks/automatic/aria-conditional-attr.js +24 -7
  25. package/src/checks/automatic/aria-deprecated-role.js +22 -8
  26. package/src/checks/automatic/aria-hidden-body.js +50 -19
  27. package/src/checks/automatic/aria-hidden-focus.js +408 -53
  28. package/src/checks/automatic/aria-prohibited-attr.js +296 -22
  29. package/src/checks/automatic/aria-prohibited-children.js +125 -29
  30. package/src/checks/automatic/aria-required-attr.js +23 -8
  31. package/src/checks/automatic/aria-required-children.js +37 -14
  32. package/src/checks/automatic/aria-required-parent.js +48 -14
  33. package/src/checks/automatic/aria-role-name-present.js +47 -21
  34. package/src/checks/automatic/aria-roles-valid.js +22 -12
  35. package/src/checks/automatic/aria-valid-attr-value.js +28 -7
  36. package/src/checks/automatic/aria-valid-attr.js +17 -5
  37. package/src/checks/automatic/autocomplete-valid.js +74 -16
  38. package/src/checks/automatic/avoid-inline-spacing.js +20 -6
  39. package/src/checks/automatic/binary-control-name-present.js +60 -50
  40. package/src/checks/automatic/button-name-present.js +48 -18
  41. package/src/checks/automatic/bypass-blocks-present.js +50 -25
  42. package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
  43. package/src/checks/automatic/combobox-name-present.js +38 -45
  44. package/src/checks/automatic/contrast-computable.js +361 -341
  45. package/src/checks/automatic/contrast-enhanced.js +487 -466
  46. package/src/checks/automatic/contrast-minimum.js +486 -465
  47. package/src/checks/automatic/css-orientation-lock.js +39 -9
  48. package/src/checks/automatic/definition-list-children-valid.js +40 -19
  49. package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
  50. package/src/checks/automatic/dialog-name-present.js +37 -75
  51. package/src/checks/automatic/dlitem-parent-valid.js +23 -8
  52. package/src/checks/automatic/duplicate-id-aria.js +24 -6
  53. package/src/checks/automatic/embed-text-alternative-present.js +86 -35
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
  55. package/src/checks/automatic/form-control-single-label.js +47 -10
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
  57. package/src/checks/automatic/iframe-focusable-content.js +26 -11
  58. package/src/checks/automatic/iframe-name-present.js +31 -9
  59. package/src/checks/automatic/iframe-title-unique.js +29 -8
  60. package/src/checks/automatic/img-alt-present.js +47 -43
  61. package/src/checks/automatic/input-image-alt-present.js +143 -112
  62. package/src/checks/automatic/label-in-name.js +50 -22
  63. package/src/checks/automatic/language-page-present.js +117 -109
  64. package/src/checks/automatic/link-in-text-block.js +59 -19
  65. package/src/checks/automatic/link-name-present.js +45 -14
  66. package/src/checks/automatic/list-children-valid.js +26 -9
  67. package/src/checks/automatic/listbox-name-present.js +39 -19
  68. package/src/checks/automatic/listitem-parent-valid.js +18 -6
  69. package/src/checks/automatic/menuitem-name-present.js +39 -61
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
  73. package/src/checks/automatic/meter-name-present.js +36 -33
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
  75. package/src/checks/automatic/object-text-alternative-present.js +91 -39
  76. package/src/checks/automatic/option-name-present.js +38 -21
  77. package/src/checks/automatic/page-title-present.js +26 -7
  78. package/src/checks/automatic/progressbar-name-present.js +41 -34
  79. package/src/checks/automatic/role-img-alt-present.js +209 -157
  80. package/src/checks/automatic/searchbox-name-present.js +39 -19
  81. package/src/checks/automatic/server-side-image-map-absent.js +23 -8
  82. package/src/checks/automatic/slider-name-present.js +40 -47
  83. package/src/checks/automatic/spinbutton-name-present.js +39 -19
  84. package/src/checks/automatic/summary-name-present.js +37 -17
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
  86. package/src/checks/automatic/svg-text-alternative-present.js +246 -226
  87. package/src/checks/automatic/tab-name-present.js +37 -60
  88. package/src/checks/automatic/table-headers-attr-valid.js +24 -8
  89. package/src/checks/automatic/table-th-has-data-cells.js +22 -8
  90. package/src/checks/automatic/target-size-minimum.js +118 -48
  91. package/src/checks/automatic/td-has-header.js +29 -11
  92. package/src/checks/automatic/textbox-name-present.js +39 -19
  93. package/src/checks/automatic/tooltip-name-present.js +37 -18
  94. package/src/checks/automatic/treeitem-name-present.js +38 -21
  95. package/src/checks/automatic/valid-lang.js +20 -6
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
  97. package/src/checks/manual/accesskeys-manual.js +14 -5
  98. package/src/checks/manual/area-alt-decorative-manual.js +192 -193
  99. package/src/checks/manual/area-alt-quality-manual.js +182 -141
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
  101. package/src/checks/manual/aria-text-manual.js +14 -6
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
  103. package/src/checks/manual/css-hidden-focus.js +196 -165
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
  105. package/src/checks/manual/empty-heading-manual.js +24 -12
  106. package/src/checks/manual/empty-table-header-manual.js +21 -14
  107. package/src/checks/manual/focus-order-semantics-manual.js +45 -10
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
  109. package/src/checks/manual/heading-order-manual.js +22 -12
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +17 -7
  112. package/src/checks/manual/img-alt-decorative-manual.js +131 -96
  113. package/src/checks/manual/img-alt-quality-manual.js +176 -127
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
  115. package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
  116. package/src/checks/manual/label-title-only-manual.js +14 -5
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
  123. package/src/checks/manual/landmark-one-main-manual.js +35 -21
  124. package/src/checks/manual/landmark-unique-manual.js +73 -45
  125. package/src/checks/manual/link-name-quality-manual.js +43 -12
  126. package/src/checks/manual/media-transcript-present-manual.js +35 -22
  127. package/src/checks/manual/meta-viewport-large-manual.js +25 -6
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
  129. package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
  131. package/src/checks/manual/p-as-heading-manual.js +22 -7
  132. package/src/checks/manual/page-has-heading-one-manual.js +40 -23
  133. package/src/checks/manual/page-title-patterns-manual.js +87 -51
  134. package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
  135. package/src/checks/manual/region-manual.js +275 -62
  136. package/src/checks/manual/scope-attr-valid-manual.js +11 -9
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
  138. package/src/checks/manual/skip-link-manual.js +35 -12
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
  140. package/src/checks/manual/tabindex-manual.js +11 -9
  141. package/src/checks/manual/table-duplicate-name-manual.js +17 -7
  142. package/src/checks/manual/table-fake-caption-manual.js +24 -7
  143. package/src/checks/manual/video-caption-manual.js +15 -4
  144. package/src/checks/manual-review.js +56 -12
  145. package/src/core/aria-helpers.js +1128 -823
  146. package/src/core/contrast-helpers.js +1217 -1062
  147. package/src/core/dom-helpers.js +4176 -3886
  148. package/src/core/dom-runner.js +720 -596
  149. package/src/core/frame-messaging.js +189 -138
  150. package/src/core/frame-scan.js +94 -82
  151. package/src/core/rollup-composites.js +94 -102
  152. package/src/core/rule-meta.js +71 -35
  153. package/src/core.js +37939 -29121
  154. package/src/i18n/en.js +1194 -887
  155. package/src/i18n/fr.js +1136 -793
  156. package/src/policy/contracts.js +13 -13
  157. package/src/policy/resolvePolicy.js +48 -44
  158. package/src/report.js +502 -0
  159. package/src/sarif.js +175 -0
  160. package/surea11y.browser.js +36042 -0
@@ -32,267 +32,287 @@
32
32
  const id = 'svg-text-alternative-present';
33
33
 
34
34
  const meta = {
35
- title: '<svg> must provide a text alternative',
36
- description: 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
37
- i18n: {
38
- titleKey: 'svg_textAltPresent_title',
39
- descriptionKey: 'svg_textAltPresent_description'
40
- },
41
- helpUrl: null,
42
- tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
43
- wcagSc: ['1.1.1'],
44
- normativeMappings: [
45
- {standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A'}
46
- ],
47
- defaultSeverity: 'serious',
48
- category: 'perceivable',
49
- type: 'automatic',
50
- defaultConfidence: 'high',
51
- coverage: {
52
- facetsBySc: {
53
- '1.1.1': ['svg-text-alternative-present']
54
- }
35
+ title: '<svg> must provide a text alternative',
36
+ description:
37
+ 'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count — it only contributes to the accessible description, not the name).',
38
+ i18n: {
39
+ titleKey: 'svg_textAltPresent_title',
40
+ descriptionKey: 'svg_textAltPresent_description'
41
+ },
42
+ helpUrl: null,
43
+ tags: ['wcag2a', 'wcag111', 'svg', 'nontext', 'images', 'atomic', 'automatic'],
44
+ wcagSc: ['1.1.1'],
45
+ normativeMappings: [
46
+ {
47
+ standard: 'WCAG',
48
+ version: '2.2',
49
+ requirement: '1.1.1',
50
+ title: 'Non-text Content',
51
+ conformanceLevel: 'A'
52
+ }
53
+ ],
54
+ defaultSeverity: 'serious',
55
+ category: 'perceivable',
56
+ type: 'automatic',
57
+ defaultConfidence: 'high',
58
+ coverage: {
59
+ facetsBySc: {
60
+ '1.1.1': ['svg-text-alternative-present']
55
61
  }
62
+ }
56
63
  };
57
64
 
58
65
  function runInPage(ctx) {
59
- const {document, root, helpers, rule} = ctx;
60
- const safeRoot = root || document;
61
-
62
- const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
63
- const queryAll = helpers && typeof helpers.queryAll === 'function'
64
- ? helpers.queryAll
65
- : (sel) => {
66
- try {
67
- return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : [];
68
- } catch {
69
- return [];
70
- }
66
+ const { document, root, helpers, rule } = ctx;
67
+ const safeRoot = root || document;
68
+
69
+ const queryAllSmart =
70
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
71
+ const queryAll =
72
+ helpers && typeof helpers.queryAll === 'function'
73
+ ? helpers.queryAll
74
+ : (sel) => {
75
+ try {
76
+ return safeRoot && safeRoot.querySelectorAll
77
+ ? Array.from(safeRoot.querySelectorAll(sel))
78
+ : [];
79
+ } catch {
80
+ return [];
81
+ }
71
82
  };
72
83
 
73
- const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
74
- ? helpers.getEligibilityInfo
75
- : null;
84
+ const getEligibilityInfo =
85
+ helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
76
86
 
77
- const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
78
- ? helpers.isAccTreeEligible
79
- : null;
87
+ const isAccTreeEligible =
88
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
80
89
 
81
- const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
82
- ? helpers.getFocusableInfo
83
- : null;
90
+ const getFocusableInfo =
91
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
84
92
 
85
- const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
86
- ? helpers.getAriaNameInfo
87
- : null;
93
+ const getAriaNameInfo =
94
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
88
95
 
89
- function trim(v) {
90
- try {
91
- return (v == null ? '' : String(v)).trim();
92
- } catch {
93
- return '';
94
- }
96
+ function trim(v) {
97
+ try {
98
+ return (v == null ? '' : String(v)).trim();
99
+ } catch {
100
+ return '';
95
101
  }
96
-
97
- // Per SVG accessible-name conventions, only a <title> that is literally
98
- // the first child element is used by assistive technologies as the
99
- // SVG's accessible name; a <title> appearing later among the children
100
- // is commonly ignored by AT even though it's still a valid DOM child.
101
- function nonEmptyFirstChildTitleText(svg) {
102
- try {
103
- const first = svg.firstElementChild;
104
- const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
105
- if (tn === 'title') {
106
- const txt = trim(first.textContent);
107
- if (txt) return txt;
108
- }
109
- } catch {
102
+ }
103
+
104
+ // Per SVG accessible-name conventions, only a <title> that is literally
105
+ // the first child element is used by assistive technologies as the
106
+ // SVG's accessible name; a <title> appearing later among the children
107
+ // is commonly ignored by AT even though it's still a valid DOM child.
108
+ function nonEmptyFirstChildTitleText(svg) {
109
+ try {
110
+ const first = svg.firstElementChild;
111
+ const tn = first ? (first.localName || first.tagName || '').toLowerCase() : '';
112
+ if (tn === 'title') {
113
+ const txt = trim(first.textContent);
114
+ if (txt) return txt;
115
+ }
116
+ } catch {}
117
+ return '';
118
+ }
119
+
120
+ // <desc> counts when it is the first child, or the second child
121
+ // immediately following a <title> — the standard <title>+<desc> pairing
122
+ // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
123
+ // appearing later than that is not reliably read by AT.
124
+ function nonEmptyDescText(svg) {
125
+ try {
126
+ const first = svg.firstElementChild;
127
+ const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
128
+ if (firstTag === 'desc') {
129
+ const txt = trim(first.textContent);
130
+ if (txt) return txt;
131
+ } else if (firstTag === 'title' && first.nextElementSibling) {
132
+ const second = first.nextElementSibling;
133
+ const secondTag = (second.localName || second.tagName || '').toLowerCase();
134
+ if (secondTag === 'desc') {
135
+ const txt = trim(second.textContent);
136
+ if (txt) return txt;
110
137
  }
111
- return '';
112
- }
113
-
114
- // <desc> counts when it is the first child, or the second child
115
- // immediately following a <title> — the standard <title>+<desc> pairing
116
- // (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
117
- // appearing later than that is not reliably read by AT.
118
- function nonEmptyDescText(svg) {
138
+ }
139
+ } catch {}
140
+ return '';
141
+ }
142
+
143
+ function isFocusable(svg) {
144
+ if (getFocusableInfo) {
145
+ const fi = (() => {
119
146
  try {
120
- const first = svg.firstElementChild;
121
- const firstTag = first ? (first.localName || first.tagName || '').toLowerCase() : '';
122
- if (firstTag === 'desc') {
123
- const txt = trim(first.textContent);
124
- if (txt) return txt;
125
- } else if (firstTag === 'title' && first.nextElementSibling) {
126
- const second = first.nextElementSibling;
127
- const secondTag = (second.localName || second.tagName || '').toLowerCase();
128
- if (secondTag === 'desc') {
129
- const txt = trim(second.textContent);
130
- if (txt) return txt;
131
- }
132
- }
147
+ return getFocusableInfo(svg, ctx);
133
148
  } catch {
149
+ return null;
134
150
  }
135
- return '';
151
+ })();
152
+ return !!(fi && fi.focusable);
136
153
  }
137
-
138
- function isFocusable(svg) {
139
- if (getFocusableInfo) {
140
- const fi = (() => {
141
- try {
142
- return getFocusableInfo(svg, ctx);
143
- } catch {
144
- return null;
145
- }
146
- })();
147
- return !!(fi && fi.focusable);
148
- }
149
- // deterministic fallback: tabindex presence/valid number
150
- try {
151
- const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
152
- return tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
153
- } catch {
154
- return false;
155
- }
154
+ // deterministic fallback: tabindex presence/valid number
155
+ try {
156
+ const tabindex = svg && svg.getAttribute ? svg.getAttribute('tabindex') : null;
157
+ return (
158
+ tabindex != null &&
159
+ String(tabindex).trim() !== '' &&
160
+ !Number.isNaN(Number(String(tabindex).trim()))
161
+ );
162
+ } catch {
163
+ return false;
156
164
  }
165
+ }
157
166
 
158
- const svgs = (() => {
159
- try {
160
- return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
161
- } catch {
162
- return queryAll('svg');
163
- }
164
- })();
165
-
166
- if (!svgs.length) {
167
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
167
+ const svgs = (() => {
168
+ try {
169
+ return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
170
+ } catch {
171
+ return queryAll('svg');
168
172
  }
173
+ })();
169
174
 
170
- const occurrences = [];
171
- let applicableCount = 0;
172
-
173
- for (const el of svgs) {
174
- if (!el || !el.getAttribute) continue;
175
-
176
- // Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
177
- if (isAccTreeEligible) {
178
- const elig = (() => {
179
- try {
180
- return isAccTreeEligible(el, ctx);
181
- } catch {
182
- return {eligible: true, reasons: []};
183
- }
184
- })();
185
- if (elig && elig.eligible === false) continue;
186
- }
175
+ if (!svgs.length) {
176
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
177
+ }
187
178
 
188
- // Applicability step 2: role (presentation/none) exclusion only when not focusable
189
- const role = (() => {
190
- try {
191
- return String(el.getAttribute('role') || '').trim().toLowerCase();
192
- } catch {
193
- return '';
194
- }
195
- })();
179
+ const occurrences = [];
180
+ let applicableCount = 0;
196
181
 
197
- const focusable = isFocusable(el);
182
+ for (const el of svgs) {
183
+ if (!el || !el.getAttribute) continue;
198
184
 
199
- if (role === 'presentation' || role === 'none') {
200
- if (!focusable) continue;
201
- }
202
-
203
- // Applicability step 3: intent signal gating (computed once per element)
204
- let hasAriaNamingAttr = false;
185
+ // Applicability step 1: only acc-tree eligible nodes (with helper exceptions)
186
+ if (isAccTreeEligible) {
187
+ const elig = (() => {
205
188
  try {
206
- hasAriaNamingAttr = (el.getAttribute('aria-label') != null) || (el.getAttribute('aria-labelledby') != null);
189
+ return isAccTreeEligible(el, ctx);
207
190
  } catch {
191
+ return { eligible: true, reasons: [] };
208
192
  }
193
+ })();
194
+ if (elig && elig.eligible === false) continue;
195
+ }
209
196
 
210
- const titleText = nonEmptyFirstChildTitleText(el);
211
- const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
212
- const hasTitleOrDesc = !!(titleText || descText);
213
-
214
- const hasIntent =
215
- role === 'img' ||
216
- hasAriaNamingAttr ||
217
- hasTitleOrDesc ||
218
- focusable;
219
-
220
- if (!hasIntent) continue;
221
-
222
- applicableCount += 1;
223
-
224
- // Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
225
- let hasAriaName = false;
226
- if (hasAriaNamingAttr) {
227
- if (getAriaNameInfo) {
228
- const info = (() => {
229
- try {
230
- return getAriaNameInfo(el, ctx);
231
- } catch {
232
- return null;
233
- }
234
- })();
235
- hasAriaName = !!(info && info.present && trim(info.value));
236
- } else {
237
- // minimal deterministic fallback
238
- const ariaLabel = trim((() => {
239
- try {
240
- return el.getAttribute('aria-label');
241
- } catch {
242
- return '';
243
- }
244
- })());
245
- const ariaLabelledby = trim((() => {
246
- try {
247
- return el.getAttribute('aria-labelledby');
248
- } catch {
249
- return '';
250
- }
251
- })());
252
- hasAriaName = !!(ariaLabel || ariaLabelledby);
253
- }
254
- }
197
+ // Applicability step 2: role (presentation/none) exclusion only when not focusable
198
+ const role = (() => {
199
+ try {
200
+ return String(el.getAttribute('role') || '')
201
+ .trim()
202
+ .toLowerCase();
203
+ } catch {
204
+ return '';
205
+ }
206
+ })();
255
207
 
256
- // Per SVG-AAM §7.1: <desc> contributes only to the accessible
257
- // DESCRIPTION, never the accessible NAME — so descText does not
258
- // count here even though it does count toward applicability above.
259
- const ok = !!titleText || hasAriaName;
260
- if (ok) continue;
208
+ const focusable = isFocusable(el);
261
209
 
262
- let eligInfo = null;
263
- if (getEligibilityInfo) {
264
- try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
265
- }
210
+ if (role === 'presentation' || role === 'none') {
211
+ if (!focusable) continue;
212
+ }
266
213
 
267
- const baseOccurrence = {
268
- summary: 'Missing text alternative for <svg>.',
269
- hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
270
- i18n: {
271
- summaryKey: 'svg_textAltPresent_summary_fail',
272
- hintKey: 'svg_textAltPresent_hint_fail',
273
- params: {element: 'svg'}
274
- },
275
- data: {
276
- visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []}
214
+ // Applicability step 3: intent signal gating (computed once per element)
215
+ let hasAriaNamingAttr = false;
216
+ try {
217
+ hasAriaNamingAttr =
218
+ el.getAttribute('aria-label') != null || el.getAttribute('aria-labelledby') != null;
219
+ } catch {}
220
+
221
+ const titleText = nonEmptyFirstChildTitleText(el);
222
+ const descText = titleText ? '' : nonEmptyDescText(el); // avoid second scan if title already passes
223
+ const hasTitleOrDesc = !!(titleText || descText);
224
+
225
+ const hasIntent = role === 'img' || hasAriaNamingAttr || hasTitleOrDesc || focusable;
226
+
227
+ if (!hasIntent) continue;
228
+
229
+ applicableCount += 1;
230
+
231
+ // Expectation: non-empty title/desc OR ARIA name (but only resolve name if attrs exist)
232
+ let hasAriaName = false;
233
+ if (hasAriaNamingAttr) {
234
+ if (getAriaNameInfo) {
235
+ const info = (() => {
236
+ try {
237
+ return getAriaNameInfo(el, ctx);
238
+ } catch {
239
+ return null;
240
+ }
241
+ })();
242
+ hasAriaName = !!(info && info.present && trim(info.value));
243
+ } else {
244
+ // minimal deterministic fallback
245
+ const ariaLabel = trim(
246
+ (() => {
247
+ try {
248
+ return el.getAttribute('aria-label');
249
+ } catch {
250
+ return '';
277
251
  }
278
- };
279
-
280
- if (helpers && typeof helpers.reportOccurrence === 'function') {
281
- occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
282
- } else {
283
- occurrences.push({selector: '', html: '', ...baseOccurrence});
284
- }
252
+ })()
253
+ );
254
+ const ariaLabelledby = trim(
255
+ (() => {
256
+ try {
257
+ return el.getAttribute('aria-labelledby');
258
+ } catch {
259
+ return '';
260
+ }
261
+ })()
262
+ );
263
+ hasAriaName = !!(ariaLabel || ariaLabelledby);
264
+ }
285
265
  }
286
266
 
287
- if (applicableCount === 0) {
288
- return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
267
+ // Per SVG-AAM §7.1: <desc> contributes only to the accessible
268
+ // DESCRIPTION, never the accessible NAME — so descText does not
269
+ // count here even though it does count toward applicability above.
270
+ const ok = !!titleText || hasAriaName;
271
+ if (ok) continue;
272
+
273
+ let eligInfo = null;
274
+ if (getEligibilityInfo) {
275
+ try {
276
+ eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' });
277
+ } catch {
278
+ eligInfo = null;
279
+ }
289
280
  }
290
281
 
291
- if (!occurrences.length) {
292
- return {ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: []};
282
+ const baseOccurrence = {
283
+ summary: 'Missing text alternative for <svg>.',
284
+ hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby) — a <desc> element alone does not provide an accessible name.',
285
+ i18n: {
286
+ summaryKey: 'svg_textAltPresent_summary_fail',
287
+ hintKey: 'svg_textAltPresent_hint_fail',
288
+ params: { element: 'svg' }
289
+ },
290
+ data: {
291
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
292
+ }
293
+ };
294
+
295
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
296
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
297
+ } else {
298
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
293
299
  }
294
-
295
- return {ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences};
300
+ }
301
+
302
+ if (applicableCount === 0) {
303
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
304
+ }
305
+
306
+ if (!occurrences.length) {
307
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
308
+ }
309
+
310
+ return {
311
+ ruleId: rule.ruleId,
312
+ outcome: 'fail',
313
+ severity: rule.defaultSeverity || 'minor',
314
+ occurrences
315
+ };
296
316
  }
297
317
 
298
- module.exports = {id, meta, runInPage};
318
+ module.exports = { id, meta, runInPage };