@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
@@ -82,24 +82,22 @@ function runInPage(ctx) {
82
82
 
83
83
  if (info.allowed) continue;
84
84
 
85
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
86
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
87
85
  const tag = (el.tagName || '').toLowerCase();
88
86
 
89
- occurrences.push({
90
- selector: stableSelector,
91
- html,
92
- summary: 'This role is not permitted on this element.',
93
- hint: 'Use a role permitted for this element, or change the host element.',
94
- i18n: {
95
- summaryKey: 'ariaAllowedRole_summary_fail',
96
- hintKey: 'ariaAllowedRole_hint_fail',
97
- params: { role, element: tag }
98
- },
99
- data: {
100
- details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
101
- }
102
- });
87
+ occurrences.push(
88
+ helpers.reportOccurrence(el, {
89
+ summary: 'This role is not permitted on this element.',
90
+ hint: 'Use a role permitted for this element, or change the host element.',
91
+ i18n: {
92
+ summaryKey: 'ariaAllowedRole_summary_fail',
93
+ hintKey: 'ariaAllowedRole_hint_fail',
94
+ params: { role, element: tag }
95
+ },
96
+ data: {
97
+ details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
98
+ }
99
+ })
100
+ );
103
101
  }
104
102
 
105
103
  if (applicableCount === 0) {
@@ -119,29 +119,27 @@ function runInPage(ctx) {
119
119
 
120
120
  if (!missing.length) continue;
121
121
 
122
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
123
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
124
122
  const tag = (el.tagName || '').toLowerCase();
125
123
 
126
124
  for (const m of missing) {
127
- occurrences.push({
128
- selector: stableSelector,
129
- html,
130
- summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
131
- hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
132
- i18n: {
133
- summaryKey: 'ariaBrailleEquivalent_summary_fail',
134
- hintKey: 'ariaBrailleEquivalent_hint_fail',
135
- params: { element: tag, attr: m.attr, requires: m.requires }
136
- },
137
- data: {
138
- details: {
139
- reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
140
- attr: m.attr,
141
- requires: m.requires
125
+ occurrences.push(
126
+ helpers.reportOccurrence(el, {
127
+ summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
128
+ hint: `${m.attr} is a Braille-specific supplement, not a replacement — also provide ${m.requires}.`,
129
+ i18n: {
130
+ summaryKey: 'ariaBrailleEquivalent_summary_fail',
131
+ hintKey: 'ariaBrailleEquivalent_hint_fail',
132
+ params: { element: tag, attr: m.attr, requires: m.requires }
133
+ },
134
+ data: {
135
+ details: {
136
+ reasonCode: 'BRAILLE_ATTR_WITHOUT_EQUIVALENT',
137
+ attr: m.attr,
138
+ requires: m.requires
139
+ }
142
140
  }
143
- }
144
- });
141
+ })
142
+ );
145
143
  }
146
144
  }
147
145
 
@@ -86,28 +86,26 @@ function runInPage(ctx) {
86
86
  const invalidValue = trim(el.getAttribute('aria-invalid')).toLowerCase();
87
87
  if (TRUTHY_INVALID_VALUES.has(invalidValue)) continue;
88
88
 
89
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
90
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
91
89
  const tag = (el.tagName || '').toLowerCase();
92
90
 
93
- occurrences.push({
94
- selector: stableSelector,
95
- html,
96
- summary:
97
- 'This element has aria-errormessage but aria-invalid is missing or "false", so the error message is not exposed.',
98
- hint: 'Set aria-invalid to "true" (or "grammar"/"spelling") whenever aria-errormessage should be exposed to assistive technology.',
99
- i18n: {
100
- summaryKey: 'ariaConditionalAttr_summary_fail',
101
- hintKey: 'ariaConditionalAttr_hint_fail',
102
- params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
103
- },
104
- data: {
105
- details: {
106
- reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
107
- ariaInvalid: invalidValue
91
+ occurrences.push(
92
+ helpers.reportOccurrence(el, {
93
+ summary:
94
+ 'This element has aria-errormessage but aria-invalid is missing or "false", so the error message is not exposed.',
95
+ hint: 'Set aria-invalid to "true" (or "grammar"/"spelling") whenever aria-errormessage should be exposed to assistive technology.',
96
+ i18n: {
97
+ summaryKey: 'ariaConditionalAttr_summary_fail',
98
+ hintKey: 'ariaConditionalAttr_hint_fail',
99
+ params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
100
+ },
101
+ data: {
102
+ details: {
103
+ reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
104
+ ariaInvalid: invalidValue
105
+ }
108
106
  }
109
- }
110
- });
107
+ })
108
+ );
111
109
  }
112
110
 
113
111
  if (applicableCount === 0) {
@@ -5,30 +5,36 @@
5
5
  /**
6
6
  * @check aria-deprecated-role
7
7
  * @atomic true
8
- * @summary An explicit role attribute must not use a deprecated or author-prohibited ARIA role
8
+ * @summary An explicit role attribute should not use a deprecated or author-discouraged ARIA role
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
12
  * Applies to any element whose role attribute's first (used) token is a
13
- * valid, non-abstract ARIA role that authors must never explicitly
13
+ * valid, non-abstract ARIA role that authors should not explicitly
14
14
  * declare — either because WAI-ARIA has deprecated it (e.g. "directory",
15
- * superseded by role="list") or because it's reserved for user-agent-
16
- * internal use only, not a spec deprecation but the same "valid token,
17
- * prohibited for authors" shape (role="generic" — per WAI-ARIA 1.2 and
18
- * MDN's "It should not be used by web authors" guidance).
15
+ * superseded by role="list") or because it is reserved for user-agent-
16
+ * internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
17
+ * NOT use in content).
19
18
  * @expectation
20
- * The role in use is neither deprecated nor author-prohibited. This is a
21
- * distinct, atomic decision from aria-roles-valid (existence/
22
- * abstractness): a role can be perfectly valid and non-abstract while
23
- * still being off-limits for explicit author use.
19
+ * The role in use is neither deprecated nor reserved. Graded by the
20
+ * strength of the rule ARIA states:
21
+ * - CANTTELL at SHOULD NOT, which leaves the usage conforming, so the
22
+ * author decides whether it matters: a deprecated role ("directory") or
23
+ * one reserved for user agents ("generic").
24
+ * - FAIL at MUST NOT. No ARIA 1.2 or 1.3 role carries an author MUST NOT
25
+ * outside the abstract roles, so this outcome is reserved for a later
26
+ * revision promoting a role to that strength.
27
+ * Distinct, atomic decision from aria-roles-valid (existence/
28
+ * abstractness): a role can be valid and non-abstract while still being
29
+ * discouraged in explicit author use.
24
30
  */
25
31
 
26
32
  const id = 'aria-deprecated-role';
27
33
 
28
34
  const meta = {
29
- title: 'role attribute must not use a deprecated or author-prohibited ARIA role',
35
+ title: 'role attribute should not use a deprecated or author-discouraged ARIA role',
30
36
  description:
31
- 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use only (e.g. role="generic").',
37
+ 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use (e.g. role="generic").',
32
38
  i18n: {
33
39
  titleKey: 'ariaDeprecatedRole_title',
34
40
  descriptionKey: 'ariaDeprecatedRole_description'
@@ -64,12 +70,43 @@ function runInPage(ctx) {
64
70
  ? helpers.queryAllSmart('[role]')
65
71
  : helpers.queryAll('[role]');
66
72
 
67
- const occurrences = [];
73
+ const failOccurrences = [];
74
+ const cantTellOccurrences = [];
68
75
  let applicableCount = 0;
69
76
 
77
+ // A role on an element hidden from assistive technology has no effect, so
78
+ // ACT 674b10 does not apply to it.
79
+ function isHidden(el) {
80
+ try {
81
+ if (typeof helpers.isDomVisibleEligible === 'function') {
82
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
83
+ }
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;
96
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
97
+ if (n.hasAttribute && n.hasAttribute('inert')) return true;
98
+ }
99
+ } catch {
100
+ return false;
101
+ }
102
+ return false;
103
+ }
104
+
70
105
  for (const el of nodes) {
71
106
  if (!el || !el.getAttribute) continue;
72
107
 
108
+ if (isHidden(el)) continue;
109
+
73
110
  const role = ariaHelpers.getExplicitRole(el);
74
111
  if (!role) continue;
75
112
 
@@ -79,42 +116,86 @@ function runInPage(ctx) {
79
116
 
80
117
  applicableCount += 1;
81
118
 
82
- if (!ariaHelpers.isDeprecatedRole(role)) continue;
119
+ const deprecated = ariaHelpers.isDeprecatedRole(role);
120
+ const discouraged =
121
+ typeof ariaHelpers.isAuthorDiscouragedRole === 'function' &&
122
+ ariaHelpers.isAuthorDiscouragedRole(role);
123
+ const prohibited =
124
+ typeof ariaHelpers.isAuthorProhibitedRole === 'function' &&
125
+ ariaHelpers.isAuthorProhibitedRole(role);
126
+ if (!deprecated && !discouraged && !prohibited) continue;
83
127
 
84
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
85
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
86
128
  const guidance = ariaHelpers.getDeprecatedRoleGuidance
87
129
  ? ariaHelpers.getDeprecatedRoleGuidance(role)
88
- : 'Replace the deprecated role with its recommended replacement.';
89
-
90
- occurrences.push({
91
- selector: stableSelector,
92
- html,
93
- summary: `This element uses role="${role}", which authors must not explicitly declare.`,
94
- hint: guidance,
95
- i18n: {
96
- summaryKey: 'ariaDeprecatedRole_summary_fail',
97
- hintKey: 'ariaDeprecatedRole_hint_fail',
98
- params: { role, guidance }
99
- },
100
- data: {
101
- details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
102
- }
103
- });
130
+ : {
131
+ key: 'ariaDeprecatedRole_guidance_default',
132
+ text: 'Replace the deprecated role with its recommended replacement.'
133
+ };
134
+
135
+ if (prohibited) {
136
+ // Author MUST NOT: the usage is non-conforming, not merely discouraged.
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
+ );
152
+ } else if (discouraged) {
153
+ // Reserved for user-agent-internal use, at SHOULD NOT strength.
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
+ );
169
+ } else {
170
+ // Deprecated but still valid: surfaced for the author to decide.
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
+ );
186
+ }
104
187
  }
105
188
 
106
189
  if (applicableCount === 0) {
107
190
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
108
191
  }
109
- if (occurrences.length) {
110
- return {
111
- ruleId: rule.ruleId,
112
- outcome: 'fail',
113
- severity: rule.defaultSeverity || 'moderate',
114
- occurrences
115
- };
116
- }
117
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
192
+
193
+ const resolved = helpers.resolveTieredOutcome(
194
+ failOccurrences,
195
+ cantTellOccurrences,
196
+ rule.defaultSeverity || 'moderate'
197
+ );
198
+ return { ruleId: rule.ruleId, ...resolved };
118
199
  }
119
200
 
120
201
  module.exports = { id, meta, runInPage };
@@ -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 {
@@ -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;
@@ -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)
@@ -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
 
@@ -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
  }