@surea11y/core 1.4.1 → 1.6.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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -11,7 +11,7 @@
11
11
  * @applicability
12
12
  * Applies to any element whose role attribute's first (used) token is a
13
13
  * valid, non-abstract ARIA role that authors should not explicitly
14
- * declare — either because WAI-ARIA has deprecated it (e.g. "directory",
14
+ * declare, either because WAI-ARIA has deprecated it (e.g. "directory",
15
15
  * superseded by role="list") or because it is reserved for user-agent-
16
16
  * internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
17
17
  * NOT use in content).
@@ -81,8 +81,20 @@ function runInPage(ctx) {
81
81
  if (typeof helpers.isDomVisibleEligible === 'function') {
82
82
  if (!helpers.isDomVisibleEligible(el, ctx)) return true;
83
83
  }
84
- for (let n = el; n && n.getAttribute; n = n.parentElement) {
84
+ // Walk the composed tree, not parentElement: that stops at a shadow
85
+ // root, so a host carrying aria-hidden or inert would never be seen
86
+ // from inside its own shadow content.
87
+ const up =
88
+ typeof helpers.composedParent === 'function'
89
+ ? helpers.composedParent
90
+ : (n) => n.parentElement;
91
+
92
+ // A shadow root has no getAttribute, so skip past it rather than
93
+ // stopping: the host one step further up is the node that matters.
94
+ for (let n = el; n; n = up(n)) {
95
+ if (!n.getAttribute) continue;
85
96
  if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
97
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
86
98
  }
87
99
  } catch {
88
100
  return false;
@@ -113,63 +125,64 @@ function runInPage(ctx) {
113
125
  ariaHelpers.isAuthorProhibitedRole(role);
114
126
  if (!deprecated && !discouraged && !prohibited) continue;
115
127
 
116
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
117
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
118
128
  const guidance = ariaHelpers.getDeprecatedRoleGuidance
119
129
  ? ariaHelpers.getDeprecatedRoleGuidance(role)
120
- : 'Replace the deprecated role with its recommended replacement.';
130
+ : {
131
+ key: 'ariaDeprecatedRole_guidance_default',
132
+ text: 'Replace the deprecated role with its recommended replacement.'
133
+ };
121
134
 
122
135
  if (prohibited) {
123
136
  // Author MUST NOT: the usage is non-conforming, not merely discouraged.
124
- failOccurrences.push({
125
- selector: stableSelector,
126
- html,
127
- summary: `This element uses role="${role}", which authors must not explicitly declare.`,
128
- hint: guidance,
129
- occurrenceOutcome: 'fail',
130
- i18n: {
131
- summaryKey: 'ariaDeprecatedRole_summary_fail',
132
- hintKey: 'ariaDeprecatedRole_hint_fail',
133
- params: { role, guidance }
134
- },
135
- data: {
136
- details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance }
137
- }
138
- });
137
+ failOccurrences.push(
138
+ helpers.reportOccurrence(el, {
139
+ summary: `This element uses role="${role}", which authors must not explicitly declare.`,
140
+ hint: guidance.text,
141
+ occurrenceOutcome: 'fail',
142
+ i18n: {
143
+ summaryKey: 'ariaDeprecatedRole_summary_fail',
144
+ hintKey: guidance.key,
145
+ params: { role }
146
+ },
147
+ data: {
148
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance: guidance.text }
149
+ }
150
+ })
151
+ );
139
152
  } else if (discouraged) {
140
153
  // Reserved for user-agent-internal use, at SHOULD NOT strength.
141
- cantTellOccurrences.push({
142
- selector: stableSelector,
143
- html,
144
- summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
145
- hint: guidance,
146
- occurrenceOutcome: 'cantTell',
147
- i18n: {
148
- summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
149
- hintKey: 'ariaDeprecatedRole_hint_cantTell',
150
- params: { role, guidance }
151
- },
152
- data: {
153
- details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance }
154
- }
155
- });
154
+ cantTellOccurrences.push(
155
+ helpers.reportOccurrence(el, {
156
+ summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
157
+ hint: guidance.text,
158
+ occurrenceOutcome: 'cantTell',
159
+ i18n: {
160
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
161
+ hintKey: guidance.key,
162
+ params: { role }
163
+ },
164
+ data: {
165
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
166
+ }
167
+ })
168
+ );
156
169
  } else {
157
170
  // Deprecated but still valid: surfaced for the author to decide.
158
- cantTellOccurrences.push({
159
- selector: stableSelector,
160
- html,
161
- summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
162
- hint: guidance,
163
- occurrenceOutcome: 'cantTell',
164
- i18n: {
165
- summaryKey: 'ariaDeprecatedRole_summary_cantTell',
166
- hintKey: 'ariaDeprecatedRole_hint_cantTell',
167
- params: { role, guidance }
168
- },
169
- data: {
170
- details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
171
- }
172
- });
171
+ cantTellOccurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
174
+ hint: guidance.text,
175
+ occurrenceOutcome: 'cantTell',
176
+ i18n: {
177
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell',
178
+ hintKey: guidance.key,
179
+ params: { role }
180
+ },
181
+ data: {
182
+ details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
183
+ }
184
+ })
185
+ );
173
186
  }
