@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
@@ -140,51 +140,49 @@ function runInPage(ctx) {
140
140
  : [...eligibleLabels];
141
141
 
142
142
  const tag = el.tagName.toLowerCase();
143
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
144
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
145
143
 
146
144
  if (contributing.length >= 2) {
147
- failOccurrences.push({
148
- selector: stableSelector,
149
- html,
150
- summary: 'This form control is associated with more than one <label>.',
151
- hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
152
- occurrenceOutcome: 'fail',
153
- i18n: {
154
- summaryKey: 'formControlSingleLabel_summary_fail',
155
- hintKey: 'formControlSingleLabel_hint_fail',
156
- params: { element: tag, labelCount: String(contributing.length) }
157
- },
158
- data: {
159
- details: {
160
- reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
161
- element: tag,
162
- labelCount: contributing.length
145
+ failOccurrences.push(
146
+ helpers.reportOccurrence(el, {
147
+ summary: 'This form control is associated with more than one <label>.',
148
+ hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
149
+ occurrenceOutcome: 'fail',
150
+ i18n: {
151
+ summaryKey: 'formControlSingleLabel_summary_fail',
152
+ hintKey: 'formControlSingleLabel_hint_fail',
153
+ params: { element: tag, labelCount: String(contributing.length) }
154
+ },
155
+ data: {
156
+ details: {
157
+ reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
158
+ element: tag,
159
+ labelCount: contributing.length
160
+ }
163
161
  }
164
- }
165
- });
162
+ })
163
+ );
166
164
  } else if (contributing.length === 1) {
167
- cantTellOccurrences.push({
168
- selector: stableSelector,
169
- html,
170
- summary:
171
- 'This form control has one labelling <label> plus an extra empty <label> association.',
172
- hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
173
- occurrenceOutcome: 'cantTell',
174
- i18n: {
175
- summaryKey: 'formControlSingleLabel_summary_cantTell',
176
- hintKey: 'formControlSingleLabel_hint_cantTell',
177
- params: { element: tag, labelCount: String(eligibleLabels.size) }
178
- },
179
- data: {
180
- details: {
181
- reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
182
- element: tag,
183
- labelCount: eligibleLabels.size,
184
- contributingLabelCount: contributing.length
165
+ cantTellOccurrences.push(
166
+ helpers.reportOccurrence(el, {
167
+ summary:
168
+ 'This form control has one labelling <label> plus an extra empty <label> association.',
169
+ hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
170
+ occurrenceOutcome: 'cantTell',
171
+ i18n: {
172
+ summaryKey: 'formControlSingleLabel_summary_cantTell',
173
+ hintKey: 'formControlSingleLabel_hint_cantTell',
174
+ params: { element: tag, labelCount: String(eligibleLabels.size) }
175
+ },
176
+ data: {
177
+ details: {
178
+ reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
179
+ element: tag,
180
+ labelCount: eligibleLabels.size,
181
+ contributingLabelCount: contributing.length
182
+ }
185
183
  }
186
- }
187
- });
184
+ })
185
+ );
188
186
  }
189
187
  // contributing.length === 0: no label carries text, so nothing competes
190
188
  // for the name. A control left unnamed is form-control-programmatic-
@@ -84,15 +84,8 @@ function runInPage(ctx) {
84
84
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
85
85
  }
86
86
 
87
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
88
- const htmlSnippet = helpers.getOuterHtmlSnippet
89
- ? helpers.getOuterHtmlSnippet(html)
90
- : (html.outerHTML || '').slice(0, 200);
91
-
92
87
  const occurrences = [
93
- {
94
- selector: stableSelector,
95
- html: htmlSnippet,
88
+ helpers.reportOccurrence(html, {
96
89
  summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
97
90
  hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
98
91
  i18n: {
@@ -103,7 +96,7 @@ function runInPage(ctx) {
103
96
  data: {
104
97
  details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
105
98
  }
106
- }
99
+ })
107
100
  ];
108
101
 
109
102
  return {
@@ -322,47 +322,45 @@ function runInPage(ctx) {
322
322
  if (!candidates.length) continue;
323
323
 
324
324
  const tag = el.tagName.toLowerCase();
325
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
326
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
327
325
  const shouldProbe = candidates.length === 1;
328
326
  const runtimeProbe = shouldProbe
329
327
  ? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
330
328
  : null;
331
329
 
332
330
  if (runtimeProbe && runtimeProbe.redirected) {
333
- cantTellOccurrences.push({
334
- selector: stableSelector,
335
- html,
336
- summary:
337
- 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
338
- hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
339
- i18n: null,
340
- data: {
341
- details: {
342
- reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
343
- element: tag,
344
- runtimeProbe
331
+ cantTellOccurrences.push(
332
+ helpers.reportOccurrence(el, {
333
+ summary:
334
+ 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
335
+ hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
336
+ i18n: null,
337
+ data: {
338
+ details: {
339
+ reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
340
+ element: tag,
341
+ runtimeProbe
342
+ }
345
343
  }
346
- }
347
- });
344
+ })
345
+ );
348
346
  continue;
349
347
  }
350
348
 
351
- failOccurrences.push({
352
- selector: stableSelector,
353
- html,
354
- summary:
355
- 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
356
- hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
357
- i18n: {
358
- summaryKey: 'iframeFocusableContent_summary_fail',
359
- hintKey: 'iframeFocusableContent_hint_fail',
360
- params: { element: tag }
361
- },
362
- data: {
363
- details: { reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_FOCUSABLE', element: tag }
364
- }
365
- });
349
+ failOccurrences.push(
350
+ helpers.reportOccurrence(el, {
351
+ summary:
352
+ 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
353
+ hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
354
+ i18n: {
355
+ summaryKey: 'iframeFocusableContent_summary_fail',
356
+ hintKey: 'iframeFocusableContent_hint_fail',
357
+ params: { element: tag }
358
+ },
359
+ data: {
360
+ details: { reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_FOCUSABLE', element: tag }
361
+ }
362
+ })
363
+ );
366
364
  }
367
365
 
368
366
  if (applicableCount === 0) {
@@ -88,24 +88,22 @@ function runInPage(ctx) {
88
88
  })()
89
89
  : null;
90
90
  const tag = el.tagName.toLowerCase();
91
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
92
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
93
91
 
94
- occurrences.push({
95
- selector: stableSelector,
96
- html,
97
- summary: 'This frame has no accessible name.',
98
- hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
99
- i18n: {
100
- summaryKey: 'iframeNamePresent_summary_fail',
101
- hintKey: 'iframeNamePresent_hint_fail',
102
- params: { element: tag }
103
- },
104
- data: {
105
- details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
106
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
107
- }
108
- });
92
+ occurrences.push(
93
+ helpers.reportOccurrence(el, {
94
+ summary: 'This frame has no accessible name.',
95
+ hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
96
+ i18n: {
97
+ summaryKey: 'iframeNamePresent_summary_fail',
98
+ hintKey: 'iframeNamePresent_hint_fail',
99
+ params: { element: tag }
100
+ },
101
+ data: {
102
+ details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
103
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
104
+ }
105
+ })
106
+ );
109
107
  }
110
108
 
111
109
  if (applicableCount === 0) {
@@ -89,30 +89,25 @@ function runInPage(ctx) {
89
89
 
90
90
  for (const el of els) {
91
91
  const tag = el.tagName.toLowerCase();
92
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
93
- const html = helpers.getOuterHtmlSnippet
94
- ? helpers.getOuterHtmlSnippet(el)
95
- : el.outerHTML || '';
96
-
97
- occurrences.push({
98
- selector: stableSelector,
99
- html,
100
- summary: 'This frame’s title is not unique among the frames on this page.',
101
- hint: 'Give each frame a distinct title describing its specific content or purpose.',
102
- i18n: {
103
- summaryKey: 'iframeTitleUnique_summary_fail',
104
- hintKey: 'iframeTitleUnique_hint_fail',
105
- params: { element: tag, title }
106
- },
107
- data: {
108
- details: {
109
- reasonCode: 'IFRAME_TITLE_DUPLICATE',
110
- element: tag,
111
- title,
112
- duplicateCount: els.length
92
+ occurrences.push(
93
+ helpers.reportOccurrence(el, {
94
+ summary: 'This frame’s title is not unique among the frames on this page.',
95
+ hint: 'Give each frame a distinct title describing its specific content or purpose.',
96
+ i18n: {
97
+ summaryKey: 'iframeTitleUnique_summary_fail',
98
+ hintKey: 'iframeTitleUnique_hint_fail',
99
+ params: { element: tag, title }
100
+ },
101
+ data: {
102
+ details: {
103
+ reasonCode: 'IFRAME_TITLE_DUPLICATE',
104
+ element: tag,
105
+ title,
106
+ duplicateCount: els.length
107
+ }
113
108
  }
114
- }
115
- });
109
+ })
110
+ );
116
111
  }
117
112
  }
118
113
 
@@ -366,43 +366,38 @@ function runInPage(ctx) {
366
366
  }
367
367
 
368
368
  if (!contains) {
369
- const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
370
- const html = helpers.getOuterHtmlSnippet
371
- ? helpers.getOuterHtmlSnippet(el)
372
- : el.outerHTML || '';
373
-
374
- occurrences.push({
375
- selector: selectorOut,
376
- html,
377
- ...(uncertainty ? { outcome: 'cantTell' } : null),
378
- summary: uncertainty
379
- ? 'Accessible name may not contain the visible label text.'
380
- : 'Accessible name does not contain the visible label text.',
381
- hint: uncertainty
382
- ? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
383
- : 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
384
- i18n: {
385
- summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
386
- hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
387
- params: {
388
- element: getElementDescriptor(el),
389
- visibleLabel: clipForSummary(visibleLabel),
390
- labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
391
- nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
369
+ occurrences.push(
370
+ helpers.reportOccurrence(el, {
371
+ ...(uncertainty ? { outcome: 'cantTell' } : null),
372
+ summary: uncertainty
373
+ ? 'Accessible name may not contain the visible label text.'
374
+ : 'Accessible name does not contain the visible label text.',
375
+ hint: uncertainty
376
+ ? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
377
+ : 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
378
+ i18n: {
379
+ summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
380
+ hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
381
+ params: {
382
+ element: getElementDescriptor(el),
383
+ visibleLabel: clipForSummary(visibleLabel),
384
+ labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
385
+ nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
386
+ }
387
+ },
388
+ data: {
389
+ details: {
390
+ reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
391
+ visibleLabel,
392
+ accessibleName: accName,
393
+ normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
394
+ tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
395
+ labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
396
+ nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
397
+ }
392
398
  }
393
- },
394
- data: {
395
- details: {
396
- reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
397
- visibleLabel,
398
- accessibleName: accName,
399
- normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
400
- tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
401
- labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
402
- nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
403
- }
404
- }
405
- });
399
+ })
400
+ );
406
401
  }
407
402
  }
408
403
 
@@ -215,31 +215,29 @@ function runInPage(ctx) {
215
215
  const eligInfo = helpers.getEligibilityInfo
216
216
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
217
217
  : null;
218
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
219
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
220
218
  const tag = (el.tagName || '').toLowerCase();
221
219
  const ratioStr = c.round2 ? c.round2(ratio) : String(ratio);
222
220
 
223
- occurrences.push({
224
- selector: stableSelector,
225
- html,
226
- summary:
227
- 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
228
- hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
229
- i18n: {
230
- summaryKey: 'linkInTextBlock_summary_fail',
231
- hintKey: 'linkInTextBlock_hint_fail',
232
- params: { element: tag, ratio: String(ratioStr), threshold: '3' }
233
- },
234
- data: {
235
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
236
- details: {
237
- reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
238
- metrics: { ratio, threshold: 3 },
239
- colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
221
+ occurrences.push(
222
+ helpers.reportOccurrence(el, {
223
+ summary:
224
+ 'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
225
+ hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
226
+ i18n: {
227
+ summaryKey: 'linkInTextBlock_summary_fail',
228
+ hintKey: 'linkInTextBlock_hint_fail',
229
+ params: { element: tag, ratio: String(ratioStr), threshold: '3' }
230
+ },
231
+ data: {
232
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
233
+ details: {
234
+ reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
235
+ metrics: { ratio, threshold: 3 },
236
+ colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
237
+ }
240
238
  }
241
- }
242
- });
239
+ })
240
+ );
243
241
  }
244
242
 
245
243
  if (applicableCount === 0) {
@@ -139,39 +139,34 @@ function runInPage(ctx) {
139
139
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
140
140
  : null;
141
141
 
142
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
143
- const html = helpers.getOuterHtmlSnippet
144
- ? helpers.getOuterHtmlSnippet(el)
145
- : el.outerHTML || '';
146
142
  const tag = (el.tagName || '').toLowerCase();
147
143
 
148
- occurrences.push({
149
- selector: stableSelector,
150
- html,
151
-
152
- // Human fallbacks (allowed)
153
- summary: 'This link has no accessible name.',
154
- hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
155
-
156
- // Validator requires these keys to exist in the English dictionary
157
- i18n: {
158
- summaryKey: 'linkNamePresent_summary_fail',
159
- hintKey: 'linkNamePresent_hint_fail',
160
- params: { element: tag }
161
- },
162
-
163
- data: {
164
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
165
- details: {
166
- reasonCode: 'name_missing',
167
- metrics: {
168
- programmaticNameLength: programmaticName.trim().length,
169
- contentNameLength: contentName.trim().length
170
- },
171
- refs: { accessibleName: nameInfo || null }
144
+ occurrences.push(
145
+ helpers.reportOccurrence(el, {
146
+ // Human fallbacks (allowed)
147
+ summary: 'This link has no accessible name.',
148
+ hint: 'Provide link text or an accessible-name mechanism (for example aria-label) so assistive technologies can identify the link.',
149
+
150
+ // Validator requires these keys to exist in the English dictionary
151
+ i18n: {
152
+ summaryKey: 'linkNamePresent_summary_fail',
153
+ hintKey: 'linkNamePresent_hint_fail',
154
+ params: { element: tag }
155
+ },
156
+
157
+ data: {
158
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
159
+ details: {
160
+ reasonCode: 'name_missing',
161
+ metrics: {
162
+ programmaticNameLength: programmaticName.trim().length,
163
+ contentNameLength: contentName.trim().length
164
+ },
165
+ refs: { accessibleName: nameInfo || null }
166
+ }
172
167
  }
173
- }
174
- });
168
+ })
169
+ );
175
170
  }
176
171
  }
177
172
 
@@ -122,23 +122,21 @@ function runInPage(ctx) {
122
122
  const dedupedInvalidTags = [...new Set(invalidTags)];
123
123
 
124
124
  const tag = el.tagName.toLowerCase();
125
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
126
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
127
-
128
- occurrences.push({
129
- selector: stableSelector,
130
- html,
131
- summary: 'This list contains a direct child that is not a list item.',
132
- hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
133
- i18n: {
134
- summaryKey: 'listChildrenValid_summary_fail',
135
- hintKey: 'listChildrenValid_hint_fail',
136
- params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
137
- },
138
- data: {
139
- details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
140
- }
141
- });
125
+
126
+ occurrences.push(
127
+ helpers.reportOccurrence(el, {
128
+ summary: 'This list contains a direct child that is not a list item.',
129
+ hint: 'Only use <li> (or <script>/<template>) as direct children of <ul>/<ol>; move other markup inside an <li>.',
130
+ i18n: {
131
+ summaryKey: 'listChildrenValid_summary_fail',
132
+ hintKey: 'listChildrenValid_hint_fail',
133
+ params: { element: tag, invalidChildren: dedupedInvalidTags.join(', ') }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'LIST_INVALID_CHILD', element: tag, invalidChildren: invalidTags }
137
+ }
138
+ })
139
+ );
142
140
  }
143
141
 
144
142
  if (applicableCount === 0) {
@@ -227,24 +227,22 @@ function runInPage(ctx) {
227
227
  }
228
228
  })()
229
229
  : null;
230
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
231
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
232
230
 
233
- occurrences.push({
234
- selector: stableSelector,
235
- html,
236
- summary: 'This element has no accessible name.',
237
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
238
- i18n: {
239
- summaryKey: 'listboxNamePresent_summary_fail',
240
- hintKey: 'listboxNamePresent_hint_fail',
241
- params: { controlType: 'listbox' }
242
- },
243
- data: {
244
- details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
245
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
246
- }
247
- });
231
+ occurrences.push(
232
+ helpers.reportOccurrence(el, {
233
+ summary: 'This element has no accessible name.',
234
+ hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this listbox's accessible name.",
235
+ i18n: {
236
+ summaryKey: 'listboxNamePresent_summary_fail',
237
+ hintKey: 'listboxNamePresent_hint_fail',
238
+ params: { controlType: 'listbox' }
239
+ },
240
+ data: {
241
+ details: { reasonCode: 'name_missing', controlType: 'listbox', methodTried: res.method },
242
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
243
+ }
244
+ })
245
+ );
248
246
  }
249
247
 
250
248
  if (applicableCount === 0) {
@@ -116,23 +116,20 @@ function runInPage(ctx) {
116
116
 
117
117
  if (valid) continue;
118
118
 
119
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
120
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
121
-
122
- occurrences.push({
123
- selector: stableSelector,
124
- html,
125
- summary: 'This list item is not contained by a list container.',
126
- hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
127
- i18n: {
128
- summaryKey: 'listitemParentValid_summary_fail',
129
- hintKey: 'listitemParentValid_hint_fail',
130
- params: { parentElement: parentTag }
131
- },
132
- data: {
133
- details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
134
- }
135
- });
119
+ occurrences.push(
120
+ helpers.reportOccurrence(el, {
121
+ summary: 'This list item is not contained by a list container.',
122
+ hint: 'Place this <li> inside a <ul>/<ol>, or give its parent role="list".',
123
+ i18n: {
124
+ summaryKey: 'listitemParentValid_summary_fail',
125
+ hintKey: 'listitemParentValid_hint_fail',
126
+ params: { parentElement: parentTag }
127
+ },
128
+ data: {
129
+ details: { reasonCode: 'LISTITEM_INVALID_PARENT', parentElement: parentTag }
130
+ }
131
+ })
132
+ );
136
133
  }
137
134
 
138
135
  if (applicableCount === 0) {