@surea11y/core 1.4.1 → 1.5.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 (104) hide show
  1. package/CHANGELOG.md +47 -7
  2. package/README.md +19 -3
  3. package/docs/API_STABILITY.md +2 -2
  4. package/docs/ENGINE_OPTIONS.md +14 -10
  5. package/docs/I18N.md +176 -20
  6. package/docs/INTEGRATION.md +28 -6
  7. package/docs/LIMITATIONS.md +1 -1
  8. package/docs/OUTPUT_SCHEMA.md +13 -3
  9. package/docs/REPORT.md +2 -0
  10. package/docs/RULE_AUTHORING.md +53 -0
  11. package/docs/TROUBLESHOOTING.md +2 -2
  12. package/package.json +6 -1
  13. package/src/checks/automatic/aria-allowed-attr.js +27 -30
  14. package/src/checks/automatic/aria-allowed-role.js +14 -16
  15. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  16. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  17. package/src/checks/automatic/aria-deprecated-role.js +62 -49
  18. package/src/checks/automatic/aria-hidden-body.js +2 -9
  19. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  20. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  21. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  22. package/src/checks/automatic/aria-required-attr.js +14 -17
  23. package/src/checks/automatic/aria-required-children.js +17 -20
  24. package/src/checks/automatic/aria-required-parent.js +17 -20
  25. package/src/checks/automatic/aria-roles-valid.js +37 -23
  26. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  27. package/src/checks/automatic/aria-valid-attr.js +14 -17
  28. package/src/checks/automatic/autocomplete-valid.js +15 -17
  29. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  30. package/src/checks/automatic/binary-control-name-present.js +19 -21
  31. package/src/checks/automatic/button-name-present.js +23 -28
  32. package/src/checks/automatic/combobox-name-present.js +15 -17
  33. package/src/checks/automatic/css-orientation-lock.js +22 -22
  34. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  35. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  36. package/src/checks/automatic/dialog-name-present.js +16 -18
  37. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  38. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  39. package/src/checks/automatic/form-control-single-label.js +38 -40
  40. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  41. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  42. package/src/checks/automatic/iframe-name-present.js +15 -17
  43. package/src/checks/automatic/iframe-title-unique.js +18 -23
  44. package/src/checks/automatic/label-in-name.js +31 -36
  45. package/src/checks/automatic/link-in-text-block.js +19 -21
  46. package/src/checks/automatic/link-name-present.js +25 -30
  47. package/src/checks/automatic/list-children-valid.js +15 -17
  48. package/src/checks/automatic/listbox-name-present.js +15 -17
  49. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  50. package/src/checks/automatic/menuitem-name-present.js +16 -18
  51. package/src/checks/automatic/meta-refresh-no-exceptions.js +15 -18
  52. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -17
  53. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  54. package/src/checks/automatic/meter-name-present.js +16 -18
  55. package/src/checks/automatic/nested-interactive-controls-absent.js +17 -19
  56. package/src/checks/automatic/option-name-present.js +16 -18
  57. package/src/checks/automatic/progressbar-name-present.js +19 -21
  58. package/src/checks/automatic/searchbox-name-present.js +19 -17
  59. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  60. package/src/checks/automatic/slider-name-present.js +15 -17
  61. package/src/checks/automatic/spinbutton-name-present.js +19 -17
  62. package/src/checks/automatic/summary-name-present.js +16 -18
  63. package/src/checks/automatic/tab-name-present.js +16 -18
  64. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  65. package/src/checks/automatic/table-th-has-data-cells.js +15 -19
  66. package/src/checks/automatic/target-size-minimum.js +104 -81
  67. package/src/checks/automatic/td-has-header.js +15 -20
  68. package/src/checks/automatic/textbox-name-present.js +15 -17
  69. package/src/checks/automatic/tooltip-name-present.js +16 -18
  70. package/src/checks/automatic/treeitem-name-present.js +16 -18
  71. package/src/checks/automatic/valid-lang.js +15 -17
  72. package/src/checks/manual/accesskeys-manual.js +18 -19
  73. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  74. package/src/checks/manual/bypass-blocks-present-manual.js +4 -12
  75. package/src/checks/manual/empty-heading-manual.js +15 -17
  76. package/src/checks/manual/empty-table-header-manual.js +27 -30
  77. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  78. package/src/checks/manual/heading-order-manual.js +17 -22
  79. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  80. package/src/checks/manual/label-title-only-manual.js +15 -17
  81. package/src/checks/manual/landmark-banner-is-top-level-manual.js +14 -17
  82. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +14 -17
  83. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  84. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  85. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  86. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  87. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  88. package/src/checks/manual/landmark-unique-manual.js +22 -27
  89. package/src/checks/manual/link-name-quality-manual.js +15 -17
  90. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  91. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  92. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  93. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  94. package/src/checks/manual/region-manual.js +13 -6
  95. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  96. package/src/checks/manual/skip-link-manual.js +39 -47
  97. package/src/checks/manual/tabindex-manual.js +14 -17
  98. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  99. package/src/core.js +4203 -3604
  100. package/src/report.js +14 -0
  101. package/surea11y.browser.js +1960 -3671
  102. package/surea11y.i18n.de.js +22 -0
  103. package/surea11y.i18n.es.js +22 -0
  104. package/surea11y.i18n.fr.js +22 -0