174
187
  }
175
188
 
@@ -10,13 +10,13 @@
10
10
  * @sc 1.3.1, 4.1.2
11
11
  * @applicability
12
12
  * Always applicable to any HTML document with a <body> element,
13
- * independent of contextSelector/root scoping — this is a whole-
13
+ * independent of contextSelector/root scoping, this is a whole-
14
14
  * document concern, matching page-title-present's pattern of
15
15
  * evaluating document.body directly rather than the scoped root.
16
16
  * @expectation
17
17
  * <body> does not have aria-hidden="true". Hiding the document body
18
18
  * removes the entire page's content and structure from the
19
- * accessibility tree at once — both 1.3.1 (Info and Relationships: the
19
+ * accessibility tree at once, both 1.3.1 (Info and Relationships: the
20
20
  * page's structure becomes entirely non-determinable) and 4.1.2 (Name,
21
21
  * Role, Value: nothing in the document exposes a role/name/value any
22
22
  * longer) apply.
@@ -86,15 +86,8 @@ function runInPage(ctx) {
86
86
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
87
87
  }
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
90
- const html = helpers.getOuterHtmlSnippet
91
- ? helpers.getOuterHtmlSnippet(body)
92
- : (body.outerHTML || '').slice(0, 200);
93
-
94
89
  const occurrences = [
95
- {
96
- selector: stableSelector,
97
- html,
90
+ helpers.reportOccurrence(body, {
98
91
  summary:
99
92
  'The document body has aria-hidden="true", which hides the entire page from assistive technologies.',
100
93
  hint: 'Remove aria-hidden from <body>. Hide specific elements instead, if that was the intent.',
@@ -106,7 +99,7 @@ function runInPage(ctx) {
106
99
  data: {
107
100
  details: { reasonCode: 'ARIA_HIDDEN_BODY' }
108
101
  }
109
- }
102
+ })
110
103
  ];
111
104
 
112
105
  return {
@@ -109,7 +109,7 @@ function runInPage(ctx) {
109
109
  }
110
110
 
111
111
  // Flat-tree ancestor walk (assignedSlot wins over parentNode, then shadow
112
- // host) — shared with every other rule via ctx.helpers.composedParent
112
+ // host), shared with every other rule via ctx.helpers.composedParent
113
113
  // (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
114
114
  // canonical definition can't drift out of sync with this rule's copy.
115
115
  const composedParent =
@@ -146,6 +146,54 @@ function runInPage(ctx) {
146
146
  return false;
147
147
  }
148
148
 
149
+ // An open modal is expected to trap focus, so a focusable background behind
150
+ // it may be unreachable; that can't be proven statically, so the finding is
151
+ // downgraded to cantTell instead of fail. A modal that is display:none is
152
+ // not open, so it does not count.
153
+ function isRenderedForModal(node) {
154
+ if (!node) return false;
155
+ if (!isDomVisibleEligible) return true;
156
+ try {
157
+ const vis = isDomVisibleEligible(node, ctx, {
158
+ visibilityMode: 'styleOnly',
159
+ disableGeometry: true
160
+ });
161
+ if (vis && vis.eligible === false) return false;
162
+ } catch {
163
+ // treat as rendered
164
+ }
165
+ return true;
166
+ }
167
+
168
+ // Native <dialog>, aria-modal="true", or a dialog/alertdialog role, so
169
+ // libraries that leave aria-modal off (e.g. Angular Material defaults to
170
+ // aria-modal="false") still count as an open modal.
171
+ function collectOpenModalCandidates() {
172
+ const nodes = qAll('dialog[open],[aria-modal="true"],[role="dialog"],[role="alertdialog"]');
173
+ const out = [];
174
+ for (let i = 0; i < nodes.length; i++) {
175
+ const n = nodes[i];
176
+ if (!n || !n.getAttribute) continue;
177
+ if (!isRenderedForModal(n)) continue;
178
+ out.push(n);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ // Only a modal in a separate subtree is the "hidden background behind an
184
+ // open dialog" case. A modal inside the aria-hidden subtree (or an
185
+ // aria-hidden root inside the modal) is a genuine defect, so keep it a fail.
186
+ function hasSeparateOpenModal(rootEl, candidates) {
187
+ for (let i = 0; i < candidates.length; i++) {
188
+ const m = candidates[i];
189
+ if (!m || m === rootEl) continue;
190
+ if (isWithinComposedSubtree(m, rootEl)) continue;
191
+ if (isWithinComposedSubtree(rootEl, m)) continue;
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
149
197
  function getDeepActiveElement() {
150
198
  let cur = document && document.activeElement ? document.activeElement : null;
151
199
  let guard = 0;
@@ -485,8 +533,8 @@ function runInPage(ctx) {
485
533
  if (isDisabledFormControl(el)) return false;
486
534
 
487
535
  // An explicit negative tabindex removes the element from the keyboard
488
- // tab sequence entirely, regardless of tag — the standard, WAI-
489
- // recommended technique for safely hiding focusable content behind
536
+ // tab sequence entirely, regardless of tag. It's the standard,
537
+ // WAI-recommended technique for safely hiding focusable content behind
490
538
  // aria-hidden (e.g. <button tabindex="-1"> / <a tabindex="-1"> inside
491
539
  // an aria-hidden container). This cares about tabbability, not raw
492
540
  // focusability. Such an element is still programmatically focusable
@@ -636,7 +684,7 @@ function runInPage(ctx) {
636
684
  // Cheap check first: a plain ancestor-attribute walk with no CSS
637
685
  // computation, vs. isActuallyFocusable's getComputedStyle-per-ancestor
638
686
  // cost. Both conditions are required (AND), so checking whichever is
639
- // cheaper first cannot change which elements end up in the bucket —
687
+ // cheaper first cannot change which elements end up in the bucket,
640
688
  // it only skips the expensive check for the (typically vast) majority
641
689
  // of focusable candidates that were never inside an aria-hidden root
642
690
  // in the first place. On pages with many focusable candidates and a
@@ -713,6 +761,8 @@ function runInPage(ctx) {
713
761
  entry.rootIsFocusable = isActuallyFocusable(el);
714
762
  }
715
763
 
764
+ const modalCandidates = collectOpenModalCandidates();
765
+
716
766
  // 3) Report one occurrence per aria-hidden root that contains focusable content.
717
767
  const failOccurrences = [];
718
768
  const uncertainOccurrences = [];
@@ -765,24 +815,56 @@ function runInPage(ctx) {
765
815
  : `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`
766
816
  : `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
767
817
 
768
- const runtimeProbe = probeImmediateFocusRedirect(entry);
769
- const downgradedToCantTell = !!(runtimeProbe && runtimeProbe.redirected);
770
-
771
- const cantTellSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
818
+ // A modal takes precedence over the redirect probe; skip the probe when a
819
+ // modal already explains the background.
820
+ const modalOpenOutside = modalCandidates.length
821
+ ? hasSeparateOpenModal(el, modalCandidates)
822
+ : false;
823
+
824
+ const runtimeProbe = modalOpenOutside ? null : probeImmediateFocusRedirect(entry);
825
+ const downgradedByRedirect = !!(runtimeProbe && runtimeProbe.redirected);
826
+ const downgradedByModal = modalOpenOutside;
827
+ const downgradedToCantTell = downgradedByModal || downgradedByRedirect;
828
+
829
+ const cantTellRedirectSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
830
+ const cantTellModalSummary = `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s) while a modal dialog is open. If the modal keeps keyboard focus trapped they may be unreachable; verify focus cannot land on them.`;
831
+
832
+ let occSummary;
833
+ let occHint;
834
+ let occSummaryKey;
835
+ let occHintKey;
836
+ let occReasonCode;
837
+
838
+ if (downgradedByModal) {
839
+ occSummary = cantTellModalSummary;
840
+ occHint =
841
+ 'A modal dialog appears to be open. Prefer making the background inert (or a native <dialog> opened with showModal()) so it leaves the tab order, then verify keyboard focus stays within the dialog.';
842
+ occSummaryKey = 'ariaHidden_focus_summary_cantTell_modal';
843
+ occHintKey = 'ariaHidden_focus_hint_cantTell_modal';
844
+ occReasonCode = 'ariaHiddenFocusable_modalOpen_needsReview';
845
+ } else if (downgradedByRedirect) {
846
+ occSummary = cantTellRedirectSummary;
847
+ occHint =
848
+ 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.';
849
+ occSummaryKey = 'ariaHidden_focus_summary_cantTell_redirect';
850
+ occHintKey = 'ariaHidden_focus_hint_cantTell_redirect';
851
+ occReasonCode = 'ariaHiddenFocusable_runtimeRedirect_needsReview';
852
+ } else {
853
+ occSummary = summaryText;
854
+ occHint =
855
+ 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.';
856
+ occSummaryKey = summaryKey;
857
+ occHintKey = 'ariaHidden_focus_hint_fail';
858
+ occReasonCode = reasonCode;
859
+ }
772
860
 
773
861
  const baseOccurrence = {
774
- summary: downgradedToCantTell ? cantTellSummary : summaryText,
775
- hint: downgradedToCantTell
776
- ? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
777
- : 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
862
+ summary: occSummary,
863
+ hint: occHint,
778
864
  occurrenceOutcome: downgradedToCantTell ? 'cantTell' : 'fail',
779
865
  i18n: {
780
- summaryKey: downgradedToCantTell
781
- ? 'ariaHidden_focus_summary_cantTell_redirect'
782
- : summaryKey,
783
- hintKey: downgradedToCantTell
784
- ? 'ariaHidden_focus_hint_cantTell_redirect'
785
- : 'ariaHidden_focus_hint_fail',
866
+ summaryKey: occSummaryKey,
867
+ hintKey: occHintKey,
786
868
  params: {
787
869
  element: tagName,
788
870
  focusableCount: String(totalFocusable),
@@ -795,15 +877,14 @@ function runInPage(ctx) {
795
877
  },
796
878
  data: {
797
879
  details: {
798
- reasonCode: downgradedToCantTell
799
- ? 'ariaHiddenFocusable_runtimeRedirect_needsReview'
800
- : reasonCode,
880
+ reasonCode: occReasonCode,
801
881
  metrics: {
802
882
  focusableTotal: totalFocusable,
803
883
  focusableDescendants: descendantFocusable,
804
884
  rootIsFocusable: selfFocusable,
805
885
  offendersCaptured: entry.offenders.length,
806
- visibilityHints: hintsArr.slice(0)
886
+ visibilityHints: hintsArr.slice(0),
887
+ modalOpen: !!downgradedByModal
807
888
  },
808
889
  runtimeProbe: runtimeProbe || null,
809
890
  offenders: entry.offenders.slice(0)
@@ -822,7 +903,7 @@ function runInPage(ctx) {
822
903
 
823
904
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
824
905
  // a fail-tier finding never silently discards cantTell-tier findings from
825
- // the same run — both are returned together when the outcome is 'fail'.
906
+ // the same run, both are returned together when the outcome is 'fail'.
826
907
  const resolved = helpers.resolveTieredOutcome(
827
908
  failOccurrences,
828
909
  uncertainOccurrences,
@@ -14,20 +14,20 @@
14
14
  * list for naming attributes (pure text-semantics / non-naming
15
15
  * structural roles: caption, code, deletion, emphasis, generic,
16
16
  * insertion, mark, none, paragraph, presentation, strong, subscript,
17
- * suggestion, superscript, time), and (b) elements with no role at all —
17
+ * suggestion, superscript, time), and (b) elements with no role at all:
18
18
  * a curated set of native HTML tags verified to carry no implicit role
19
19
  * (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
20
20
  * hyphenated, author-defined tag per the Custom Elements spec; see
21
- * isRolelessCustomElementTag below) — in both cases, only elements that
21
+ * isRolelessCustomElementTag below). In both cases, only elements that
22
22
  * also carry aria-label or aria-labelledby.
23
23
  * @expectation
24
24
  * Prohibited attributes must not be present on (a); for (b), the naming
25
25
  * attribute is at best unreliable (nothing accessible-name-aware to hang
26
- * it off) and at worst silently ignored by assistive technology — see the
26
+ * it off) and at worst silently ignored by assistive technology. See the
27
27
  * roleless-branch implementation note below for the confidence split
28
28
  * this produces.
29
29
  * @implementation-notes
30
- * - Deliberately scoped to the single, well-established prohibition class
30
+ * - Scoped to the single, well-established prohibition class
31
31
  * (naming attributes on pure text-semantics roles) rather than an
32
32
  * exhaustive per-role prohibited-attribute table; see
33
33
  * src/core/aria-helpers.js file header for this engine's confidence-
@@ -41,7 +41,7 @@
41
41
  * narrower, unambiguous naming-prohibition case fires as a hard,
42
42
  * WCAG-normative fail instead, matching this engine's "one rule = one
43
43
  * normative decision" pattern.
44
- * - Deliberately excludes `definition`/`term` despite both appearing on
44
+ * - Excludes `definition`/`term` on purpose, despite both appearing on
45
45
  * MDN's aria-label "not supported" list: both support name from author
46
46
  * (`nameFrom: ['author']` for definition, `['author', 'contents']` for
47
47
  * term), and the W3C spec's §5.2.8.4 "Roles Supporting Name From Author"
@@ -50,9 +50,9 @@
50
50
  * property, while engine-level hidden-subtree filtering still applies
51
51
  * unless engineOptions.includeHiddenElements is true.
52
52
  * - Second, independent branch: naming attributes on ROLELESS elements (no
53
- * explicit role="", no implicit/native role either) — e.g. icon-only
53
+ * explicit role="", no implicit/native role either), e.g. icon-only
54
54
  * `<span aria-label="...">` tiles with no other accessible-name source.
55
- * ROLELESS_NATIVE_TAGS below is a curated, deliberately conservative
55
+ * ROLELESS_NATIVE_TAGS below is a curated, intentionally conservative
56
56
  * list of native tags confirmed to carry no implicit role (common
57
57
  * text-level tags like `<p>`/`<strong>`/`<em>`/`<code>`/`<mark>`/`<time>`
58
58
  * resolve to role `null`, same as a bare `<div>`/`<span>`);
@@ -65,7 +65,7 @@
65
65
  * already produces a non-empty accessible name from its content (via
66
66
  * `helpers.getContentNameInfo`, same as
67
67
  * link-name-present/button-name-present), the naming attribute might
68
- * just be a redundant/intentional override — reported as `cantTell`, not
68
+ * just be a redundant/intentional override, so it's reported as `cantTell`, not
69
69
  * a hard fail. Only a roleless element with no other accessible-name
70
70
  * source at all is a confident, deterministic `fail`. The
71
71
  * widget-ancestor exemption (skip when the closest real ancestor role is
@@ -119,8 +119,8 @@ function runInPage(ctx) {
119
119
  // Roles whose WAI-ARIA 1.2 definition lists a "Prohibited ARIA States and
120
120
  // Properties" entry for naming attributes (these roles must never carry an
121
121
  // accessible name). Declared inside runInPage (rather than at module
122
- // scope) because the build inlines only this function's own source text
123
- // — see scripts/build-core.js header ("runInPage MUST be self-contained").
122
+ // scope) because the build inlines only this function's own source text.
123
+ // See scripts/build-core.js header ("runInPage MUST be self-contained").
124
124
  const ROLES_PROHIBITING_NAME = new Set([
125
125
  'caption',
126
126
  'code',
@@ -167,25 +167,22 @@ function runInPage(ctx) {
167
167
 
168
168
  if (!present.length) continue;
169
169
 
170
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
171
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
172
-
173
170
  for (const attr of present) {
174
- failOccurrences.push({
175
- selector: stableSelector,
176
- html,
177
- occurrenceOutcome: 'fail',
178
- summary: 'This attribute is prohibited on this element’s role.',
179
- hint: 'Remove this attribute; this role must not carry an accessible name.',
180
- i18n: {
181
- summaryKey: 'ariaProhibitedAttr_summary_fail',
182
- hintKey: 'ariaProhibitedAttr_hint_fail',
183
- params: { attr, role }
184
- },
185
- data: {
186
- details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
187
- }
188
- });
171
+ failOccurrences.push(
172
+ helpers.reportOccurrence(el, {
173
+ occurrenceOutcome: 'fail',
174
+ summary: 'This attribute is prohibited on this element’s role.',
175
+ hint: 'Remove this attribute; this role must not carry an accessible name.',
176
+ i18n: {
177
+ summaryKey: 'ariaProhibitedAttr_summary_fail',
178
+ hintKey: 'ariaProhibitedAttr_hint_fail',
179
+ params: { attr, role }
180
+ },
181
+ data: {
182
+ details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
183
+ }
184
+ })
185
+ );
189
186
  }
190
187
  }
191
188
 
@@ -193,7 +190,7 @@ function runInPage(ctx) {
193
190
  // and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
194
191
 
195
192
  // Small, curated set of native tags verified to carry no explicit or
196
- // implicit ARIA role. Deliberately excludes <section>/<form>/<a> — all
193
+ // implicit ARIA role. Excludes <section>/<form>/<a>, which are
197
194
  // conditionally roleless too, but already handled with more nuance
198
195
  // elsewhere in this engine (see header comment).
199
196
  const ROLELESS_NATIVE_TAGS = new Set([
@@ -271,7 +268,7 @@ function runInPage(ctx) {
271
268
  };
272
269
 
273
270
  // Nearest ancestor's real role (explicit-if-valid, else native/implicit),
274
- // skipping roleless/presentation/none ancestors — used only to check
271
+ // skipping roleless/presentation/none ancestors. Used only to check
275
272
  // whether that role is a "widget"-type one (the roleless-branch
276
273
  // exemption). Not the same helper as aria-required-parent's containment
277
274
  // walk: this one also accepts non-required-context roles.
@@ -299,7 +296,7 @@ function runInPage(ctx) {
299
296
 
300
297
  // A small, spec-reserved set of hyphenated tag names that are NOT
301
298
  // autonomous custom elements despite containing a hyphen (legacy SVG/
302
- // MathML tags predating the Custom Elements spec) — see
299
+ // MathML tags predating the Custom Elements spec). See
303
300
  // https://html.spec.whatwg.org/#valid-custom-element-name's own
304
301
  // exclusion list. Excluded so this doesn't misclassify them as
305
302
  // always-roleless the same way a real custom element is.
@@ -339,8 +336,8 @@ function runInPage(ctx) {
339
336
  const tag = String(el.tagName || '').toLowerCase();
340
337
  if (!ROLELESS_NATIVE_TAGS.has(tag) && !isRolelessCustomElementTag(tag)) continue;
341
338
  const explicitRole = ariaHelpers.getExplicitRole(el);
342
- if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role — Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
343
- if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all — not this branch's concern
339
+ if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role: Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
340
+ if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all, not this branch's concern
344
341
 
345
342
  const present = [];
346
343
  for (const attr of PROHIBITED_NAMING_ATTRS) {
@@ -352,7 +349,7 @@ function runInPage(ctx) {
352
349
  applicableCount += 1;
353
350
 
354
351
  const ancestorRole = getNearestAncestorRole(el);
355
- if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget — not flagged
352
+ if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget, not flagged
356
353
 
357
354
  const nameInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
358
355
  const hasContentFallback = !!(
@@ -361,47 +358,49 @@ function runInPage(ctx) {
361
358
  String(nameInfo.value || '').trim() !== ''
362
359
  );
363
360
 
364
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
365
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
366
-
367
361
  for (const attr of present) {
368
362
  if (hasContentFallback) {
369
- cantTellOccurrences.push({
370
- selector: stableSelector,
371
- html,
372
- occurrenceOutcome: 'cantTell',
373
- summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology — but the element's own content already provides one.`,
374
- hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
375
- i18n: {
376
- summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
377
- hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
378
- params: { attr, element: tag }
379
- },
380
- data: {
381
- details: {
382
- reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
383
- attr,
384
- role: null,
385
- element: tag
363
+ cantTellOccurrences.push(
364
+ helpers.reportOccurrence(el, {
365
+ occurrenceOutcome: 'cantTell',
366
+ summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology, but the element's own content already provides one.`,
367
+ hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
368
+ i18n: {
369
+ summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
370
+ hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
371
+ params: { attr, element: tag }
372
+ },
373
+ data: {
374
+ details: {
375
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
376
+ attr,
377
+ role: null,
378
+ element: tag
379
+ }
386
380
  }
387
- }
388
- });
381
+ })
382
+ );
389
383
  } else {
390
- failOccurrences.push({
391
- selector: stableSelector,
392
- html,
393
- occurrenceOutcome: 'fail',
394
- summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
395
- hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
396
- i18n: {
397
- summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
398
- hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
399
- params: { attr, element: tag }
400
- },
401
- data: {
402
- details: { reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS', attr, role: null, element: tag }
403
- }
404
- });
384
+ failOccurrences.push(
385
+ helpers.reportOccurrence(el, {
386
+ occurrenceOutcome: 'fail',
387
+ summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
388
+ hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
389
+ i18n: {
390
+ summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
391
+ hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
392
+ params: { attr, element: tag }
393
+ },
394
+ data: {
395
+ details: {
396
+ reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS',
397
+ attr,
398
+ role: null,
399
+ element: tag
400
+ }
401
+ }
402
+ })
403
+ );
405
404
  }
406
405
  }
407
406
  }
@@ -412,7 +411,7 @@ function runInPage(ctx) {
412
411
 
413
412
  // See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
414
413
  // a fail-tier finding never silently discards cantTell-tier findings from
415
- // the same run — both are returned together when the outcome is 'fail'.
414
+ // the same run. Both are returned together when the outcome is 'fail'.
416
415
  const resolved = helpers.resolveTieredOutcome(
417
416
  failOccurrences,
418
417
  cantTellOccurrences,