@surea11y/core 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -9,13 +9,13 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.4
11
11
  * @applicability
12
- * Any accessible (same-document, non-cross-origin) stylesheet — inline
12
+ * Any accessible (same-document, non-cross-origin) stylesheet, inline
13
13
  * `<style>` blocks and same-origin `<link>` stylesheets already loaded
14
14
  * into `document.styleSheets`.
15
15
  * @expectation
16
16
  * No `@media (orientation: portrait)` or `@media (orientation:
17
17
  * landscape)` block sets a `transform`/`-webkit-transform`/`rotate`
18
- * rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270) —
18
+ * rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270),
19
19
  * the well-known technique for visually forcing one orientation
20
20
  * regardless of the device's actual orientation, which defeats WCAG
21
21
  * 1.3.4's requirement that content not restrict its view to a single
@@ -27,14 +27,15 @@
27
27
  * lock. Compute the actual rotation angle and only flag ~90/~270 degrees,
28
28
  * excluding ~0/~180 (a no-op or a flip, neither of which changes
29
29
  * portrait<->landscape).
30
- * - Only `rotate`/`rotateZ` (transform functions) and the standalone CSS
31
- * `rotate` property are parsed for degrees, from the two sources
32
- * `style.transform`/`style.rotate`; `matrix()`/`matrix3d()`/`rotate3d()`
33
- * are not decomposed into an equivalent angle (deliberately deferred as
34
- * the same class of higher-complexity/lower-value work deferred elsewhere,
35
- * e.g. `table-th-has-data-cells`'s narrower positional-header algorithm).
30
+ * - `rotate`/`rotateZ`, the standalone CSS `rotate` property, and
31
+ * `rotate3d(x, y, z, angle)`/`matrix()`/`matrix3d()` are all parsed for
32
+ * degrees. The matrix forms only yield an angle when they resolve to a
33
+ * pure rotation about the Z axis (no scale, skew, translation, or
34
+ * rotation combined with another axis). Anything else contributes 0,
35
+ * same as an unrecognized value, rather than guessing at an angle a
36
+ * general 3D matrix doesn't uniquely have.
36
37
  * - Cross-origin stylesheets throw on `.cssRules` access (browser
37
- * security model) and are skipped — same class of limitation as any
38
+ * security model) and are skipped, same class of limitation as any
38
39
  * check that can only see same-origin/inspectable content (compare
39
40
  * `iframe-focusable-content`).
40
41
  * - Not per-element: this is a whole-document/whole-stylesheet concern,
@@ -107,25 +108,98 @@ function runInPage(ctx) {
107
108
  return value; // deg
108
109
  }
109
110
 