@@ -86,24 +86,21 @@ function runInPage(ctx) {
86
86
 
87
87
  if (!invalidNames || !invalidNames.length) continue;
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
-
92
89
  for (const name of invalidNames) {
93
- occurrences.push({
94
- selector: stableSelector,
95
- html,
96
- summary: 'This element has an attribute that is not a recognized ARIA attribute.',
97
- hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
98
- i18n: {
99
- summaryKey: 'ariaValidAttr_summary_fail',
100
- hintKey: 'ariaValidAttr_hint_fail',
101
- params: { attr: name }
102
- },
103
- data: {
104
- details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
105
- }
106
- });
90
+ occurrences.push(
91
+ helpers.reportOccurrence(el, {
92
+ summary: 'This element has an attribute that is not a recognized ARIA attribute.',
93
+ hint: 'Correct the attribute name (check for typos), or remove it if not needed.',
94
+ i18n: {
95
+ summaryKey: 'ariaValidAttr_summary_fail',
96
+ hintKey: 'ariaValidAttr_hint_fail',
97
+ params: { attr: name }
98
+ },
99
+ data: {
100
+ details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
+ }
102
+ })
103
+ );
107
104
  }
108
105
  }
109
106
 
@@ -193,23 +193,21 @@ function runInPage(ctx) {
193
193
  if (isValidAutocomplete(raw)) continue;
194
194
 
195
195
  const tag = el.tagName.toLowerCase();
196
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
197
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
198
-
199
- occurrences.push({
200
- selector: stableSelector,
201
- html,
202
- summary: 'This autocomplete attribute value is not a valid autofill value.',
203
- hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
204
- i18n: {
205
- summaryKey: 'autocompleteValid_summary_fail',
206
- hintKey: 'autocompleteValid_hint_fail',
207
- params: { element: tag, value: raw }
208
- },
209
- data: {
210
- details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
211
- }
212
- });
196
+
197
+ occurrences.push(
198
+ helpers.reportOccurrence(el, {
199
+ summary: 'This autocomplete attribute value is not a valid autofill value.',
200
+ hint: 'Use "on"/"off", or a valid autofill token list (e.g. "shipping street-address", "cc-number").',
201
+ i18n: {
202
+ summaryKey: 'autocompleteValid_summary_fail',
203
+ hintKey: 'autocompleteValid_hint_fail',
204
+ params: { element: tag, value: raw }
205
+ },
206
+ data: {
207
+ details: { reasonCode: 'AUTOCOMPLETE_VALUE_INVALID', element: tag, value: raw }
208
+ }
209
+ })
210
+ );
213
211
  }
214
212
 
215
213
  if (applicableCount === 0) {
@@ -245,23 +245,21 @@ function runInPage(ctx) {
245
245
  if (!flagged.length) continue;
246
246
 
247
247
  const tag = el.tagName.toLowerCase();
248
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
249
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
250
248
 
251
- occurrences.push({
252
- selector: stableSelector,
253
- html,
254
- summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
255
- hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
256
- i18n: {
257
- summaryKey: 'avoidInlineSpacing_summary_fail',
258
- hintKey: 'avoidInlineSpacing_hint_fail',
259
- params: { element: tag, properties: flagged.join(', ') }
260
- },
261
- data: {
262
- details: { reasonCode: 'INLINE_SPACING_IMPORTANT', element: tag, properties: flagged }
263
- }
264
- });
249
+ occurrences.push(
250
+ helpers.reportOccurrence(el, {
251
+ summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
252
+ hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
253
+ i18n: {
254
+ summaryKey: 'avoidInlineSpacing_summary_fail',
255
+ hintKey: 'avoidInlineSpacing_hint_fail',
256
+ params: { element: tag, properties: flagged.join(', ') }
257
+ },
258
+ data: {
259
+ details: { reasonCode: 'INLINE_SPACING_IMPORTANT', element: tag, properties: flagged }
260
+ }
261
+ })
262
+ );
265
263
  }
266
264
 
267
265
  if (applicableCount === 0) {
@@ -272,28 +272,26 @@ function runInPage(ctx) {
272
272
  }
273
273
  })()
