@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -5,34 +5,36 @@
5
5
  /**
6
6
  * @check deprecated-elements-not-used
7
7
  * @atomic true
8
- * @summary Obsolete non-stoppable elements must not be used
8
+ * @summary Scrolling <marquee> content needs a way to pause, stop, or hide it
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.2
11
11
  * @applicability
12
- * Applies to any <blink> or <marquee> element present in scope. These are
13
- * obsolete, non-standard HTML elements whose defining behavior (blinking
14
- * or auto-scrolling text) has no built-in user mechanism to pause, stop,
15
- * or hide it.
12
+ * Applies to any scan scope; whether it contains a <marquee> element is
13
+ * always an answerable question. <marquee> is an obsolete, non-standard
14
+ * HTML element that browsers still render as auto-scrolling text, with no
15
+ * built-in user mechanism to pause, stop, or hide it.
16
16
  * @expectation
17
- * Neither element is present. Since their movement can never be paused,
18
- * stopped, or hidden by the user, presence is itself the violation. This
19
- * rule has no partial-pass case (it reports only when the element is
20
- * found).
17
+ * Each <marquee> is reported as cantTell: the scrolling itself is certain,
18
+ * but a page can offer its own pause or stop control (a button calling the
19
+ * element's stop() method, for example), and then failure F16 does not
20
+ * apply. Whether such a control exists is for a person to check. A scope
21
+ * with no <marquee> passes; there is no separate not-applicable case.
21
22
  * @implementation-notes
22
- * - Not rule-gated on isAccTreeEligible: presence in markup is itself the
23
- * violation, independent of visibility (moving/blinking content inside a
24
- * hidden ancestor could still become visible later without a code
25
- * change, so hiding it today does not remove the underlying defect).
26
- * Engine-level hidden-subtree filtering still applies unless
27
- * engineOptions.includeHiddenElements is true.
23
+ * - <blink> is not reported. No current browser makes it blink: it renders
24
+ * as an unknown inline element, so it holds no blinking content for 2.2.2
25
+ * to govern.
26
+ * - Not rule-gated on isAccTreeEligible: presence in markup is what the rule
27
+ * asks about, independent of visibility. Engine-level hidden-subtree
28
+ * filtering still applies unless engineOptions.includeHiddenElements is
29
+ * true.
28
30
  */
29
31
 
30
32
  const id = 'deprecated-elements-not-used';
31
33
 
