@surea11y/core 1.4.0 → 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 (115) hide show
  1. package/CHANGELOG.md +88 -7
  2. package/README.md +19 -3
  3. package/bin/surea11y-core.js +0 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/ARIA_DEPRECATION.md +95 -0
  6. package/docs/ENGINE_OPTIONS.md +14 -10
  7. package/docs/I18N.md +176 -20
  8. package/docs/INTEGRATION.md +28 -6
  9. package/docs/LIMITATIONS.md +1 -1
  10. package/docs/OUTPUT_SCHEMA.md +13 -3
  11. package/docs/REPORT.md +2 -0
  12. package/docs/RULE_AUTHORING.md +53 -0
  13. package/docs/RULE_CATALOG.md +6 -6
  14. package/docs/TROUBLESHOOTING.md +2 -2
  15. package/package.json +8 -1
  16. package/src/checks/automatic/aria-allowed-attr.js +661 -99
  17. package/src/checks/automatic/aria-allowed-role.js +14 -16
  18. package/src/checks/automatic/aria-braille-equivalent.js +17 -19
  19. package/src/checks/automatic/aria-conditional-attr.js +17 -19
  20. package/src/checks/automatic/aria-deprecated-role.js +122 -41
  21. package/src/checks/automatic/aria-hidden-body.js +2 -9
  22. package/src/checks/automatic/aria-hidden-focus.js +99 -18
  23. package/src/checks/automatic/aria-prohibited-attr.js +54 -55
  24. package/src/checks/automatic/aria-prohibited-children.js +26 -26
  25. package/src/checks/automatic/aria-required-attr.js +14 -17
  26. package/src/checks/automatic/aria-required-children.js +17 -20
  27. package/src/checks/automatic/aria-required-parent.js +17 -20
  28. package/src/checks/automatic/aria-roles-valid.js +66 -27
  29. package/src/checks/automatic/aria-valid-attr-value.js +18 -21
  30. package/src/checks/automatic/aria-valid-attr.js +14 -17
  31. package/src/checks/automatic/autocomplete-valid.js +52 -18
  32. package/src/checks/automatic/avoid-inline-spacing.js +192 -34
  33. package/src/checks/automatic/binary-control-name-present.js +30 -24
  34. package/src/checks/automatic/button-name-present.js +73 -45
  35. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  36. package/src/checks/automatic/combobox-name-present.js +23 -18
  37. package/src/checks/automatic/css-orientation-lock.js +22 -22
  38. package/src/checks/automatic/definition-list-children-valid.js +18 -21
  39. package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
  40. package/src/checks/automatic/dialog-name-present.js +24 -19
  41. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  42. package/src/checks/automatic/duplicate-id-aria.js +45 -37
  43. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  44. package/src/checks/automatic/form-control-single-label.js +109 -43
  45. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  46. package/src/checks/automatic/iframe-focusable-content.js +29 -31
  47. package/src/checks/automatic/iframe-name-present.js +15 -17
  48. package/src/checks/automatic/iframe-title-unique.js +18 -23
  49. package/src/checks/automatic/img-alt-present.js +16 -9
  50. package/src/checks/automatic/input-image-alt-present.js +99 -48
  51. package/src/checks/automatic/label-in-name.js +102 -33
  52. package/src/checks/automatic/language-page-present.js +5 -1
  53. package/src/checks/automatic/link-in-text-block.js +19 -21
  54. package/src/checks/automatic/link-name-present.js +75 -47
  55. package/src/checks/automatic/list-children-valid.js +15 -17
  56. package/src/checks/automatic/listbox-name-present.js +23 -18
  57. package/src/checks/automatic/listitem-parent-valid.js +14 -17
  58. package/src/checks/automatic/menuitem-name-present.js +24 -19
  59. package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
  60. package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
  61. package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
  62. package/src/checks/automatic/meter-name-present.js +24 -19
  63. package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
  64. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  65. package/src/checks/automatic/option-name-present.js +24 -19
  66. package/src/checks/automatic/progressbar-name-present.js +27 -22
  67. package/src/checks/automatic/searchbox-name-present.js +27 -18
  68. package/src/checks/automatic/server-side-image-map-absent.js +15 -18
  69. package/src/checks/automatic/slider-name-present.js +26 -19
  70. package/src/checks/automatic/spinbutton-name-present.js +27 -18
  71. package/src/checks/automatic/summary-name-present.js +24 -19
  72. package/src/checks/automatic/tab-name-present.js +24 -19
  73. package/src/checks/automatic/table-headers-attr-valid.js +15 -17
  74. package/src/checks/automatic/table-th-has-data-cells.js +82 -25
  75. package/src/checks/automatic/target-size-minimum.js +104 -81
  76. package/src/checks/automatic/td-has-header.js +15 -20
  77. package/src/checks/automatic/textbox-name-present.js +23 -18
  78. package/src/checks/automatic/tooltip-name-present.js +24 -19
  79. package/src/checks/automatic/treeitem-name-present.js +24 -19
  80. package/src/checks/automatic/valid-lang.js +31 -20
  81. package/src/checks/manual/accesskeys-manual.js +18 -19
  82. package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
  83. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
  84. package/src/checks/manual/empty-heading-manual.js +15 -17
  85. package/src/checks/manual/empty-table-header-manual.js +27 -30
  86. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
  87. package/src/checks/manual/heading-order-manual.js +17 -22
  88. package/src/checks/manual/image-redundant-alt-manual.js +14 -17
  89. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  90. package/src/checks/manual/label-title-only-manual.js +15 -17
  91. package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
  92. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
  93. package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
  94. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
  95. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
  96. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
  97. package/src/checks/manual/landmark-one-main-manual.js +2 -9
  98. package/src/checks/manual/landmark-unique-manual.js +22 -27
  99. package/src/checks/manual/link-name-quality-manual.js +15 -17
  100. package/src/checks/manual/meta-viewport-large-manual.js +14 -17
  101. package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
  102. package/src/checks/manual/page-has-heading-one-manual.js +2 -9
  103. package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
  104. package/src/checks/manual/region-manual.js +13 -6
  105. package/src/checks/manual/scope-attr-valid-manual.js +14 -17
  106. package/src/checks/manual/skip-link-manual.js +39 -47
  107. package/src/checks/manual/tabindex-manual.js +14 -17
  108. package/src/checks/manual/table-duplicate-name-manual.js +14 -17
  109. package/src/core.js +8818 -4330
  110. package/src/report.js +14 -0
  111. package/src/sarif.js +2 -2
  112. package/surea11y.browser.js +4023 -3911
  113. package/surea11y.i18n.de.js +22 -0
  114. package/surea11y.i18n.es.js +22 -0
  115. package/surea11y.i18n.fr.js +22 -0