274
274
  : null;
275
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
276
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
277
-
278
- occurrences.push({
279
- selector: stableSelector,
280
- html,
281
- summary: 'This control has no accessible name.',
282
- hint: 'Provide a label, aria-label, aria-labelledby, or other accessible-name mechanism so assistive technologies can identify the control.',
283
- i18n: {
284
- summaryKey: 'binaryControlNamePresent_summary_fail',
285
- hintKey: 'binaryControlNamePresent_hint_fail',
286
- params: { controlType }
287
- },
288
- data: {
289
- details: {
290
- reasonCode: 'name_missing',
291
- controlType,
292
- methodTried: res.method
275
+
276
+ occurrences.push(
277
+ helpers.reportOccurrence(el, {
278
+ summary: 'This control has no accessible name.',
279
+ hint: 'Provide a label, aria-label, aria-labelledby, or other accessible-name mechanism so assistive technologies can identify the control.',
280
+ i18n: {
281
+ summaryKey: 'binaryControlNamePresent_summary_fail',
282
+ hintKey: 'binaryControlNamePresent_hint_fail',
283
+ params: { controlType }
293
284
  },
294
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
295
- }
296
- });
285
+ data: {
286
+ details: {
287
+ reasonCode: 'name_missing',
288
+ controlType,
289
+ methodTried: res.method
290
+ },
291
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
292
+ }
293
+ })
294
+ );
297
295
  }
298
296
 
299
297
  if (applicableCount === 0) {
@@ -170,35 +170,30 @@ function runInPage(ctx) {
170
170
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
171
171
  : null;
172
172
 
173
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
174
- const html = helpers.getOuterHtmlSnippet
175
- ? helpers.getOuterHtmlSnippet(el)
176
- : el.outerHTML || '';
177
-
178
- occurrences.push({
179
- selector: stableSelector,
180
- html,
181
- summary: 'This button has no accessible name.',
182
- hint: 'Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.',
183
- i18n: {
184
- summaryKey: 'buttonNamePresent_summary_fail',
185
- hintKey: 'buttonNamePresent_hint_fail',
186
- params: { element: tag }
187
- },
188
- data: {
189
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
190
- details: {
191
- reasonCode: 'name_missing',
192
- metrics: {
193
- trustedProgrammaticNameLength: trustedProgrammaticName.length,
194
- inputValueNameLength: inputValueName.length,
195
- contentNameLength: contentName.length,
196
- explicitProgrammatic: explicitProg ? 1 : 0
197
- },
198
- refs: { accessibleName: nameInfo || null }
173
+ occurrences.push(
174
+ helpers.reportOccurrence(el, {
175
+ summary: 'This button has no accessible name.',
176
+ hint: 'Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.',
177
+ i18n: {
178
+ summaryKey: 'buttonNamePresent_summary_fail',
179
+ hintKey: 'buttonNamePresent_hint_fail',
180
+ params: { element: tag }
181
+ },
182
+ data: {
183
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
184
+ details: {
185
+ reasonCode: 'name_missing',
186
+ metrics: {
187
+ trustedProgrammaticNameLength: trustedProgrammaticName.length,
188
+ inputValueNameLength: inputValueName.length,
189
+ contentNameLength: contentName.length,
190
+ explicitProgrammatic: explicitProg ? 1 : 0
191
+ },
192
+ refs: { accessibleName: nameInfo || null }
193
+ }
199
194
  }
200
- }
201
- });
195
+ })
196
+ );
202
197
  }
203
198
  }
204
199
 
@@ -231,24 +231,22 @@ function runInPage(ctx) {
231
231
  }
232
232
  })()
233
233
  : null;
234
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
235
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
236
234
 