110
- // Sums the degrees of every rotate()/rotateZ() function found in a
111
- // transform value (a real transform can legitimately compose more than
112
- // one, e.g. "translate(-50%) rotate(90deg)").
111
+ function parseNumberList(argsStr) {
112
+ return trim(argsStr)
113
+ .split(',')
114
+ .map((s) => parseFloat(s.trim()));
115
+ }
116
+
117
+ // A 2D linear map (a, b, c, d) -- the first four arguments of
118
+ // `matrix(a, b, c, d, e, f)`, or the top-left of `matrix3d`'s 4x4 --
119
+ // is a pure rotation only when its columns are unit length and
120
+ // orthogonal (no scale/skew/reflection): a = cos(theta), b = sin(theta),
121
+ // c = -sin(theta), d = cos(theta). Returns degrees, or null when the
122
+ // map isn't a pure rotation.
123
+ function decomposeMatrix2dRotation(a, b, c, d) {
124
+ const EPS = 1e-3;
125
+ if (Math.abs(a - d) > EPS || Math.abs(b + c) > EPS) return null;
126
+ if (Math.abs(a * a + b * b - 1) > EPS) return null;
127
+ if (Math.abs(a * d - b * c - 1) > EPS) return null;
128
+ return (Math.atan2(b, a) * 180) / Math.PI;
129
+ }
130
+
131
+ // The 16 column-major values of `matrix3d(...)` represent a pure Z-axis
132
+ // rotation only when the 3rd/4th columns still match the identity (no
133
+ // translation, perspective, or rotation around another axis) and the
134
+ // top-left 2x2 block is a pure 2D rotation.
135
+ function decomposeMatrix3dZRotation(v) {
136
+ if (v.length !== 16 || !v.every(Number.isFinite)) return null;
137
+ const EPS = 1e-3;
138
+ const near = (x, t) => Math.abs(x - t) < EPS;
139
+ if (!(near(v[2], 0) && near(v[3], 0) && near(v[6], 0) && near(v[7], 0))) return null;
140
+ if (!(near(v[8], 0) && near(v[9], 0) && near(v[10], 1) && near(v[11], 0))) return null;
141
+ if (!(near(v[12], 0) && near(v[13], 0) && near(v[14], 0) && near(v[15], 1))) return null;
142
+ return decomposeMatrix2dRotation(v[0], v[1], v[4], v[5]);
143
+ }
144
+
145
+ // Sums the degrees of every rotate()/rotateZ()/rotate3d()/matrix()/
146
+ // matrix3d() function found in a transform value (a real transform can
147
+ // legitimately compose more than one, e.g. "translate(-50%)
148
+ // rotate(90deg)"). The matrix forms only contribute when they decompose
149
+ // to a pure Z rotation; anything else contributes 0, same as no match.
113
150
  function rotateDegreesFromTransform(t) {
114
151
  if (!t) return 0;
115
152
  let total = 0;
116
- const re = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
117
153
  let m;
118
- while ((m = re.exec(t)) !== null) {
154
+
155
+ const rotateRe = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
156
+ while ((m = rotateRe.exec(t)) !== null) {
119
157
  total += angleToDegrees(m[1]);
120
158
  }
159
+
160
+ const rotate3dRe = /rotate3d\s*\(([^)]*)\)/gi;
161
+ while ((m = rotate3dRe.exec(t)) !== null) {
162
+ const parts = m[1].split(',');
163
+ if (parts.length !== 4) continue;
164
+ const x = parseFloat(parts[0]);
165
+ const y = parseFloat(parts[1]);
166
+ const z = parseFloat(parts[2]);
167
+ const deg = angleToDegrees(parts[3]);
168
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z) || !deg) continue;
169
+ const EPS = 1e-3;
170
+ if (Math.abs(x) < EPS && Math.abs(y) < EPS && Math.abs(Math.abs(z) - 1) < EPS) {
171
+ total += z < 0 ? -deg : deg;
172
+ }
173
+ }
174
+
175
+ const matrixRe = /matrix\s*\(([^)]*)\)/gi;
176
+ while ((m = matrixRe.exec(t)) !== null) {
177
+ const vals = parseNumberList(m[1]);
178
+ if (vals.length === 6 && vals.every(Number.isFinite)) {
179
+ const deg = decomposeMatrix2dRotation(vals[0], vals[1], vals[2], vals[3]);
180
+ if (deg != null) total += deg;
181
+ }
182
+ }
183
+
184
+ const matrix3dRe = /matrix3d\s*\(([^)]*)\)/gi;
185
+ while ((m = matrix3dRe.exec(t)) !== null) {
186
+ const deg = decomposeMatrix3dZRotation(parseNumberList(m[1]));
187
+ if (deg != null) total += deg;
188
+ }
189
+
121
190
  return total;
122
191
  }
123
192
 
124
193
  // Whole-page-orientation-lock detection (see @implementation-notes): a
125
194
  // rotation near 0 or 180 degrees (mod 180) is a no-op or a flip, neither
126
195
  // of which changes portrait<->landscape, so it's NOT a lock; only a
