@surea11y/core 1.5.0 → 1.7.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -0,0 +1,203 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check duplicate-id
7
+ * @atomic true
8
+ * @summary Every id value must be unique within its own tree
9
+ * @standard WCAG 2.1
10
+ * @sc 4.1.1
11
+ * @applicability
12
+ * Applies to any element carrying a non-empty id attribute. Visibility
13
+ * is irrelevant. A duplicate id breaks the same lookups whether the
14
+ * element renders or not, which is why ACT 3ea0c8 evaluates hidden
15
+ * elements too.
16
+ * @expectation
17
+ * No other element in the same tree carries the same id value. Ids are
18
+ * scoped per document tree and per shadow tree, so the same id inside
19
+ * two different shadow roots is not a duplicate.
20
+ * @implementation-notes
21
+ * - WCAG-VERSION SCOPED. SC 4.1.1 Parsing was removed in WCAG 2.2, so this
22
+ * rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
23
+ * The engine acts on that tag itself: under a 2.2 target, which is the
24
+ * default, this rule still runs and still reports every duplicate it
25
+ * finds, but its fail is coerced to `cantTell` with a `wcagVersionScope`
26
+ * field saying why (see scopeOutcomeToWcagVersion in
27
+ * `src/core/dom-runner.js`). A consumer targeting 2.0 or 2.1
28
+ * (`engineOptions.wcagVersion`, or a tag set that implies it) gets the
29
+ * real 4.1.1 failure; one that would rather not see the rule at all
30
+ * under 2.2 still excludes it with `excludeTags: ['wcag22-removed']`.
31
+ * The alternative, dropping the SC mapping entirely, would have made a
32
+ * genuine 2.0/2.1 failure invisible to anyone conformance-testing
33
+ * against those versions. See `docs/ENGINE_OPTIONS.md` for the option
34
+ * and the tag, and `docs/DESIGN_CHALLENGES.md` for the decision history.
35
+ * - The defect outlives its Success Criterion: a duplicate id breaks
36
+ * `<label for>` association, fragment navigation, `getElementById`, and
37
+ * every ID-reference attribute, none of which stopped mattering when
38
+ * 4.1.1 was retired. The SC was removed because browsers recover from
39
+ * malformed markup, not because ids became free-form.
40
+ * - Scoping is per ROOT NODE, not per document: `getRootNode()` groups
41
+ * light DOM against the document and each shadow tree against itself,
42
+ * matching the DOM's own id-lookup scope. Two components that each use
43
+ * `id="title"` inside their own shadow root are correct markup and are
44
+ * not reported.
45
+ * - Overlaps `duplicate-id-aria` by design, and the two say different
46
+ * things. That rule reports a duplicate id that an ARIA attribute
47
+ * actually references, as a `cantTell` under 4.1.2, the reference
48
+ * resolves to the first match, so whether the right element was named is
49
+ * an authoring question. This one is the flat structural fact under
50
+ * 4.1.1, for every id, referenced or not.
51
+ * - Detection is document-wide while reporting follows the scanned scope,
52
+ * the same split `duplicate-id-aria` uses: a `contextSelector` narrows
53
+ * which duplicates get reported, never which ones count as duplicates.
54
+ */
55
+
56
+ const id = 'duplicate-id';
57
+
58
+ const meta = {
59
+ title: 'IDs must be unique',
60
+ description:
61
+ 'Checks that every non-empty id attribute value is unique within its own document or shadow tree (WCAG 2.0/2.1 SC 4.1.1, removed in WCAG 2.2).',
62
+ i18n: {
63
+ titleKey: 'duplicateId_title',
64
+ descriptionKey: 'duplicateId_description'
65
+ },
66
+ helpUrl: null,
67
+ tags: ['wcag2a', 'wcag411', 'wcag22-removed', 'structure', 'atomic', 'automatic'],
68
+ wcagSc: ['4.1.1'],
69
+ normativeMappings: [
70
+ {
71
+ standard: 'WCAG',
72
+ version: '2.1',
73
+ requirement: '4.1.1',
74
+ title: 'Parsing',
75
+ conformanceLevel: 'A'
76
+ }
77
+ ],
78
+ defaultSeverity: 'moderate',
79
+ category: 'robust',
80
+ type: 'automatic',
81
+ defaultConfidence: 'high',
82
+ coverage: { facetsBySc: { '4.1.1': ['id-unique-page-wide'] } }
83
+ };
84
+
85
+ function runInPage(ctx) {
86
+ const { document, helpers, rule } = ctx;
87
+
88
+ // Detection spans the whole document; see the header comment on scope.
89
+ const all = new Set();
90
+ try {
91
+ const nodes = document.querySelectorAll ? document.querySelectorAll('[id]') : [];
92
+ for (const el of nodes) all.add(el);
93
+ } catch {
94
+ // no-throw: fall through to the helper-provided set below
95
+ }
96
+
97
+ const queryAllSmart =
98
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
99
+
100
+ // queryAllSmart reaches into open shadow roots when includeShadowDom is on,
101
+ // which document.querySelectorAll never does.
102
+ let inScope = null;
103
+ if (queryAllSmart) {
104
+ try {
105
+ const scoped = queryAllSmart('[id]');
106
+ const list = Array.isArray(scoped) ? scoped : Array.from(scoped || []);
107
+ inScope = new Set(list);
108
+ for (const el of list) all.add(el);
109
+ } catch {
110
+ inScope = null;
111
+ }
112
+ }
113
+
114
+ // Ids resolve within their own tree, so group by root before comparing.
115
+ function rootOf(el) {
116
+ try {
117
+ if (typeof el.getRootNode === 'function') return el.getRootNode();
118
+ } catch {
119
+ // fall through
120
+ }
121
+ return document;
122
+ }
123
+
124
+ const byRootAndId = new Map(); // root -> Map(idValue -> element[])
125
+ let applicableCount = 0;
126
+
127
+ for (const el of all) {
128
+ if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
129
+ const value = String(el.getAttribute('id') || '').trim();
130
+ if (!value) continue;
131
+
132
+ applicableCount += 1;
133
+
134
+ const root = rootOf(el);
135
+ let idMap = byRootAndId.get(root);
136
+ if (!idMap) {
137
+ idMap = new Map();
138
+ byRootAndId.set(root, idMap);
139
+ }
140
+ const bucket = idMap.get(value);
141
+ if (bucket) bucket.push(el);
142
+ else idMap.set(value, [el]);
143
+ }
144
+
145
+ if (applicableCount === 0) {
146
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
147
+ }
148
+
149
+ const occurrences = [];
150
+
151
+ for (const idMap of byRootAndId.values()) {
152
+ for (const [value, els] of idMap) {
153
+ if (els.length <= 1) continue;
154
+
155
+ for (const el of els) {
156
+ if (inScope && !inScope.has(el)) continue;
157
+
158
+ const eligInfo = helpers.getEligibilityInfo
159
+ ? (() => {
160
+ try {
161
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' });
162
+ } catch {
163
+ return null;
164
+ }
165
+ })()
166
+ : null;
167
+
168
+ occurrences.push(
169
+ helpers.reportOccurrence(el, {
170
+ summary: `The id "${value}" is used on ${els.length} elements in the same tree.`,
171
+ hint: 'Give each element its own id. A duplicate breaks <label for>, fragment links, getElementById and every ID-reference attribute, all of which resolve to the first match only.',
172
+ i18n: {
173
+ summaryKey: 'duplicateId_summary_fail',
174
+ hintKey: 'duplicateId_hint_fail',
175
+ params: { id: value, count: String(els.length) }
176
+ },
177
+ data: {
178
+ details: {
179
+ reasonCode: 'DUPLICATE_ID',
180
+ id: value,
181
+ count: els.length
182
+ },
183
+ visibilityFilter: eligInfo || { targetSet: 'dom', accEligible: null, reasons: [] }
184
+ }
185
+ })
186
+ );
187
+ }
188
+ }
189
+ }
190
+
191
+ if (occurrences.length) {
192
+ return {
193
+ ruleId: rule.ruleId,
194
+ outcome: 'fail',
195
+ severity: rule.defaultSeverity || 'moderate',
196
+ occurrences
197
+ };
198
+ }
199
+
200
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
201
+ }
202
+
203
+ module.exports = { id, meta, runInPage };
@@ -135,8 +135,8 @@ function runInPage(ctx) {
135
135
  }