237
- occurrences.push({
238
- selector: stableSelector,
239
- html,
240
- summary: 'This combobox has no accessible name.',
241
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this combobox's accessible name.",
242
- i18n: {
243
- summaryKey: 'comboboxNamePresent_summary_fail',
244
- hintKey: 'comboboxNamePresent_hint_fail',
245
- params: {}
246
- },
247
- data: {
248
- details: { reasonCode: 'name_missing', controlType: 'combobox', methodTried: res.method },
249
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
250
- }
251
- });
235
+ occurrences.push(
236
+ helpers.reportOccurrence(el, {
237
+ summary: 'This combobox has no accessible name.',
238
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this combobox's accessible name.",
239
+ i18n: {
240
+ summaryKey: 'comboboxNamePresent_summary_fail',
241
+ hintKey: 'comboboxNamePresent_hint_fail',
242
+ params: {}
243
+ },
244
+ data: {
245
+ details: { reasonCode: 'name_missing', controlType: 'combobox', methodTried: res.method },
246
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
247
+ }
248
+ })
249
+ );
252
250
  }
253
251
 
254
252
  if (applicableCount === 0) {
@@ -156,7 +156,7 @@ function runInPage(ctx) {
156
156
  for (const r of rules) {
157
157
  if (!r) continue;
158
158
  if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
159
- findings.push({ mediaText, selectorText: trim(r.selectorText) || '(unknown selector)' });
159
+ findings.push({ mediaText, selectorText: trim(r.selectorText) });
160
160
  }
161
161
  }
162
162
  }
@@ -196,28 +196,28 @@ function runInPage(ctx) {
196
196
  }
197
197
 
198
198
  const target = document.documentElement || document.body || null;