127
- // rotation near 90 or 270 degrees (mod 90, once the 0/180 case is
128
- // excluded) is.
196
+ // rotation near 90 or 270 degrees is. "Near" is a tolerance window, not
197
+ // exact equality: a `rad`/`grad`/`turn` value converts to a 90-degree
198
+ // rotation with floating-point remainder (e.g. `1.5708rad` is
199
+ // 90.0000210...deg, never exactly 90), and ACT's own failed examples
200
+ // include a -inexact 92.5deg, both must still register as a
201
+ // lock, which exact-modulo-equality (`% 90 === 0`) never does.
202
+ const LOCK_TOLERANCE_DEG = 5;
129
203
  function isLockingRotation(styleDecl) {
130
204
  if (!styleDecl) return false;
131
205
  const transformVal =
@@ -141,9 +215,12 @@ function runInPage(ctx) {
141
215
 
142
216
  if (!degrees) return false;
143
217
 
144
- const abs = Math.abs(degrees);
145
- if (Math.abs(abs - 180) % 180 <= 0) return false; // near 0/180: not a lock
146
- return Math.abs(abs - 90) % 90 <= 0; // near 90/270: a lock
218
+ // Normalize into [0, 180): a rotation and its mirror (rotation + 180)
219
+ // swap the same two axes, so only the position within one half-turn
220
+ // matters. A value near the 90-degree midpoint of that range is near
221
+ // 90 OR 270 in the original full-turn range.
222
+ const normalized = ((degrees % 180) + 180) % 180;
223
+ return Math.abs(normalized - 90) <= LOCK_TOLERANCE_DEG;
147
224
  }
148
225
 
149
226
  function isOrientationMedia(mediaText) {
@@ -16,17 +16,16 @@
16
16
  * level of wrapping div is allowed, matching how authors commonly group
17
17
  * dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
18
18
  * must contain BOTH (an unbalanced dt-without-dd or dd-without-dt is
19
- * invalid) — a flattened set with neither is vacuously fine, not a
19
+ * invalid). A flattened set with neither is vacuously fine, not a
20
20
  * violation (see implementation-notes). Any other direct or wrapped child
21
21
  * breaks the description-list semantics assistive technologies rely on.
22
22
  * @implementation-notes
23
- * - Only one level of <div> wrapping is flattened — a <div> nested inside
23
+ * - Only one level of <div> wrapping is flattened, a <div> nested inside
24
24
  * another wrapping <div> is not flattened further and its contents are
25
25
  * reported invalid.
26
26
  * - The dt/dd pairing is only required "when not empty". A flattened set
27
- * with NEITHER dt nor dd
28
- * — whether from an empty wrapping <div>, only <script>/<template>/
29
- * <style> content, or a genuinely childless <dl> — is not flagged; only
27
+ * with NEITHER dt nor dd, whether from an empty wrapping <div>, only
28
+ * <script>/<template>/<style> content, or a childless <dl>, is not flagged; only
30
29
  * an unbalanced dt/dd pairing is a real structural problem.
31
30
  * - Distinct, atomic decision from dlitem-parent-valid (the
32
31
  * inverse relationship: does a given <dt>/<dd> have a valid parent).
@@ -64,7 +63,7 @@ const meta = {
64
63
  function runInPage(ctx) {
65
64
  const { helpers, rule } = ctx;
66
65
 
67
- // Declared inside runInPage — see scripts/build-core.js header
66
+ // Declared inside runInPage, see scripts/build-core.js header
68
67
  // ("runInPage MUST be self-contained").
69
68
  const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
70
69
 
@@ -109,8 +108,8 @@ function runInPage(ctx) {
109
108
  }
110
109
  const dedupedInvalidTags = [...new Set(invalidTags)];
111
110
 
112
- // The dt/dd pairing is only required "when not empty" — a <dl> with
113
- // NEITHER dt nor dd (whether genuinely childless after flattening, only
111
+ // The dt/dd pairing is only required "when not empty", a <dl> with
112
+ // NEITHER dt nor dd (whether childless after flattening, only
114
113
  // passthrough script/template/style content, or an empty wrapping div)
115
114
  // is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
116
115
  // present without any dd, or vice versa) is a real structural problem.
@@ -15,7 +15,7 @@
15
15
  * or hide it.
16
16
  * @expectation
17
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
18
+ * stopped, or hidden by the user, presence is itself the violation. This
19
19
  * rule has no partial-pass case (it reports only when the element is
20
20
  * found).
21
21
  * @implementation-notes
@@ -2,6 +2,24 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check dialog-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="dialog"/"alertdialog" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
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.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title. Both
19
+ * roles are name-from-author-only, so the heading or body text inside the
20
+ * dialog is not accepted as its name unless aria-labelledby points at it.
21
+ */
22
+
5
23
  const id = 'dialog-name-present';
6
24
 
7
25
  const meta = {
@@ -54,13 +72,13 @@ function runInPage(ctx) {
54
72
  function resolveAriaLabelledbyText(document, el, maxRefs) {
55
73
  const raw = getAttr(el, 'aria-labelledby');
56
74
  if (!raw) return '';
57
- // Resolve via the shared getTextFromIdRefs helper — computes each
75
+ // Resolve via the shared getTextFromIdRefs helper, computes each
58
76
  // referenced element's own ACCESSIBLE NAME (aria-label, then
59
77
  // aria-labelledby, then a value-like name, then content, then title),
60
78
  // not just its content text. This matters when aria-labelledby points
61
79
  // at an <iframe title="..."> (e.g. BBC News' cookie-consent dialog,
62
80
  // pointing at an `<iframe title="SP Consent Message">`), whose only
63
- // name source is its title attribute — an iframe's content is
81
+ // name source is its title attribute, an iframe's content is
64
82
  // opaque/cross-origin per HTML-AAM, so "name from content" is always
65
83
  // empty. Computing only name-from-content of the referenced node would
66
84
  // silently miss the title fallback and report no accessible name at
@@ -20,8 +20,10 @@
20
20
  * still computed. Whether that element is the intended target depends on
21
21
  * author intent, which markup does not carry, so the outcome is cantTell.
22
22
  * @implementation-notes
23
- * - Scoped deliberately to ids referenced by ARIA, not the broader/
24
- * deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
23
+ * - Scoped to ids referenced by ARIA. The page-wide check
24
+ * lives in its own rule, `duplicate-id`, mapped to the WCAG 2.0/2.1 SC
25
+ * 4.1.1 that WCAG 2.2 removed; the two overlap on referenced ids and
26
+ * answer different questions there (see that rule's header).
25
27
  * - Document-wide by design: id uniqueness and ARIA id references are a
26
28
  * whole-document property, not scoped to a sub-root. Reported occurrences are
27
29
  * limited to the scanned scope.
@@ -59,7 +61,7 @@ const meta = {
59
61
  function runInPage(ctx) {
60
62
  const { document, helpers, rule } = ctx;
61
63
 
62
- // Declared inside runInPage — see scripts/build-core.js header
64
+ // Declared inside runInPage, see scripts/build-core.js header
63
65
  // ("runInPage MUST be self-contained").
64
66
  const IDREF_ATTRS = [
65
67
  'aria-labelledby',
@@ -0,0 +1,198 @@
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`. 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.
30
+ * - The defect outlives its Success Criterion: a duplicate id breaks
31
+ * `<label for>` association, fragment navigation, `getElementById`, and
32
+ * every ID-reference attribute, none of which stopped mattering when
33
+ * 4.1.1 was retired. The SC was removed because browsers recover from
34
+ * malformed markup, not because ids became free-form.
35
+ * - Scoping is per ROOT NODE, not per document: `getRootNode()` groups
36
+ * light DOM against the document and each shadow tree against itself,
37
+ * matching the DOM's own id-lookup scope. Two components that each use
38
+ * `id="title"` inside their own shadow root are correct markup and are
39
+ * not reported.
40
+ * - Overlaps `duplicate-id-aria` by design, and the two say different
41
+ * things. That rule reports a duplicate id that an ARIA attribute
42
+ * actually references, as a `cantTell` under 4.1.2, the reference
43
+ * resolves to the first match, so whether the right element was named is
44
+ * an authoring question. This one is the flat structural fact under
45
+ * 4.1.1, for every id, referenced or not.
46
+ * - Detection is document-wide while reporting follows the scanned scope,
47
+ * the same split `duplicate-id-aria` uses: a `contextSelector` narrows
48
+ * which duplicates get reported, never which ones count as duplicates.
49
+ */
50
+
51
+ const id = 'duplicate-id';
52
+
53
+ const meta = {
54
+ title: 'IDs must be unique',
55
+ description:
56
+ '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).',
57
+ i18n: {
58
+ titleKey: 'duplicateId_title',
59
+ descriptionKey: 'duplicateId_description'
60
+ },
61
+ helpUrl: null,
62
+ tags: ['wcag2a', 'wcag411', 'wcag22-removed', 'structure', 'atomic', 'automatic'],
63
+ wcagSc: ['4.1.1'],
64
+ normativeMappings: [
65
+ {
66
+ standard: 'WCAG',
67
+ version: '2.1',
68
+ requirement: '4.1.1',
69
+ title: 'Parsing',
70
+ conformanceLevel: 'A'
71
+ }
72
+ ],
73
+ defaultSeverity: 'moderate',
74
+ category: 'robust',
75
+ type: 'automatic',
76
+ defaultConfidence: 'high',
77
+ coverage: { facetsBySc: { '4.1.1': ['id-unique-page-wide'] } }
78
+ };
79
+
80
+ function runInPage(ctx) {
81
+ const { document, helpers, rule } = ctx;
82
+
83
+ // Detection spans the whole document; see the header comment on scope.
84
+ const all = new Set();
85
+ try {
86
+ const nodes = document.querySelectorAll ? document.querySelectorAll('[id]') : [];
87
+ for (const el of nodes) all.add(el);
88
+ } catch {
89
+ // no-throw: fall through to the helper-provided set below
90
+ }
91
+
92
+ const queryAllSmart =
93
+ helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
94
+
95
+ // queryAllSmart reaches into open shadow roots when includeShadowDom is on,
96
+ // which document.querySelectorAll never does.
97
+ let inScope = null;
98
+ if (queryAllSmart) {
99
+ try {
100
+ const scoped = queryAllSmart('[id]');
101
+ const list = Array.isArray(scoped) ? scoped : Array.from(scoped || []);
102
+ inScope = new Set(list);
103
+ for (const el of list) all.add(el);
104
+ } catch {
105
+ inScope = null;
106
+ }
107
+ }
108
+
109
+ // Ids resolve within their own tree, so group by root before comparing.
110
+ function rootOf(el) {
111
+ try {
112
+ if (typeof el.getRootNode === 'function') return el.getRootNode();
113
+ } catch {
114
+ // fall through
115
+ }
116
+ return document;
117
+ }
118
+
119
+ const byRootAndId = new Map(); // root -> Map(idValue -> element[])
120
+ let applicableCount = 0;
121
+
122
+ for (const el of all) {
123
+ if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
124
+ const value = String(el.getAttribute('id') || '').trim();
125
+ if (!value) continue;
126
+
127
+ applicableCount += 1;
128
+
129
+ const root = rootOf(el);
130
+ let idMap = byRootAndId.get(root);
131
+ if (!idMap) {
132
+ idMap = new Map();
133
+ byRootAndId.set(root, idMap);
134
+ }
135
+ const bucket = idMap.get(value);
136
+ if (bucket) bucket.push(el);
137
+ else idMap.set(value, [el]);
138
+ }
139
+
140
+ if (applicableCount === 0) {
141
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
142
+ }
143
+
144
+ const occurrences = [];
145
+
146
+ for (const idMap of byRootAndId.values()) {
147
+ for (const [value, els] of idMap) {
148
+ if (els.length <= 1) continue;
149
+
150
+ for (const el of els) {
151
+ if (inScope && !inScope.has(el)) continue;
152
+
153
+ const eligInfo = helpers.getEligibilityInfo
154
+ ? (() => {
155
+ try {
156
+ return helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' });
157
+ } catch {
158
+ return null;
159
+ }
160
+ })()
161
+ : null;
162
+
163
+ occurrences.push(
164
+ helpers.reportOccurrence(el, {
165
+ summary: `The id "${value}" is used on ${els.length} elements in the same tree.`,
166
+ 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.',
167
+ i18n: {
168
+ summaryKey: 'duplicateId_summary_fail',
169
+ hintKey: 'duplicateId_hint_fail',
170
+ params: { id: value, count: String(els.length) }
171
+ },
172
+ data: {
173
+ details: {
174
+ reasonCode: 'DUPLICATE_ID',
175
+ id: value,
176
+ count: els.length
177
+ },
178
+ visibilityFilter: eligInfo || { targetSet: 'dom', accEligible: null, reasons: [] }
179
+ }
180
+ })
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ if (occurrences.length) {
187
+ return {
188
+ ruleId: rule.ruleId,
189
+ outcome: 'fail',
190
+ severity: rule.defaultSeverity || 'moderate',
191
+ occurrences
192
+ };
193
+ }
194
+
195
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
196
+ }
197
+
198
+ 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
@@ -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;