@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.
- package/CHANGELOG.md +193 -149
- package/README.md +27 -6
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +2 -2
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +16 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +6 -4
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +53 -25
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +3 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +68 -10
- package/src/checks/automatic/aria-allowed-role.js +2 -2
- package/src/checks/automatic/aria-braille-equivalent.js +3 -3
- package/src/checks/automatic/aria-conditional-attr.js +5 -5
- package/src/checks/automatic/aria-deprecated-role.js +1 -1
- package/src/checks/automatic/aria-hidden-body.js +2 -2
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +18 -18
- package/src/checks/automatic/aria-prohibited-children.js +130 -37
- package/src/checks/automatic/aria-required-attr.js +60 -12
- package/src/checks/automatic/aria-required-children.js +21 -14
- package/src/checks/automatic/aria-required-parent.js +61 -9
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-valid-attr-value.js +15 -12
- package/src/checks/automatic/aria-valid-attr.js +1 -1
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +96 -19
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +5 -3
- package/src/checks/automatic/duplicate-id.js +198 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +1 -1
- package/src/checks/automatic/iframe-focusable-content.js +63 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +172 -18
- package/src/checks/automatic/link-in-text-block.js +10 -10
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +23 -4
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +71 -53
- package/src/checks/automatic/td-has-header.js +5 -5
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +4 -4
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +8965 -1647
- package/src/report.js +2 -2
- package/surea11y.browser.js +3768 -611
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- 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
|
|
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
|
-
* -
|
|
31
|
-
* `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
//
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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)
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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"
|
|
113
|
-
// NEITHER dt nor dd (whether
|
|
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
|
|
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
|
|
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
|
|
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
|
|
24
|
-
*
|
|
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
|
|
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
|
|
139
|
-
// in every context (e.g. touch/mobile)
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
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"
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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;
|