32
34
  const meta = {
33
- title: 'Obsolete non-stoppable elements (<blink>, <marquee>) must not be used',
35
+ title: 'Scrolling <marquee> content must be possible to pause, stop, or hide',
34
36
  description:
35
- 'Checks that deprecated, non-standard HTML elements whose blinking/scrolling content cannot be paused, stopped, or hidden by the user (<blink>, <marquee>) are not present.',
37
+ 'Asks, for each obsolete <marquee> element, whether the page offers a way to pause, stop, or hide its auto-scrolling content, since the element itself has none.',
36
38
  i18n: {
37
39
  titleKey: 'deprecatedElements_title',
38
40
  descriptionKey: 'deprecatedElements_description'
@@ -60,47 +62,50 @@ function runInPage(ctx) {
60
62
  const { helpers, rule } = ctx;
61
63
 
62
64
  const nodes = helpers.queryAllSmart
63
- ? helpers.queryAllSmart('blink, marquee')
64
- : helpers.queryAll('blink, marquee');
65
+ ? helpers.queryAllSmart('marquee')
66
+ : helpers.queryAll('marquee');
65
67
 
66
68
  const occurrences = [];
67
- let applicableCount = 0;
68
69
 
69
70
  for (const el of nodes) {
70
71
  if (!el || !el.tagName) continue;
71
72
 
72
- applicableCount += 1;
73
-
74
73
  const tag = el.tagName.toLowerCase();
75
74
 
76
75
  occurrences.push(
77
76
  helpers.reportOccurrence(el, {
78
- summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
79
- hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
77
+ summary:
78
+ 'This <marquee> scrolls its content, and the element itself gives the user no way to pause, stop, or hide it.',
79
+ hint: 'Check that the page offers a control that pauses, stops, or hides this content. Better: replace it with static content, or with an animation that has a pause/stop control.',
80
+ occurrenceOutcome: 'cantTell',
80
81
  i18n: {
81
- summaryKey: 'deprecatedElements_summary_fail',
82
- hintKey: 'deprecatedElements_hint_fail',
82
+ summaryKey: 'deprecatedElements_summary_cantTell',
83
+ hintKey: 'deprecatedElements_hint_cantTell',
83
84
  params: { element: tag }
84
85
  },
86
+ uncertainty: {
87
+ code: 'runtime-dependent',
88
+ needed:
89
+ 'Whether the page provides a control that pauses, stops, or hides the scrolling content.',
90
+ evidence: { element: tag }
91
+ },
85
92
  data: {
86
- details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
93
+ details: { reasonCode: 'MARQUEE_PAUSE_MECHANISM_UNKNOWN', element: tag }
87
94
  }
88
95
  })
89
96
  );
90
97
  }
91
98
 
92
- if (applicableCount === 0) {
93
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
94
- }
95
- if (occurrences.length) {
96
- return {
97
- ruleId: rule.ruleId,
98
- outcome: 'fail',
99
- severity: rule.defaultSeverity || 'serious',
100
- occurrences
101
- };
99
+ // No <marquee> in scope is itself the passing case (see @expectation above).
100
+ if (!occurrences.length) {
101
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
102
102
  }
103
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
103
+ return {
104
+ ruleId: rule.ruleId,
105
+ outcome: 'cantTell',
106
+ severity: rule.defaultSeverity || 'serious',
107
+ occurrences
108
+ };
104
109
  }
105
110
 
106
111
  module.exports = { id, meta, runInPage };
@@ -5,14 +5,16 @@
5
5
  /**
6
6
  * @check dialog-name-present
7
7
  * @atomic true
8
- * @summary Elements with role="dialog"/"alertdialog" must have an accessible name
8
+ * @summary Dialogs (role="dialog"/"alertdialog" or a native <dialog>) must have an accessible name
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
- * Applies to elements carrying role="dialog" or role="alertdialog" (the
13
- * attribute must name one of those roles alone, not a fallback list) that
14
- * are included in the accessibility tree. A native <dialog> without an
15
- * explicit role is out of scope.
12
+ * Applies to elements included in the accessibility tree whose role is
13
+ * dialog or alertdialog: an element whose role attribute resolves to one
14
+ * of those roles (the first token that names a concrete ARIA role wins,
15
+ * so role="alertdialog dialog" is an alertdialog), and a native <dialog>
16
+ * whose role attribute is absent or names no concrete role (its implicit
17
+ * role is dialog). A closed <dialog> is hidden and so not in scope.
16
18
  * @expectation
17
19
  * The element has a non-empty accessible name from aria-label, from an
18
20
  * aria-labelledby that resolves to non-empty text, or from title. Both
@@ -25,7 +27,7 @@ const id = 'dialog-name-present';
25
27
  const meta = {
26
28
  title: 'Dialogs have an accessible name',
27
29
  description:
28
- 'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
30
+ 'Checks that dialogs (elements with role="dialog" or role="alertdialog", and native <dialog> elements) expose a non-empty accessible name.',
29
31
  i18n: {
30
32
  titleKey: 'dialogNamePresent_title',
31
33
  descriptionKey: 'dialogNamePresent_description'
@@ -115,7 +117,8 @@ function runInPage(ctx) {
115
117
  const occurrences = [];
116
118
  let applicableCount = 0;
117
119
 
118
- const selector = '[role="dialog"],[role="alertdialog"]';
120
+ // Filtered below by the resolved role.
121
+ const selector = 'dialog,[role]';
119
122
  const nodes = helpers.queryAllSmart
120
123
  ? helpers.queryAllSmart(selector)
121
124
  : helpers.queryAll(selector);
@@ -137,12 +140,28 @@ function runInPage(ctx) {
137
140
  return { ok: false, method: 'none' };
138
141
  }
139
142
 
143
+ // The role the browser uses: the first token of the role attribute that
144
+ // names a concrete ARIA role, else the element's implicit role (dialog for
145
+ // a native <dialog>; the others are not in scope here).
146
+ const aria = helpers && helpers.aria;
147
+ function resolveRole(el) {
148
+ const tokens = getAttr(el, 'role').toLowerCase().split(' ').filter(Boolean);
149
+ for (const t of tokens) {
150
+ const concrete =
151
+ aria && typeof aria.isValidConcreteRole === 'function'
152
+ ? aria.isValidConcreteRole(t)
153
+ : t === 'dialog' || t === 'alertdialog';
154
+ if (concrete) return t;
155
+ }
156
+ return String(el.tagName || '').toLowerCase() === 'dialog' ? 'dialog' : '';
157
+ }
158
+
140
159
  for (const el of nodes) {
141
160
  if (!el) continue;
142
- if (!isEligibleAcc(helpers, el, ctx)) continue;
143
161
 
144
- const role = getAttr(el, 'role').toLowerCase();
162
+ const role = resolveRole(el);
145
163
  if (role !== 'dialog' && role !== 'alertdialog') continue;
164
+ if (!isEligibleAcc(helpers, el, ctx)) continue;
146
165
 
147
166
  applicableCount += 1;
148
167
 
@@ -128,6 +128,11 @@ function runInPage(ctx) {
128
128
  hintKey: 'duplicateIdAria_hint_cantTell',
129
129
  params: { id: refId, duplicateCount: String(els.length) }
130
130
  },
131
+ uncertainty: {
132
+ code: 'judgement-required',
133
+ needed: 'Whether the first element carrying this id is the intended target.',
134
+ evidence: { id: refId, duplicateCount: els.length, resolvesTo: 'first' }
135
+ },
131
136
  data: {
132
137
  details: {
133
138
  reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
@@ -14,19 +14,25 @@
14
14
  * element renders or not, which is why ACT 3ea0c8 evaluates hidden
15
15
  * elements too.
16
16
  * @expectation
17
- * No other element in the same tree carries the same id value. Ids are
17
+ * No other element in the same tree carries the same id value, compared
18
+ * exactly as written (id="a " and id="a" are different ids). Ids are
18
19
  * scoped per document tree and per shadow tree, so the same id inside
19
20
  * two different shadow roots is not a duplicate.
20
21
  * @implementation-notes
21
22
  * - 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`. A
23
- * consumer targeting WCAG 2.2 excludes it with
24
- * `excludeTags: ['wcag22-removed']`; one targeting 2.0 or 2.1 keeps it
25
- * and gets a real 4.1.1 result. The alternative, dropping the SC
26
- * mapping entirely, would have made a genuine 2.0/2.1 failure
27
- * invisible to anyone conformance-testing against those versions. See
28
- * `docs/ENGINE_OPTIONS.md` for the tag, and `docs/DESIGN_CHALLENGES.md`
29
- * for the decision this reverses.
23
+ * rule is tagged `wcag2a` (its 2.0/2.1 origin) plus `wcag22-removed`.
24
+ * The engine acts on that tag itself: under a 2.2 target, which is the
25
+ * default, this rule still runs and still reports every duplicate it
26
+ * finds, but its fail is coerced to `cantTell` with a `wcagVersionScope`
27
+ * field saying why (see scopeOutcomeToWcagVersion in
28
+ * `src/core/dom-runner.js`). A consumer targeting 2.0 or 2.1
29
+ * (`engineOptions.wcagVersion`, or a tag set that implies it) gets the
30
+ * real 4.1.1 failure; one that would rather not see the rule at all
31
+ * under 2.2 still excludes it with `excludeTags: ['wcag22-removed']`.
32
+ * The alternative, dropping the SC mapping entirely, would have made a
33
+ * genuine 2.0/2.1 failure invisible to anyone conformance-testing
34
+ * against those versions. See `docs/ENGINE_OPTIONS.md` for the option
35
+ * and the tag, and `docs/DESIGN_CHALLENGES.md` for the decision history.
30
36
  * - The defect outlives its Success Criterion: a duplicate id breaks
31
37
  * `<label for>` association, fragment navigation, `getElementById`, and
32
38
  * every ID-reference attribute, none of which stopped mattering when
@@ -121,7 +127,10 @@ function runInPage(ctx) {
121
127
 
122
128
  for (const el of all) {
123
129
  if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
124
- const value = String(el.getAttribute('id') || '').trim();
130
+ // Compared as written: "a " and "a" are different ids in the DOM, and
131
+ // getElementById does not trim. Whitespace inside an id is a separate
132
+ // validity error, not a duplicate.
133
+ const value = String(el.getAttribute('id') || '');
125
134
  if (!value) continue;
126
135
 
127
136
  applicableCount += 1;
@@ -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
+ * - Frames are grouped by the computed accessible name, not the title
24
+ * attribute, so a title that aria-label or aria-labelledby overrides does
25
+ * not count. It replaces iframe-title-unique, deprecated in 1.8.0, which
26
+ * grouped them by the title attribute.
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 };
@@ -386,10 +386,18 @@ function runInPage(ctx) {
386
386
  if (runtimeProbe && runtimeProbe.redirected) {
387
387
  cantTellOccurrences.push(
388
388
  helpers.reportOccurrence(el, {
389
- summary:
390
- 'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
391
- hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
392
- i18n: null,
389
+ summary: `This <${tag}> has tabindex="-1" and focusable content, but focus moves immediately to another target. Verify keyboard reachability in a real browser.`,
390
+ hint: 'If this is an intentional focus handoff, make sure keyboard users cannot remain on hidden or intermediate frame content.',
391
+ i18n: {
392
+ summaryKey: 'iframeFocusableContent_summary_cantTell_redirect',
393
+ hintKey: 'iframeFocusableContent_hint_cantTell_redirect',
394
+ params: { element: tag }
395
+ },
396
+ uncertainty: {
397
+ code: 'runtime-dependent',
398
+ needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
399
+ evidence: { element: tag, focusRedirected: true }
400
+ },
393
401
  data: {
394
402
  details: {
395
403
  reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',