@@ -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
 
@@ -9,10 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @applicability
12
- * Applies to <img> elements that are exposed to assistive technologies.
13
- * Images with role="presentation" or role="none" are excluded only when they are not focusable.
14
- * Elements otherwise hidden from the accessibility tree remain applicable
15
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <img> elements included in the accessibility tree.
13
+ * Images with role="presentation" or role="none" are excluded only when they are not focusable,
14
+ * since a focusable one reverts to the img role under presentational roles conflict resolution.
15
+ * Hidden images are excluded whether or not they are focusable.
16
16
  * @expectation
17
17
  * Each applicable <img> element has an alt attribute.
18
18
  * The alt attribute may be empty (alt="").
@@ -60,8 +60,15 @@ function runInPage(ctx) {
60
60
  const getEligibilityInfo =
61
61
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
62
62
 
63
- const isAccTreeEligible =
64
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
63
+ // ACT 23a2a8 exempts programmatically hidden images, and that glossary term
64
+ // has no focusability carve-out, so an aria-hidden image stays out of scope
65
+ // even when tabbable; aria-hidden-focus reports that markup instead.
66
+ const isEligibleHelper =
67
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
68
+ ? helpers.isIncludedInAccessibilityTree
69
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
70
+ ? helpers.isAccTreeEligible
71
+ : null;
65
72
  const getFocusableInfo =
66
73
  helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
67
74
  const getAriaNameInfo =
@@ -113,11 +120,11 @@ function runInPage(ctx) {
113
120
  const el = imgs[i];
114
121
  if (!el || !el.getAttribute) continue;
115
122
 
116
- // Eligibility: only imgs exposed to assistive tech (with focusable/IDREF exceptions handled by helper)
117
- if (isAccTreeEligible) {
123
+ // Eligibility: only imgs exposed to assistive tech.
124
+ if (isEligibleHelper) {
118
125
  let elig;
119
126
  try {
120
- elig = isAccTreeEligible(el, ctx);
127
+ elig = isEligibleHelper(el, ctx);
121
128
  } catch {
122
129
  elig = null;
123
130
  }
@@ -9,11 +9,10 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @applicability
12
- * Applies to <input type="image"> elements that are exposed to assistive technologies.
13
- * Elements otherwise hidden from the accessibility tree remain applicable
14
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
12
+ * Applies to <input type="image"> elements included in the accessibility tree.
15
13
  * @expectation
16
- * Each applicable <input type="image"> element has an alt attribute.
14
+ * Each applicable <input type="image"> element has an alt attribute, and its
15
+ * accessible name is not the browser default for an image button.
17
16
  * The alt attribute may be empty (alt="").
18
17
  */
19
18
 
@@ -72,8 +71,14 @@ function runInPage(ctx) {
72
71
  const getEligibilityInfo =
73
72
  helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
74
73
 
75
- const isAccTreeEligible =
76
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
74
+ // ACT 59796f applies to image buttons included in the accessibility tree,
75
+ // which excludes focusable content inside aria-hidden.
76
+ const isEligibleHelper =
77
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
78
+ ? helpers.isIncludedInAccessibilityTree
79
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
80
+ ? helpers.isAccTreeEligible
81
+ : null;
77
82
 
78
83
  const getAriaNameInfo =
79
84
  helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
@@ -99,63 +104,109 @@ function runInPage(ctx) {
99
104
  for (const el of inputs) {
100
105
  if (!el || !el.getAttribute) continue;
101
106
 
102
- // Applicability: eligible in the acc tree (with helper exceptions).
103
- if (isAccTreeEligible) {
107
+ if (isEligibleHelper) {
104
108
  const elig = (() => {
105
109
  try {
106
- return isAccTreeEligible(el, ctx);
110
+ return isEligibleHelper(el, ctx);
107
111
  } catch {
108
- return { eligible: true, reasons: [] };
112
+ return true;
109
113
  }
110
114
  })();
111
- if (elig && elig.eligible === false) continue;
115
+ const eligible = typeof elig === 'boolean' ? elig : !(elig && elig.eligible === false);
116
+ if (!eligible) continue;
112
117
  }
113
118
 
114
119
  applicableCount += 1;
115
120
 
116
- const hasAlt = el.getAttribute('alt') !== null;
117
- if (hasAlt) continue;
118
-
119
- // aria-label / aria-labelledby is also a valid, standards-recognized
120
- // text-alternative mechanism for <input type="image"> (HTML-AAM
121
- // accessible name computation includes ARIA naming before falling
122
- // back to alt).
123
- if (getAriaNameInfo) {
124
- let ariaName;
125
- try {
126
- ariaName = getAriaNameInfo(el, ctx);
127
- } catch {
128
- ariaName = null;
121
+ // The browser's own fallback name for an image button carries no
122
+ // information, so an author-supplied name equal to it is treated as no
123
+ // name at all (ACT 59796f). Only the English defaults are recognised:
124
+ // "Submit Query" from HTML-AAM, "Submit" from Chrome.
125
+ const effectiveName = (() => {
126
+ let v = '';
127
+ if (getAriaNameInfo) {
128
+ try {
129
+ const aria = getAriaNameInfo(el, ctx);
130
+ if (aria && aria.present && aria.value) v = String(aria.value);
131
+ } catch {
132
+ v = '';
133
+ }
129
134
  }
130
- if (ariaName && ariaName.present) continue;
131
- }
132
-
133
- // A non-empty title attribute is HTML-AAM's own next fallback naming
134
- // source once alt is entirely absent. Same gap img-alt-present handles
135
- // for <img title="..."> with no alt.
136
- const titleRaw = (() => {
137
- try {
138
- return el.getAttribute('title');
139
- } catch {
140
- return null;
135
+ if (!v) {
136
+ const alt = el.getAttribute('alt');
137
+ if (alt != null && String(alt).trim()) v = String(alt);
141
138
  }
139
+ if (!v) {
140
+ const t = el.getAttribute('title');
141
+ if (t != null && String(t).trim()) v = String(t);
142
+ }
143
+ return v.trim().toLowerCase();
142
144
  })();
143
- if (titleRaw !== null && String(titleRaw).trim()) continue;
145
+
146
+ if (effectiveName === 'submit query' || effectiveName === 'submit') {
147
+ const eligInfoDefault = getEligibilityInfo
148
+ ? getEligibilityInfo(el, ctx, { targetSet: 'acc' })
149
+ : null;
150
+ const defaultNameOccurrence = {
151
+ summary:
152
+ 'Accessible name is the browser default for an image button, which conveys nothing.',
153
+ hint: 'Replace it with text describing what the button does, for example "Search".',
154
+ i18n: {
155
+ summaryKey: 'inputImage_altPresent_summary_defaultName',
156
+ hintKey: 'inputImage_altPresent_hint_defaultName',
157
+ params: { element: 'input[type=image]' }
158
+ },
159
+ data: {
160
+ visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
161
+ details: { reasonCode: 'default_name' }
162
+ }
163
+ };
164
+ occurrences.push(
165
+ helpers && typeof helpers.reportOccurrence === 'function'
166
+ ? helpers.reportOccurrence(el, defaultNameOccurrence)
167
+ : { selector: '', html: '', ...defaultNameOccurrence }
168
+ );
169
+ continue;
170
+ }
171
+
172
+ // effectiveName already covers the naming sources HTML-AAM allows here,
173
+ // in order: aria-label/aria-labelledby, then alt, then title.
174
+ if (effectiveName) continue;
175
+
176
+ // An image button is a control, so an empty name fails whether alt is
177
+ // absent or present-but-empty. alt="" marks a decorative image, and an
178
+ // image button is never decorative.
179
+ const emptyAlt = el.getAttribute('alt') !== null;
144
180
 
145
181
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
146
182
 
147
- const baseOccurrence = {
148
- summary: 'Missing alt attribute on <input type="image">.',
149
- hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
150
- i18n: {
151
- summaryKey: 'inputImage_altPresent_summary_fail',
152
- hintKey: 'inputImage_altPresent_hint_fail',
153
- params: { element: 'input[type=image]' }
154
- },
155
- data: {
156
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
157
- }
158
- };
183
+ const baseOccurrence = emptyAlt
184
+ ? {
185
+ summary: 'Empty alt="" on <input type="image"> leaves the control unnamed.',
186
+ hint: 'Describe the action in alt, or name the control with aria-label or aria-labelledby.',
187
+ i18n: {
188
+ summaryKey: 'inputImage_altPresent_summary_emptyAlt',
189
+ hintKey: 'inputImage_altPresent_hint_emptyAlt',
190
+ params: { element: 'input[type=image]' }
191
+ },
192
+ data: {
193
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
194
+ details: { reasonCode: 'empty_alt' }
195
+ }
196
+ }
197
+ : {
198
+ summary: 'Missing alt attribute on <input type="image">.',
199
+ hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
200
+ i18n: {
201
+ summaryKey: 'inputImage_altPresent_summary_fail',
202
+ hintKey: 'inputImage_altPresent_hint_fail',
203
+ params: { element: 'input[type=image]' }
204
+ },
205
+ data: {
206
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
207
+ details: { reasonCode: 'missing_alt' }
208
+ }
209
+ };
159
210
 
160
211
  if (helpers && typeof helpers.reportOccurrence === 'function') {
161
212
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -52,6 +52,56 @@ function runInPage(ctx) {
52
52
  return v.replace(/\s+/g, ' ').trim().toLowerCase();
53
53
  }
54
54
 
55
+ // WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
56
+ // parenthesised text, case-fold and NFKD-normalise, then reduce every
57
+ // non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
58
+ // splitting on them, which distinguishes a real mismatch from one that is
59
+ // only a hyphenation difference.
60
+ function tokenize(s, hyphensJoin) {
61
+ let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
62
+ try {
63
+ v = v.normalize('NFKD');
64
+ } catch {
65
+ // Realm without String#normalize: the word comparison below still holds.
66
+ }
67
+ if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
68
+ return v
69
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
70
+ .split(' ')
71
+ .filter(Boolean);
72
+ }
73
+
74
+ // The label's words must appear adjacent and in order inside the name, so a
75
+ // scattered subsequence does not count. Words in `prefixable` may match a
76
+ // longer name word they start.
77
+ function containsWordRun(needle, hay, prefixable) {
78
+ if (!needle.length) return true;
79
+ for (let i = 0; i + needle.length <= hay.length; i++) {
80
+ let ok = true;
81
+ for (let j = 0; j < needle.length; j++) {
82
+ const want = needle[j];
83
+ const got = hay[i + j];
84
+ if (got === want) continue;
85
+ if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
86
+ ok = false;
87
+ break;
88
+ }
89
+ if (ok) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ // A trailing period marks a word the author may have shortened ("Ave." for
95
+ // "Avenue"). Tokenizing removes the period, so collect these beforehand.
96
+ function abbreviatedWords(s) {
97
+ const out = new Set();
98
+ for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
99
+ const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
100
+ if (m) out.add(m[1].toLowerCase());
101
+ }
102
+ return out;
103
+ }
104
+
55
105
  function getElementDescriptor(el) {
56
106
  const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
57
107
  let role;
@@ -297,52 +347,71 @@ function runInPage(ctx) {
297
347
  const accName = acc && acc.value != null ? String(acc.value) : '';
298
348
  const accNorm = norm(accName);
299
349
 
300
- const contains = !!(accNorm && visibleNorm && accNorm.indexOf(visibleNorm) !== -1);
350
+ const labelTokens = tokenize(visibleLabel, false);
351
+ const nameTokens = tokenize(accName, false);
352
+ const contains = containsWordRun(labelTokens, nameTokens, null);
301
353
 
354
+ // An abbreviation, or a word hyphenated differently in the two places, is
355
+ // not something markup settles: the author may have meant either. Report
356
+ // without asserting a defect instead of failing or staying silent.
357
+ let uncertainty = '';
302
358
  if (!contains) {
303
- const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
304
- const html = helpers.getOuterHtmlSnippet
305
- ? helpers.getOuterHtmlSnippet(el)
306
- : el.outerHTML || '';
307
-
308
- occurrences.push({
309
- selector: selectorOut,
310
- html,
311
- summary: 'Accessible name does not contain the visible label text.',
312
- hint: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
313
- i18n: {
314
- summaryKey: 'labelInName_summary_fail',
315
- hintKey: 'labelInName_hint_fail',
316
- params: {
317
- element: getElementDescriptor(el),
318
- visibleLabel: clipForSummary(visibleLabel),
319
- labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
320
- nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
321
- }
322
- },
323
- data: {
324
- details: {
325
- reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
326
- visibleLabel,
327
- accessibleName: accName,
328
- normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
329
- labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
330
- nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
359
+ if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
360
+ uncertainty = 'HYPHENATION_DIFFERS';
361
+ } else {
362
+ const abbreviated = abbreviatedWords(visibleLabel);
363
+ if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
364
+ uncertainty = 'POSSIBLE_ABBREVIATION';
365
+ }
366
+ }
367
+
368
+ if (!contains) {
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
+ }
331
398
  }
332
- }
333
- });
399
+ })
400
+ );
334
401
  }
335
402
  }
336
403
 
337
404
  if (applicableCount === 0)
338
405
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
339
- if (occurrences.length)
406
+ if (occurrences.length) {
407
+ const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
340
408
  return {
341
409
  ruleId: rule.ruleId,
342
- outcome: 'fail',
410
+ outcome: anyFail ? 'fail' : 'cantTell',
343
411
  severity: rule.defaultSeverity || 'minor',
344
412
  occurrences
345
413
  };
414
+ }
346
415
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
347
416
  }
348
417
 
@@ -144,7 +144,11 @@ function runInPage(ctx) {
144
144
  }
145
145
 
146
146
  // Minimal BCP47 primary subtag check
147
- if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)) {
147
+ const isValidTag =
148
+ helpers && typeof helpers.isValidLanguageTag === 'function'
149
+ ? helpers.isValidLanguageTag
150
+ : (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
151
+ if (!isValidTag(lang)) {
148
152
  return {
149
153
  ruleId: rule.ruleId,
150
154
  outcome: 'fail',