199
- const stableSelector = helpers.buildSelector && target ? helpers.buildSelector(target) : 'html';
200
- const html =
201
- helpers.getOuterHtmlSnippet && target ? helpers.getOuterHtmlSnippet(target) : '<html>';
202
-
203
- const occurrences = findings.map((f) => ({
204
- selector: stableSelector,
205
- html,
206
- summary: `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`,
207
- hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
208
- i18n: {
209
- summaryKey: 'cssOrientationLock_summary_fail',
210
- hintKey: 'cssOrientationLock_hint_fail',
211
- params: { mediaText: f.mediaText, selectorText: f.selectorText }
212
- },
213
- data: {
214
- details: {
215
- reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
216
- mediaText: f.mediaText,
217
- selectorText: f.selectorText
199
+ const occurrences = findings.map((f) =>
200
+ helpers.reportOccurrence(target, {
201
+ summary: f.selectorText
202
+ ? `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`
203
+ : `A "${f.mediaText}" media query rotates an element with no readable selector, locking the page to one orientation.`,
204
+ hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
205
+ i18n: {
206
+ summaryKey: f.selectorText
207
+ ? 'cssOrientationLock_summary_fail'
208
+ : 'cssOrientationLock_summary_fail_unknownSelector',
209
+ hintKey: 'cssOrientationLock_hint_fail',
210
+ params: { mediaText: f.mediaText, selectorText: f.selectorText }
211
+ },
212
+ data: {
213
+ details: {
214
+ reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
215
+ mediaText: f.mediaText,
216
+ selectorText: f.selectorText
217
+ }
218
218
  }
219
- }
220
- }));
219
+ })
220
+ );
221
221
 
222
222
  return {
223
223
  ruleId: rule.ruleId,
@@ -121,9 +121,6 @@ function runInPage(ctx) {
121
121
  : null;
122
122
  if (!reasonCode) continue;
123
123
 
124
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
125
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
126
-
127
124
  const summary = invalidTags.length
128
125
  ? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
129
126
  : 'This description list has no <dt>/<dd> term-definition group.';
@@ -131,24 +128,24 @@ function runInPage(ctx) {
131
128
  ? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
132
129
  : 'Add at least one <dt>/<dd> pair inside this <dl>.';
133
130
 
134
- occurrences.push({
135
- selector: stableSelector,
136
- html,
137
- summary,
138
- hint,
139
- i18n: {
140
- summaryKey: invalidTags.length
141
- ? 'definitionListChildrenValid_summary_fail_invalidChild'
142
- : 'definitionListChildrenValid_summary_fail_noDtDd',
143
- hintKey: invalidTags.length
144
- ? 'definitionListChildrenValid_hint_fail_invalidChild'
145
- : 'definitionListChildrenValid_hint_fail_noDtDd',
146
- params: { invalidChildren: dedupedInvalidTags.join(', ') }
147
- },
148
- data: {
149
- details: { reasonCode, invalidChildren: invalidTags }
150
- }
151
- });
131
+ occurrences.push(
132
+ helpers.reportOccurrence(el, {
133
+ summary,
134
+ hint,
135
+ i18n: {
136
+ summaryKey: invalidTags.length
137
+ ? 'definitionListChildrenValid_summary_fail_invalidChild'
138
+ : 'definitionListChildrenValid_summary_fail_noDtDd',
139
+ hintKey: invalidTags.length
140
+ ? 'definitionListChildrenValid_hint_fail_invalidChild'
141
+ : 'definitionListChildrenValid_hint_fail_noDtDd',
142
+ params: { invalidChildren: dedupedInvalidTags.join(', ') }
143
+ },
144
+ data: {
145
+ details: { reasonCode, invalidChildren: invalidTags }
146
+ }
147
+ })
148
+ );
152
149
  }
153
150
 
154
151
  if (applicableCount === 0) {
@@ -72,23 +72,21 @@ function runInPage(ctx) {
72
72
  applicableCount += 1;
73
73
 
74
74
  const tag = el.tagName.toLowerCase();
75
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
76
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
77
75
 
78
- occurrences.push({
79
- selector: stableSelector,
80
- html,
81
- summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
82
- hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
83
- i18n: {
84
- summaryKey: 'deprecatedElements_summary_fail',
85
- hintKey: 'deprecatedElements_hint_fail',
86
- params: { element: tag }
87
- },
88
- data: {
89
- details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
90
- }
91
- });
76
+ occurrences.push(
77
+ helpers.reportOccurrence(el, {
78
+ summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
79
+ hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
80
+ i18n: {
81
+ summaryKey: 'deprecatedElements_summary_fail',
82
+ hintKey: 'deprecatedElements_hint_fail',
83
+ params: { element: tag }
84
+ },
85
+ data: {
86
+ details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
87
+ }
88
+ })
89
+ );
92
90
  }
93
91
 
94
92
  if (applicableCount === 0) {
@@ -140,24 +140,22 @@ function runInPage(ctx) {
140
140
  }
141
141
  })()
142
142
  : null;
143
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
144
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
145
-
146
- occurrences.push({
147
- selector: stableSelector,
148
- html,
149
- summary: 'This dialog has no accessible name.',
150
- hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
151
- i18n: {
152
- summaryKey: 'dialogNamePresent_summary_fail',
153
- hintKey: 'dialogNamePresent_hint_fail',
154
- params: { role }
155
- },
156
- data: {
157
- details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
158
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
159
- }
160
- });
143
+
144
+ occurrences.push(
145
+ helpers.reportOccurrence(el, {
146
+ summary: 'This dialog has no accessible name.',
147
+ hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
148
+ i18n: {
149
+ summaryKey: 'dialogNamePresent_summary_fail',
150
+ hintKey: 'dialogNamePresent_hint_fail',
151
+ params: { role }
152
+ },
153
+ data: {
154
+ details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
155
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
156
+ }
157
+ })
158
+ );
161
159
  }
162
160
 
163
161
  if (applicableCount === 0) {
@@ -80,23 +80,21 @@ function runInPage(ctx) {
80
80
  if (valid) continue;
81
81
 
82
82
  const tag = el.tagName.toLowerCase();
83
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
84
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
85
-
86
- occurrences.push({
87
- selector: stableSelector,
88
- html,
89
- summary: 'This description-list item is not contained by a <dl>.',
90
- hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
91
- i18n: {
92
- summaryKey: 'dlitemParentValid_summary_fail',
93
- hintKey: 'dlitemParentValid_hint_fail',
94
- params: { element: tag, parentElement: parentTag }
95
- },
96
- data: {
97
- details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
98
- }
99
- });
83
+
84
+ occurrences.push(
85
+ helpers.reportOccurrence(el, {
86
+ summary: 'This description-list item is not contained by a <dl>.',
87
+ hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
88
+ i18n: {
89
+ summaryKey: 'dlitemParentValid_summary_fail',
90
+ hintKey: 'dlitemParentValid_hint_fail',
91
+ params: { element: tag, parentElement: parentTag }
92
+ },
93
+ data: {
94
+ details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
95
+ }
96
+ })
97
+ );
100
98
  }
101
99
 
102
100
  if (applicableCount === 0) {
@@ -15,15 +15,16 @@
15
15
  * attribute (i.e. at least one ARIA ID reference exists to resolve).
16
16
  * @expectation
17
17
  * For every id value referenced by one of those attributes, exactly one
18
- * element in the document carries that id. A duplicated id referenced by
19
- * ARIA is ambiguous: assistive technologies cannot reliably determine
20
- * which element the reference resolves to (typically the first, silently
21
- * dropping the others).
18
+ * element in the document carries that id. A duplicate does not break the
19
+ * reference: it resolves to the first element in tree order, so the name is
20
+ * still computed. Whether that element is the intended target depends on
21
+ * author intent, which markup does not carry, so the outcome is cantTell.
22
22
  * @implementation-notes
23
23
  * - Scoped deliberately to ids referenced by ARIA, not the broader/
24
24
  * deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
25
25
  * - Document-wide by design: id uniqueness and ARIA id references are a
26
- * whole-document property, not scoped to a sub-root.
26
+ * whole-document property, not scoped to a sub-root. Reported occurrences are
27
+ * limited to the scanned scope.
27
28
  */
28
29
 
29
30
  const id = 'duplicate-id-aria';
@@ -95,51 +96,58 @@ function runInPage(ctx) {
95
96
  }
96
97
  }
97
98
 
98
- const occurrences = [];
99
+ // Detection is document-wide; occurrences are limited to the scanned scope.
100
+ let inScope = null;
101
+ if (helpers && typeof helpers.queryAllSmart === 'function') {
102
+ try {
103
+ const scoped = helpers.queryAllSmart('[id]');
104
+ inScope = new Set(Array.isArray(scoped) ? scoped : Array.from(scoped || []));
105
+ } catch {
106
+ inScope = null;
107
+ }
108
+ }
109
+
110
+ const cantTellOccurrences = [];
99
111
 
100
112
  for (const refId of referencedIds) {
101
113
  const els = idMap.get(refId) || [];
102
114
  if (els.length <= 1) continue;
103
115
 
104
116
  for (const el of els) {
105
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
106
- const html = helpers.getOuterHtmlSnippet
107
- ? helpers.getOuterHtmlSnippet(el)
108
- : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This id is referenced by an ARIA attribute but is used by more than one element.',
114
- hint: 'Make ids referenced by ARIA attributes unique within the document.',
115
- i18n: {
116
- summaryKey: 'duplicateIdAria_summary_fail',
117
- hintKey: 'duplicateIdAria_hint_fail',
118
- params: { id: refId, duplicateCount: String(els.length) }
119
- },
120
- data: {
121
- details: {
122
- reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
123
- id: refId,
124
- duplicateCount: els.length
117
+ if (inScope && !inScope.has(el)) continue;
118
+
119
+ cantTellOccurrences.push(
120
+ helpers.reportOccurrence(el, {
121
+ summary:
122
+ 'This id is referenced by an ARIA attribute but is used by more than one element; the reference resolves to the first.',
123
+ hint: 'Confirm the first element carrying this id is the intended target, or make the id unique.',
124
+ i18n: {
125
+ summaryKey: 'duplicateIdAria_summary_cantTell',
126
+ hintKey: 'duplicateIdAria_hint_cantTell',
127
+ params: { id: refId, duplicateCount: String(els.length) }
128
+ },
129
+ data: {
130
+ details: {
131
+ reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
132
+ id: refId,
133
+ duplicateCount: els.length
134
+ }
125
135
  }
126
- }
127
- });
136
+ })
137
+ );
128
138
  }
129
139
  }
130
140
 
131
141
  if (referencedIds.size === 0) {
132
142
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
133
143
  }
134
- if (occurrences.length) {
135
- return {
136
- ruleId: rule.ruleId,
137
- outcome: 'fail',
138
- severity: rule.defaultSeverity || 'serious',
139
- occurrences
140
- };
141
- }
142
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
144
+
145
+ const resolved = helpers.resolveTieredOutcome(
146
+ [],
147
+ cantTellOccurrences,
148
+ rule.defaultSeverity || 'serious'
149
+ );
150
+ return { ruleId: rule.ruleId, ...resolved };
143
151
  }
144
152
 
145
153
  module.exports = { id, meta, runInPage };