136
136
 
137
137
  // `title` is a weaker text-alternative mechanism than aria-label/
138
- // aria-labelledby — it is not reliably exposed to assistive technology
139
- // in every context (e.g. touch/mobile) — so a pass achieved only via
138
+ // aria-labelledby, it is not reliably exposed to assistive technology
139
+ // in every context (e.g. touch/mobile), so a pass achieved only via
140
140
  // `title` is reported at reduced confidence rather than the rule's
141
141
  // default `high`.
142
142
  let anyPassedViaWeakMechanism = false;
@@ -13,12 +13,29 @@
13
13
  *
14
14
  * WCAG mapping: matches technique H44 ("Using label elements to associate
15
15
  * text labels with form controls"), which WCAG's own Techniques document
16
- * lists as sufficient for 1.3.1, 3.3.2, AND 4.1.2 simultaneously — a label
16
+ * lists as sufficient for 1.3.1, 3.3.2, AND 4.1.2 simultaneously. A label
17
17
  * that programmatically associates with a control conveys the
18
18
  * relationship (1.3.1), provides the instruction (3.3.2), and exposes the
19
19
  * accessible name (4.1.2) all at once. This rule was originally only wired
20
20
  * to 4.1.2, even though its own `tags` already listed wcag131/wcag332 and
21
21
  * wcag-facets.js already had matching facet ids under those SCs.
22
+ *
23
+ * @applicability
24
+ * Applies to <input>, <select> and <textarea> elements included in the
25
+ * accessibility tree, excluding the input types hidden, submit, reset,
26
+ * button and image, which take their name from a value or alt attribute
27
+ * rather than from a label. A control carrying
28
+ * an explicit ARIA widget role is out of scope, button, checkbox,
29
+ * combobox, listbox, textbox, slider and the rest of ROLE_OWNED_ELSEWHERE
30
+ * each have a naming rule of their own, and role="presentation"/"none"
31
+ * removes a control unless it is still tabbable.
32
+ * @expectation
33
+ * Each applicable control carries a programmatic label by one of the
34
+ * mechanisms helpers.getLabelMethod resolves, in its priority order: an
35
+ * associated <label>, aria-labelledby, aria-label, title, then
36
+ * placeholder. Any of the five satisfies this rule. Whether the weaker two
37
+ * are an appropriate primary label is a separate question, asked by
38
+ * form-control-programmatic-label-quality.
22
39
  */
23
40
 
24
41
  const id = 'form-control-programmatic-label-present';
@@ -162,7 +179,7 @@ function runInPage(ctx) {
162
179
 
163
180
  // getLabelMethod is provided by the shared dom-helpers bundle that
164
181
  // dom-runner.js always constructs for every rule execution (built-in or
165
- // custom) — see createDomHelpers's own getLabelMethod, which implements
182
+ // custom), see createDomHelpers's own getLabelMethod, which implements
166
183
  // this exact <label>/aria-labelledby/aria-label/title/placeholder
167
184
  // priority order. No local reimplementation is needed as a fallback.
168
185
  function getLabelMethodSafe(el) {
@@ -13,7 +13,7 @@
13
13
  * hidden/submit/reset/button/image; select; textarea).
14
14
  * @expectation
15
15
  * At most one <label> that can contribute to the control's accessible name
16
- * is associated with it — by wrapping it, or by a <label for="..."> on its
16
+ * is associated with it, by wrapping it, or by a <label for="..."> on its
17
17
  * id (a label that both wraps and self-references via for counts once).
18
18
  * Graded by whether the surplus labels actually compete for the name:
19
19
  * - PASS when an override (aria-labelledby / aria-label) supersedes every
@@ -173,6 +173,15 @@ function runInPage(ctx) {
173
173
  hintKey: 'formControlSingleLabel_hint_cantTell',
174
174
  params: { element: tag, labelCount: String(eligibleLabels.size) }
175
175
  },
176
+ uncertainty: {
177
+ code: 'spec-only',
178
+ needed: 'Whether the empty label is filled in at runtime or is simply redundant.',
179
+ evidence: {
180
+ element: tag,
181
+ labelCount: eligibleLabels.size,
182
+ contributingLabelCount: contributing.length
183
+ }
184
+ },
176
185
  data: {
177
186
  details: {
178
187
  reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
@@ -0,0 +1,229 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * @check identical-iframes-same-purpose
7
+ * @atomic true
8
+ * @summary Frames sharing an accessible name must embed the same resource
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to each set of two or more <iframe>/<frame> elements that are
13
+ * included in the accessibility tree and share the same non-empty
14
+ * accessible name, compared with whitespace collapsed. A frame named
15
+ * only by a mechanism that names nothing, or hidden from the
16
+ * accessibility tree, is not part of a set; a set needs two surviving
17
+ * members to exist at all.
18
+ * @expectation
19
+ * Every frame in a set resolves to the same resource. A shared name
20
+ * describes one resource, so two frames answering to it must embed the
21
+ * same one.
22
+ * @implementation-notes
23
+ * - Distinct from iframe-title-unique, which asks the stricter question of
24
+ * whether the title ATTRIBUTE repeats at all, and answers it from static
25
+ * markup. This rule keys on the computed accessible name and judges the
26
+ * resource behind it.
27
+ * - src values are compared as resolved absolute URLs with the fragment
28
+ * removed and a trailing slash normalised away, so a directory written
29
+ * both with and without one is a single resource.
30
+ * - Frames that resolve to different URLs are reported cantTell, never
31
+ * fail. Different resources can still be equivalent — differently worded
32
+ * copies of one page, or two adverts serving the same purpose — and
33
+ * nothing in the markup settles it. Comparing the embedded documents
34
+ * would not settle it either, since content differing is exactly what
35
+ * those equivalent cases look like.
36
+ */
37
+
38
+ const id = 'identical-iframes-same-purpose';
39
+
40
+ const meta = {
41
+ title: 'Frames with the same name embed the same resource',
42
+ description:
43
+ 'Checks that <iframe>/<frame> elements sharing an accessible name embed the same resource, since one name can only describe one resource.',
44
+ i18n: {
45
+ titleKey: 'identicalIframesSamePurpose_title',
46
+ descriptionKey: 'identicalIframesSamePurpose_description'
47
+ },
48
+ helpUrl: null,
49
+ tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
50
+ wcagSc: ['4.1.2'],
51
+ normativeMappings: [
52
+ {
53
+ standard: 'WCAG',
54
+ version: '2.2',
55
+ requirement: '4.1.2',
56
+ title: 'Name, Role, Value',
57
+ conformanceLevel: 'A'
58
+ }
59
+ ],
60
+ defaultSeverity: 'moderate',
61
+ category: 'robust',
62
+ type: 'automatic',
63
+ defaultConfidence: 'medium',
64
+ coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
65
+ };
66
+
67
+ function runInPage(ctx) {
68
+ const { helpers, rule } = ctx;
69
+
70
+ const nodes = helpers.queryAllSmart
71
+ ? helpers.queryAllSmart('iframe, frame')
72
+ : helpers.queryAll('iframe, frame');
73
+
74
+ function normalizedName(el) {
75
+ if (!helpers.getAccessibleNameInfo) return '';
76
+ let info;
77
+ try {
78
+ info = helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 });
79
+ } catch {
80
+ return '';
81
+ }
82
+ if (!info || !info.present || !info.value) return '';
83
+ return String(info.value).replace(/\s+/g, ' ').trim();
84
+ }
85
+
86
+ // A light-DOM child of a shadow host with no slot to land in is absent from
87
+ // the flat tree and so renders nowhere, which the shared eligibility helper
88
+ // does not model.
89
+ function isUnslotted(el) {
90
+ try {
91
+ let cur = el;
92
+ let guard = 0;
93
+ while (cur && cur.nodeType === 1 && guard++ < 100) {
94
+ const parent = cur.parentNode;
95
+ if (!parent || parent.nodeType !== 1) return false;
96
+ if (parent.shadowRoot && cur.assignedSlot == null) return true;
97
+ cur = parent;
98
+ }
99
+ return false;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function inAccessibilityTree(el) {
106
+ if (isUnslotted(el)) return false;
107
+ if (!helpers.isIncludedInAccessibilityTree) return true;
108
+ try {
109
+ return !!helpers.isIncludedInAccessibilityTree(el);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ // A directory written with and without its trailing slash is one resource,
116
+ // and a fragment selects within a resource rather than naming another.
117
+ function resourceKey(el) {
118
+ let raw;
119
+ try {
120
+ raw = el.getAttribute('src');
121
+ } catch {
122
+ return null;
123
+ }
124
+ if (raw == null || !String(raw).trim()) return null;
125
+
126
+ const doc = (ctx && ctx.document) || (el.ownerDocument ? el.ownerDocument : null);
127
+ const base = doc && doc.baseURI ? doc.baseURI : undefined;
128
+ try {
129
+ const u = new URL(String(raw).trim(), base);
130
+ let pathname = u.pathname;
131
+ if (pathname.length > 1 && pathname.charAt(pathname.length - 1) === '/') {
132
+ pathname = pathname.slice(0, -1);
133
+ }
134
+ return u.protocol + '//' + u.host + pathname + u.search;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ const groups = new Map();
141
+
142
+ for (const el of nodes) {
143
+ if (!el || !el.tagName) continue;
144
+ if (!inAccessibilityTree(el)) continue;
145
+
146
+ const name = normalizedName(el);
147
+ if (!name) continue;
148
+
149
+ const list = groups.get(name);
150
+ if (list) list.push(el);
151
+ else groups.set(name, [el]);
152
+ }
153
+
154
+ const sets = [];
155
+ for (const [name, els] of groups) {
156
+ if (els.length >= 2) sets.push([name, els]);
157
+ }
158
+
159
+ if (!sets.length) {
160
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
161
+ }
162
+
163
+ const occurrences = [];
164
+
165
+ for (const [name, els] of sets) {
166
+ const keys = els.map(resourceKey);
167
+ const resolved = keys.filter((k) => k != null);
168
+ const allResolved = resolved.length === keys.length;
169
+ const allSame = allResolved && resolved.every((k) => k === resolved[0]);
170
+ if (allSame) continue;
171
+
172
+ for (let i = 0; i < els.length; i++) {
173
+ const el = els[i];
174
+ const tag = el.tagName.toLowerCase();
175
+ occurrences.push(
176
+ helpers.reportOccurrence(el, {
177
+ summary:
178
+ 'This frame shares its accessible name with another frame that embeds a different resource.',
179
+ hint: 'Give each frame a name describing the resource it embeds, or point them at the same resource.',
180
+ i18n: {
181
+ summaryKey: 'identicalIframesSamePurpose_summary_cantTell',
182
+ hintKey: 'identicalIframesSamePurpose_hint_cantTell',
183
+ params: { element: tag, name }
184
+ },
185
+ uncertainty:
186
+ keys[i] == null
187
+ ? {
188
+ code: 'not-computable',
189
+ needed: 'A resolvable src for this frame.',
190
+ evidence: { element: tag, name, setSize: els.length }
191
+ }
192
+ : {
193
+ code: 'equivalence-unknown',
194
+ needed: 'Whether the two resources serve the same purpose despite differing.',
195
+ evidence: {
196
+ element: tag,
197
+ name,
198
+ resource: keys[i],
199
+ otherResources: resolved.filter((k) => k !== keys[i]),
200
+ setSize: els.length
201
+ }
202
+ },
203
+ data: {
204
+ details: {
205
+ reasonCode:
206
+ keys[i] == null ? 'IFRAME_RESOURCE_UNRESOLVED' : 'IFRAME_RESOURCE_DIFFERS',
207
+ element: tag,
208
+ name,
209
+ resource: keys[i],
210
+ setSize: els.length
211
+ }
212
+ }
213
+ })
214
+ );
215
+ }
216
+ }
217
+
218
+ if (occurrences.length) {
219
+ return {
220
+ ruleId: rule.ruleId,
221
+ outcome: 'cantTell',
222
+ severity: rule.defaultSeverity || 'moderate',
223
+ occurrences
224
+ };
225
+ }
226
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
227
+ }
228
+
229
+ module.exports = { id, meta, runInPage };
@@ -11,7 +11,7 @@
11
11
  * @applicability
12
12
  * Applies to <iframe>/<frame> elements with an explicit negative
13
13
  * tabindex, whose embedded document is same-origin and reachable via
14
- * contentDocument (cross-origin/unreachable frames assert nothing — see
14
+ * contentDocument (cross-origin/unreachable frames assert nothing, see
15
15
  * implementation notes).
16
16
  * @expectation
17
17
  * The frame's embedded document contains no focusable element. Browsers
@@ -19,14 +19,26 @@
19
19
  * document: Tab can still reach focusable content inside, even though
20
20
  * the frame itself is skipped. An author who set tabindex="-1" intending
21
21
  * to remove the frame from the tab order has not actually done so if the
22
- * embedded document contains focusable content.
22
+ * embedded document contains focusable content. Exception: an iframe with
23
+ * both a `width` and `height` HTML attribute of 2px or less (a common
24
+ * "tracking pixel" pattern) cannot render any perceptible content, so
25
+ * focusable content inside it never satisfies ACT akn7bn's "visible"
26
+ * requirement and doesn't count.
23
27
  * @implementation-notes
24
- * - Deliberately scoped to same-origin, currently-accessible content only
28
+ * - Scoped to same-origin, currently-accessible content only
25
29
  * (contentDocument access is wrapped in try/catch and treated as "no
26
- * constraint asserted" — not counted as applicable — when unreachable),
30
+ * constraint asserted", not counted as applicable, when unreachable),
27
31
  * matching this engine's established scope-limiting rationale (see
28
32
  * src/core/aria-helpers.js file header) rather than guessing at
29
33
  * cross-origin content.
34
+ * - A `srcdoc` iframe's document is same-origin by definition, but some
35
+ * environments (notably jsdom, including this library's own Node/jsdom
36
+ * integration path, see docs/INTEGRATION.md) never populate
37
+ * `contentDocument` from the attribute. When the live document looks
38
+ * empty and a `srcdoc` attribute is present, its HTML string is parsed
39
+ * directly via DOMParser as a static fallback, no rendering pipeline
40
+ * needed, and a real browser's already-loaded contentDocument is always
41
+ * preferred untouched.
30
42
  * - Focusability inside the embedded document is checked with a small,
31
43
  * self-contained heuristic (native interactive tags + non-negative
32
44
  * tabindex) rather than ctx.helpers.getFocusableInfo, since that helper
@@ -67,15 +79,15 @@ function runInPage(ctx) {
67
79
  const { helpers, rule, document } = ctx;
68
80
 
69
81
  // Self-contained rendering check for the embedded document (a distinct
70
- // realm — see this rule's own header comment on why the outer
82
+ // realm, see this rule's own header comment on why the outer
71
83
  // document's shared eligibility helpers can't be reused here).
72
- // Deliberately checks only genuine non-rendering (display:none,
84
+ // Checks only genuine non-rendering (display:none,
73
85
  // visibility:hidden, the hidden attribute) via the ancestor chain, NOT
74
86
  // aria-hidden: aria-hidden alone does not remove an element from a real
75
87
  // browser's native tab order (the same anti-pattern this engine's own
76
88
  // aria-hidden-focus rule exists to catch), so an aria-hidden-but-
77
89
  // visually-rendered focusable element inside the frame is still
78
- // genuinely reachable by keyboard and must stay flagged.
90
+ // reachable by keyboard and must stay flagged.
79
91
  function isRenderedInDoc(doc, el) {
80
92
  try {
81
93
  const view = doc.defaultView;
@@ -296,6 +308,44 @@ function runInPage(ctx) {
296
308
  return !Number.isNaN(n) && n < 0;
297
309
  }
298
310
 
311
+ // A `srcdoc` iframe's embedded document is same-origin by definition, but
312
+ // some environments (jsdom, notably) never populate `contentDocument`
313
+ // from the attribute at all. Parsing the attribute's own HTML string is a
314
+ // static, deterministic fallback that needs no rendering pipeline. It
315
+ // only kicks in when the live document looks empty, so a real browser's
316
+ // already-loaded contentDocument is always preferred untouched.
317
+ function parseSrcdocFallback(el) {
318
+ try {
319
+ const raw = el.getAttribute('srcdoc');
320
+ if (raw == null) return null;
321
+ const view = el.ownerDocument && el.ownerDocument.defaultView;
322
+ const DOMParserCtor = view && view.DOMParser;
323
+ if (!DOMParserCtor) return null;
324
+ return new DOMParserCtor().parseFromString(raw, 'text/html');
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ // ACT akn7bn's own Expectation only cares about focusable content that is
331
+ // also *visible*: a 1x1 (or similar tracking-pixel-sized) iframe cannot
332
+ // render any perceptible content, whatever's focusable inside it. Scoped
333
+ // to the iframe's own HTML width/height attributes, a static, always-
334
+ // readable signal, unlike computed/rendered size, which needs real
335
+ // layout jsdom doesn't have (see docs/LIMITATIONS.md).
336
+ function isIframeVisiblyTiny(el) {
337
+ try {
338
+ const wAttr = el.getAttribute('width');
339
+ const hAttr = el.getAttribute('height');
340
+ if (wAttr == null || hAttr == null) return false;
341
+ const w = Number(String(wAttr).trim());
342
+ const h = Number(String(hAttr).trim());
343
+ return Number.isFinite(w) && Number.isFinite(h) && w <= 2 && h <= 2;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
299
349
  const nodes = helpers.queryAllSmart
300
350
  ? helpers.queryAllSmart('iframe, frame')
301
351
  : helpers.queryAll('iframe, frame');
@@ -314,12 +364,18 @@ function runInPage(ctx) {
314
364
  } catch {
315
365
  contentDoc = null;
316
366
  }
367
+ const looksEmpty = !contentDoc || !contentDoc.body || !contentDoc.body.hasChildNodes();
368
+ if (looksEmpty && el.getAttribute('srcdoc') != null) {
369
+ const parsed = parseSrcdocFallback(el);
370
+ if (parsed) contentDoc = parsed;
371
+ }
317
372
  if (!contentDoc || !contentDoc.querySelectorAll) continue; // cross-origin/unreachable: no constraint asserted
318
373
 
319
374
  applicableCount += 1;
320
375
 
321
376
  const candidates = getFocusableCandidates(contentDoc);
322
377
  if (!candidates.length) continue;
378
+ if (isIframeVisiblyTiny(el)) continue; // ACT akn7bn: no visible content at all
323
379
 
324
380
  const tag = el.tagName.toLowerCase();
325
381
  const shouldProbe = candidates.length === 1;
@@ -334,6 +390,11 @@ function runInPage(ctx) {
334
390
  'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
335
391
  hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
336
392
  i18n: null,
393
+ uncertainty: {
394
+ code: 'runtime-dependent',
395
+ needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
396
+ evidence: { element: tag, focusRedirected: true }
397
+ },
337
398
  data: {
338
399
  details: {
339
400
  reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',