@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -17,17 +19,21 @@
17
19
  * are ambiguous: many screen readers only announce one of them, and it
18
20
  * is not deterministic which.
19
21
  * @implementation-notes
20
- * - Matches a widely-used reference engine's form-field-multiple-labels. Distinct, atomic
21
- * decision from form-control-programmatic-label-present (that
22
- * rule checks a label exists at all; this one checks there is at most
23
- * one).
22
+ * - Distinct, atomic decision from form-control-programmatic-label-present
23
+ * (that rule checks a label exists at all; this one checks there is at
24
+ * most one).
25
+ * - A label that isn't accessibility-tree-eligible (display:none,
26
+ * aria-hidden, etc.) is excluded before counting: it can't contribute to
27
+ * the control's accessible name, so it can't create the ambiguity this
28
+ * rule exists to catch.
24
29
  */
25
30
 
26
31
  const id = 'form-control-single-label';
27
32
 
28
33
  const meta = {
29
34
  title: 'Form controls must not have multiple labels',
30
- description: 'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
35
+ description:
36
+ 'Checks that a form control is associated with at most one <label> (by wrapping or by label[for]).',
31
37
  i18n: {
32
38
  titleKey: 'formControlSingleLabel_title',
33
39
  descriptionKey: 'formControlSingleLabel_description'
@@ -36,7 +42,13 @@ const meta = {
36
42
  tags: ['wcag2a', 'wcag332', 'forms', 'atomic', 'automatic'],
37
43
  wcagSc: ['3.3.2'],
38
44
  normativeMappings: [
39
- { standard: 'WCAG', version: '2.2', requirement: '3.3.2', title: 'Labels or Instructions', conformanceLevel: 'A' }
45
+ {
46
+ standard: 'WCAG',
47
+ version: '2.2',
48
+ requirement: '3.3.2',
49
+ title: 'Labels or Instructions',
50
+ conformanceLevel: 'A'
51
+ }
40
52
  ],
41
53
  defaultSeverity: 'moderate',
42
54
  category: 'understandable',
@@ -48,8 +60,14 @@ const meta = {
48
60
  function runInPage(ctx) {
49
61
  const { document, helpers, rule } = ctx;
50
62
 
51
- const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
52
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
63
+ const isAccTreeEligible =
64
+ helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
65
+
66
+ const selector =
67
+ 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
68
+ const nodes = helpers.queryAllSmart
69
+ ? helpers.queryAllSmart(selector)
70
+ : helpers.queryAll(selector);
53
71
 
54
72
  // Build a for-value -> label[] map once (avoids per-control dynamic
55
73
  // attribute-selector construction / CSS.escape, which is not guaranteed
@@ -82,11 +100,20 @@ function runInPage(ctx) {
82
100
  for (const lab of labelsByFor.get(controlId)) labels.add(lab);
83
101
  }
84
102
 
85
- if (labels.size <= 1) continue;
103
+ const eligibleLabels = isAccTreeEligible
104
+ ? new Set(
105
+ [...labels].filter((lab) => {
106
+ const elig = isAccTreeEligible(lab);
107
+ return !elig || elig.eligible !== false;
108
+ })
109
+ )
110
+ : labels;
111
+
112
+ if (eligibleLabels.size <= 1) continue;
86
113
 
87
114
  const tag = el.tagName.toLowerCase();
88
115
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
89
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
116
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
90
117
 
91
118
  occurrences.push({
92
119
  selector: stableSelector,
@@ -96,10 +123,14 @@ function runInPage(ctx) {
96
123
  i18n: {
97
124
  summaryKey: 'formControlSingleLabel_summary_fail',
98
125
  hintKey: 'formControlSingleLabel_hint_fail',
99
- params: { element: tag, labelCount: String(labels.size) }
126
+ params: { element: tag, labelCount: String(eligibleLabels.size) }
100
127
  },
101
128
  data: {
102
- details: { reasonCode: 'FORM_FIELD_MULTIPLE_LABELS', element: tag, labelCount: labels.size }
129
+ details: {
130
+ reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
131
+ element: tag,
132
+ labelCount: eligibleLabels.size
133
+ }
103
134
  }
104
135
  });
105
136
  }
@@ -108,9 +139,14 @@ function runInPage(ctx) {
108
139
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
140
  }
110
141
  if (occurrences.length) {
111
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
142
+ return {
143
+ ruleId: rule.ruleId,
144
+ outcome: 'fail',
145
+ severity: rule.defaultSeverity || 'moderate',
146
+ occurrences
147
+ };
112
148
  }
113
149
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
114
150
  }
115
151
 
116
- module.exports = { id, meta, runInPage };
152
+ module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -27,7 +29,8 @@ const id = 'html-xml-lang-mismatch';
27
29
 
28
30
  const meta = {
29
31
  title: 'lang and xml:lang must not disagree',
30
- description: 'Checks that the <html> element\'s lang and xml:lang attributes declare the same primary language, when both are present.',
32
+ description:
33
+ "Checks that the <html> element's lang and xml:lang attributes declare the same primary language, when both are present.",
31
34
  i18n: {
32
35
  titleKey: 'htmlXmlLangMismatch_title',
33
36
  descriptionKey: 'htmlXmlLangMismatch_description'
@@ -36,7 +39,13 @@ const meta = {
36
39
  tags: ['wcag2a', 'wcag311', 'structure', 'language', 'atomic', 'automatic'],
37
40
  wcagSc: ['3.1.1'],
38
41
  normativeMappings: [
39
- { standard: 'WCAG', version: '2.2', requirement: '3.1.1', title: 'Language of Page', conformanceLevel: 'A' }
42
+ {
43
+ standard: 'WCAG',
44
+ version: '2.2',
45
+ requirement: '3.1.1',
46
+ title: 'Language of Page',
47
+ conformanceLevel: 'A'
48
+ }
40
49
  ],
41
50
  defaultSeverity: 'serious',
42
51
  category: 'understandable',
@@ -57,7 +66,7 @@ function runInPage(ctx) {
57
66
  const { document, helpers, rule } = ctx;
58
67
 
59
68
  const html = document && document.documentElement;
60
- const tag = (html && html.tagName) ? String(html.tagName).toLowerCase() : '';
69
+ const tag = html && html.tagName ? String(html.tagName).toLowerCase() : '';
61
70
  if (!html || tag !== 'html') {
62
71
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
63
72
  }
@@ -76,24 +85,33 @@ function runInPage(ctx) {
76
85
  }
77
86
 
78
87
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
79
- const htmlSnippet = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(html) : (html.outerHTML || '').slice(0, 200);
88
+ const htmlSnippet = helpers.getOuterHtmlSnippet
89
+ ? helpers.getOuterHtmlSnippet(html)
90
+ : (html.outerHTML || '').slice(0, 200);
80
91
 
81
- const occurrences = [{
82
- selector: stableSelector,
83
- html: htmlSnippet,
84
- summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
85
- hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
86
- i18n: {
87
- summaryKey: 'htmlXmlLangMismatch_summary_fail',
88
- hintKey: 'htmlXmlLangMismatch_hint_fail',
89
- params: { lang, xmlLang }
90
- },
91
- data: {
92
- details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
92
+ const occurrences = [
93
+ {
94
+ selector: stableSelector,
95
+ html: htmlSnippet,
96
+ summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
97
+ hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
98
+ i18n: {
99
+ summaryKey: 'htmlXmlLangMismatch_summary_fail',
100
+ hintKey: 'htmlXmlLangMismatch_hint_fail',
101
+ params: { lang, xmlLang }
102
+ },
103
+ data: {
104
+ details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
105
+ }
93
106
  }
94
- }];
107
+ ];
95
108
 
96
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
109
+ return {
110
+ ruleId: rule.ruleId,
111
+ outcome: 'fail',
112
+ severity: rule.defaultSeverity || 'serious',
113
+ occurrences
114
+ };
97
115
  }
98
116
 
99
117
  module.exports = { id, meta, runInPage, applicability };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -36,7 +38,8 @@ const id = 'iframe-focusable-content';
36
38
 
37
39
  const meta = {
38
40
  title: 'Frames with tabindex="-1" must not contain focusable content',
39
- description: 'Checks that same-origin <iframe>/<frame> elements with tabindex="-1" do not contain focusable content, since browsers do not propagate that restriction into the frame’s embedded document.',
41
+ description:
42
+ 'Checks that same-origin <iframe>/<frame> elements with tabindex="-1" do not contain focusable content, since browsers do not propagate that restriction into the frame’s embedded document.',
40
43
  i18n: {
41
44
  titleKey: 'iframeFocusableContent_title',
42
45
  descriptionKey: 'iframeFocusableContent_description'
@@ -45,7 +48,13 @@ const meta = {
45
48
  tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'automatic', 'keyboard', 'iframe'],
46
49
  wcagSc: ['2.1.1'],
47
50
  normativeMappings: [
48
- { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' }
51
+ {
52
+ standard: 'WCAG',
53
+ version: '2.2',
54
+ requirement: '2.1.1',
55
+ title: 'Keyboard',
56
+ conformanceLevel: 'A'
57
+ }
49
58
  ],
50
59
  defaultSeverity: 'moderate',
51
60
  category: 'operable',
@@ -55,19 +64,151 @@ const meta = {
55
64
  };
56
65
 
57
66
  function runInPage(ctx) {
58
- const { document, helpers, rule } = ctx;
67
+ const { helpers, rule, document } = ctx;
68
+
69
+ // Self-contained rendering check for the embedded document (a distinct
70
+ // realm — see this rule's own header comment on why the outer
71
+ // document's shared eligibility helpers can't be reused here).
72
+ // Deliberately checks only genuine non-rendering (display:none,
73
+ // visibility:hidden, the hidden attribute) via the ancestor chain, NOT
74
+ // aria-hidden: aria-hidden alone does not remove an element from a real
75
+ // browser's native tab order (the same anti-pattern this engine's own
76
+ // aria-hidden-focus rule exists to catch), so an aria-hidden-but-
77
+ // visually-rendered focusable element inside the frame is still
78
+ // genuinely reachable by keyboard and must stay flagged.
79
+ function isRenderedInDoc(doc, el) {
80
+ try {
81
+ const view = doc.defaultView;
82
+ if (!view || typeof view.getComputedStyle !== 'function') return true;
83
+ let node = el;
84
+ while (node && node.nodeType === 1) {
85
+ if (node.hasAttribute && node.hasAttribute('hidden')) return false;
86
+ const cs = view.getComputedStyle(node);
87
+ if (cs) {
88
+ if (cs.display === 'none') return false;
89
+ if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
90
+ }
91
+ node = node.parentElement;
92
+ }
93
+ return true;
94
+ } catch {
95
+ return true;
96
+ }
97
+ }
98
+
99
+ function getDeepActiveElement(docRef) {
100
+ let cur = docRef && docRef.activeElement ? docRef.activeElement : null;
101
+ let guard = 0;
102
+ while (cur && cur.shadowRoot && cur.shadowRoot.activeElement && guard++ < 20) {
103
+ cur = cur.shadowRoot.activeElement;
104
+ }
105
+ return cur;
106
+ }
107
+
108
+ function focusElementSafe(el) {
109
+ if (!el || typeof el.focus !== 'function') return false;
110
+ try {
111
+ el.focus({ preventScroll: true });
112
+ return true;
113
+ } catch {
114
+ try {
115
+ el.focus();
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ }
122
+
123
+ function runFocusObservationWindow(win, fn) {
124
+ if (!win || typeof fn !== 'function') return;
125
+ const originalSetTimeout =
126
+ typeof win.setTimeout === 'function' ? win.setTimeout.bind(win) : null;
127
+ const originalRequestAnimationFrame =
128
+ typeof win.requestAnimationFrame === 'function' ? win.requestAnimationFrame.bind(win) : null;
129
+ const originalQueueMicrotask =
130
+ typeof win.queueMicrotask === 'function' ? win.queueMicrotask.bind(win) : null;
131
+
132
+ const queuedMicrotasks = [];
133
+ const queuedRaf = [];
134
+ const queuedTimers = [];
135
+ let fakeTimerId = 1;
136
+
137
+ const patchedSetTimeout = function (cb, delay) {
138
+ const d = Number.isFinite(Number(delay)) ? Number(delay) : 0;
139
+ if (typeof cb === 'function' && d <= 200) {
140
+ const args = [];
141
+ for (let i = 2; i < arguments.length; i++) args.push(arguments[i]);
142
+ queuedTimers.push({ delay: d, cb: () => cb.apply(win, args) });
143
+ return fakeTimerId++;
144
+ }
145
+ if (originalSetTimeout) return originalSetTimeout.apply(win, arguments);
146
+ return fakeTimerId++;
147
+ };
148
+
149
+ const patchedRaf = function (cb) {
150
+ if (typeof cb === 'function') {
151
+ queuedRaf.push(cb);
152
+ return fakeTimerId++;
153
+ }
154
+ if (originalRequestAnimationFrame) return originalRequestAnimationFrame.apply(win, arguments);
155
+ return fakeTimerId++;
156
+ };
59
157
 
60
- function hasFocusableCandidate(doc) {
61
- if (!doc || !doc.querySelectorAll) return false;
62
- let els = [];
158
+ const patchedQueueMicrotask = function (cb) {
159
+ if (typeof cb === 'function') queuedMicrotasks.push(cb);
160
+ };
161
+
162
+ try {
163
+ if (originalSetTimeout) win.setTimeout = patchedSetTimeout;
164
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = patchedRaf;
165
+ if (originalQueueMicrotask) win.queueMicrotask = patchedQueueMicrotask;
166
+ fn();
167
+
168
+ let guard = 0;
169
+ while (
170
+ (queuedMicrotasks.length || queuedRaf.length || queuedTimers.length) &&
171
+ guard++ < 100
172
+ ) {
173
+ while (queuedMicrotasks.length) {
174
+ const mt = queuedMicrotasks.shift();
175
+ try {
176
+ mt();
177
+ } catch {}
178
+ }
179
+ while (queuedRaf.length) {
180
+ const rf = queuedRaf.shift();
181
+ try {
182
+ rf(16);
183
+ } catch {}
184
+ }
185
+ if (queuedTimers.length) {
186
+ queuedTimers.sort((a, b) => a.delay - b.delay);
187
+ const tt = queuedTimers.shift();
188
+ try {
189
+ tt.cb();
190
+ } catch {}
191
+ }
192
+ }
193
+ } finally {
194
+ if (originalSetTimeout) win.setTimeout = originalSetTimeout;
195
+ if (originalRequestAnimationFrame) win.requestAnimationFrame = originalRequestAnimationFrame;
196
+ if (originalQueueMicrotask) win.queueMicrotask = originalQueueMicrotask;
197
+ }
198
+ }
199
+
200
+ function getFocusableCandidates(doc) {
201
+ if (!doc || !doc.querySelectorAll) return [];
202
+ let els;
63
203
  try {
64
204
  els = doc.querySelectorAll(
65
205
  'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
66
- 'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
206
+ 'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
67
207
  );
68
208
  } catch {
69
- return false;
209
+ return [];
70
210
  }
211
+ const candidates = [];
71
212
  for (const el of els) {
72
213
  if (!el || !el.getAttribute) continue;
73
214
  const raw = el.getAttribute('tabindex');
@@ -75,9 +216,77 @@ function runInPage(ctx) {
75
216
  const n = Number(String(raw).trim());
76
217
  if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
77
218
  }
78
- return true;
219
+ if (!isRenderedInDoc(doc, el)) continue; // display:none/visibility:hidden/[hidden]: never reachable at all
220
+ candidates.push(el);
79
221
  }
80
- return false;
222
+ return candidates;
223
+ }
224
+
225
+ function probeImmediateFocusRedirect(frameEl, embeddedDoc, candidate) {
226
+ if (!frameEl || !embeddedDoc || !candidate) return null;
227
+ const embeddedWindow = embeddedDoc.defaultView;
228
+ if (!embeddedWindow) return null;
229
+
230
+ let focusedByEvent = false;
231
+ const onFocusCapture = () => {
232
+ focusedByEvent = true;
233
+ };
234
+ try {
235
+ candidate.addEventListener('focus', onFocusCapture, true);
236
+ } catch {}
237
+
238
+ const innerFocusTrace = [];
239
+ const outerFocusTrace = [];
240
+ const onInnerFocusIn = (ev) => {
241
+ if (ev && ev.target) innerFocusTrace.push(ev.target);
242
+ };
243
+ const onOuterFocusIn = (ev) => {
244
+ if (ev && ev.target) outerFocusTrace.push(ev.target);
245
+ };
246
+ try {
247
+ embeddedDoc.addEventListener('focusin', onInnerFocusIn, true);
248
+ document.addEventListener('focusin', onOuterFocusIn, true);
249
+ } catch {}
250
+
251
+ const beforeInner = getDeepActiveElement(embeddedDoc);
252
+ const beforeOuter = getDeepActiveElement(document);
253
+ let focused = false;
254
+ runFocusObservationWindow(embeddedWindow, () => {
255
+ focused = focusElementSafe(candidate);
256
+ });
257
+ try {
258
+ embeddedDoc.removeEventListener('focusin', onInnerFocusIn, true);
259
+ document.removeEventListener('focusin', onOuterFocusIn, true);
260
+ } catch {}
261
+ try {
262
+ candidate.removeEventListener('focus', onFocusCapture, true);
263
+ } catch {}
264
+
265
+ if (!focused) return null;
266
+
267
+ const afterInner = getDeepActiveElement(embeddedDoc);
268
+ const afterOuter = getDeepActiveElement(document);
269
+ const sawRedirectedInnerTrace = innerFocusTrace.some((n) => n && n !== candidate);
270
+ const sawRedirectedOuterTrace = outerFocusTrace.some(
271
+ (n) => n && n !== frameEl && n !== candidate
272
+ );
273
+ const redirectedWithinFrame =
274
+ !!(afterInner && afterInner !== candidate) || sawRedirectedInnerTrace;
275
+ const redirectedOutOfFrame =
276
+ !!(afterOuter && afterOuter !== frameEl) || sawRedirectedOuterTrace;
277
+ const sawCandidateFocus = focusedByEvent || innerFocusTrace.some((n) => n === candidate);
278
+
279
+ if (beforeInner && beforeInner !== afterInner) focusElementSafe(beforeInner);
280
+ if (beforeOuter && beforeOuter !== afterOuter) focusElementSafe(beforeOuter);
281
+
282
+ if (!sawCandidateFocus) return null;
283
+ if (!redirectedWithinFrame && !redirectedOutOfFrame) return null;
284
+
285
+ return {
286
+ redirected: true,
287
+ redirectedWithinFrame,
288
+ redirectedOutOfFrame
289
+ };
81
290
  }
82
291
 
83
292
  function getNegativeTabIndex(el) {
@@ -87,16 +296,19 @@ function runInPage(ctx) {
87
296
  return !Number.isNaN(n) && n < 0;
88
297
  }
89
298
 
90
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
299
+ const nodes = helpers.queryAllSmart
300
+ ? helpers.queryAllSmart('iframe, frame')
301
+ : helpers.queryAll('iframe, frame');
91
302
 
92
- const occurrences = [];
303
+ const failOccurrences = [];
304
+ const cantTellOccurrences = [];
93
305
  let applicableCount = 0;
94
306
 
95
307
  for (const el of nodes) {
96
308
  if (!el || !el.getAttribute) continue;
97
309
  if (!getNegativeTabIndex(el)) continue;
98
310
 
99
- let contentDoc = null;
311
+ let contentDoc;
100
312
  try {
101
313
  contentDoc = el.contentDocument || null;
102
314
  } catch {
@@ -106,16 +318,41 @@ function runInPage(ctx) {
106
318
 
107
319
  applicableCount += 1;
108
320
 
109
- if (!hasFocusableCandidate(contentDoc)) continue;
321
+ const candidates = getFocusableCandidates(contentDoc);
322
+ if (!candidates.length) continue;
110
323
 
111
324
  const tag = el.tagName.toLowerCase();
112
325
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
113
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
326
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
327
+ const shouldProbe = candidates.length === 1;
328
+ const runtimeProbe = shouldProbe
329
+ ? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
330
+ : null;
114
331
 
115
- occurrences.push({
332
+ 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
345
+ }
346
+ }
347
+ });
348
+ continue;
349
+ }
350
+
351
+ failOccurrences.push({
116
352
  selector: stableSelector,
117
353
  html,
118
- summary: 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
354
+ summary:
355
+ 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
119
356
  hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
120
357
  i18n: {
121
358
  summaryKey: 'iframeFocusableContent_summary_fail',
@@ -131,10 +368,16 @@ function runInPage(ctx) {
131
368
  if (applicableCount === 0) {
132
369
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
133
370
  }
134
- if (occurrences.length) {
135
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
136
- }
137
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
371
+ // helpers.resolveTieredOutcome is unconditionally provided by dom-helpers.js
372
+ // (see its own header comment) -- no fallback needed, matching the same
373
+ // cleanup already applied to aria-hidden-focus.js/aria-prohibited-attr.js/
374
+ // target-size-minimum.js.
375
+ const resolved = helpers.resolveTieredOutcome(
376
+ failOccurrences,
377
+ cantTellOccurrences,
378
+ rule.defaultSeverity || 'moderate'
379
+ );
380
+ return { ruleId: rule.ruleId, ...resolved };
138
381
  }
139
382
 
140
- module.exports = { id, meta, runInPage };
383
+ module.exports = { id, meta, runInPage };
@@ -1,3 +1,5 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  /**
@@ -25,7 +27,8 @@ const id = 'iframe-name-present';
25
27
 
26
28
  const meta = {
27
29
  title: 'Frames have an accessible name',
28
- description: 'Checks that <iframe>/<frame> elements expose a non-empty accessible name via aria-label, aria-labelledby, or the title attribute.',
30
+ description:
31
+ 'Checks that <iframe>/<frame> elements expose a non-empty accessible name via aria-label, aria-labelledby, or the title attribute.',
29
32
  i18n: {
30
33
  titleKey: 'iframeNamePresent_title',
31
34
  descriptionKey: 'iframeNamePresent_description'
@@ -34,7 +37,13 @@ const meta = {
34
37
  tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
35
38
  wcagSc: ['4.1.2'],
36
39
  normativeMappings: [
37
- { standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
40
+ {
41
+ standard: 'WCAG',
42
+ version: '2.2',
43
+ requirement: '4.1.2',
44
+ title: 'Name, Role, Value',
45
+ conformanceLevel: 'A'
46
+ }
38
47
  ],
39
48
  defaultSeverity: 'serious',
40
49
  category: 'robust',
@@ -44,9 +53,11 @@ const meta = {
44
53
  };
45
54
 
46
55
  function runInPage(ctx) {
47
- const { document, helpers, rule } = ctx;
56
+ const { helpers, rule } = ctx;
48
57
 
49
- const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame') : helpers.queryAll('iframe, frame');
58
+ const nodes = helpers.queryAllSmart
59
+ ? helpers.queryAllSmart('iframe, frame')
60
+ : helpers.queryAll('iframe, frame');
50
61
 
51
62
  const occurrences = [];
52
63
  let applicableCount = 0;
@@ -62,15 +73,23 @@ function runInPage(ctx) {
62
73
 
63
74
  applicableCount += 1;
64
75
 
65
- const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 }) : null;
76
+ const nameInfo = helpers.getAccessibleNameInfo
77
+ ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 })
78
+ : null;
66
79
  if (nameInfo && nameInfo.present && nameInfo.value) continue;
67
80
 
68
81
  const eligInfo = helpers.getEligibilityInfo
69
- ? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
82
+ ? (() => {
83
+ try {
84
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' });
85
+ } catch {
86
+ return null;
87
+ }
88
+ })()
70
89
  : null;
71
90
  const tag = el.tagName.toLowerCase();
72
91
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
73
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
92
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
74
93
 
75
94
  occurrences.push({
76
95
  selector: stableSelector,
@@ -93,9 +112,14 @@ function runInPage(ctx) {
93
112
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
94
113
  }
95
114
  if (occurrences.length) {
96
- return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
115
+ return {
116
+ ruleId: rule.ruleId,
117
+ outcome: 'fail',
118
+ severity: rule.defaultSeverity || 'serious',
119
+ occurrences
120
+ };
97
121
  }
98
122
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
99
123
  }
100
124
 
101
- module.exports = { id, meta, runInPage };
125
+ module.exports = { id, meta, runInPage };