@surea11y/core 1.4.1 → 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 +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -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 +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- 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 +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- 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 +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- 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 +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- 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 +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- 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 +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- 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 +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- 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 +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- 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 +18 -21
- 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 +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- 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 +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- 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 +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- 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) {
|
|
@@ -156,7 +233,7 @@ function runInPage(ctx) {
|
|
|
156
233
|
for (const r of rules) {
|
|
157
234
|
if (!r) continue;
|
|
158
235
|
if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
|
|
159
|
-
findings.push({ mediaText, selectorText: trim(r.selectorText)
|
|
236
|
+
findings.push({ mediaText, selectorText: trim(r.selectorText) });
|
|
160
237
|
}
|
|
161
238
|
}
|
|
162
239
|
}
|
|
@@ -196,28 +273,28 @@ function runInPage(ctx) {
|
|
|
196
273
|
}
|
|
197
274
|
|
|
198
275
|
const target = document.documentElement || document.body || null;
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
276
|
+
const occurrences = findings.map((f) =>
|
|
277
|
+
helpers.reportOccurrence(target, {
|
|
278
|
+
summary: f.selectorText
|
|
279
|
+
? `A "${f.mediaText}" media query rotates "${f.selectorText}", locking the page to one orientation.`
|
|
280
|
+
: `A "${f.mediaText}" media query rotates an element with no readable selector, locking the page to one orientation.`,
|
|
281
|
+
hint: 'Remove the rotate() transform from the orientation media query; let the page respond naturally to device orientation instead of forcing a visual rotation.',
|
|
282
|
+
i18n: {
|
|
283
|
+
summaryKey: f.selectorText
|
|
284
|
+
? 'cssOrientationLock_summary_fail'
|
|
285
|
+
: 'cssOrientationLock_summary_fail_unknownSelector',
|
|
286
|
+
hintKey: 'cssOrientationLock_hint_fail',
|
|
287
|
+
params: { mediaText: f.mediaText, selectorText: f.selectorText }
|
|
288
|
+
},
|
|
289
|
+
data: {
|
|
290
|
+
details: {
|
|
291
|
+
reasonCode: 'ORIENTATION_MEDIA_ROTATE_TRANSFORM',
|
|
292
|
+
mediaText: f.mediaText,
|
|
293
|
+
selectorText: f.selectorText
|
|
294
|
+
}
|
|
218
295
|
}
|
|
219
|
-
}
|
|
220
|
-
|
|
296
|
+
})
|
|
297
|
+
);
|
|
221
298
|
|
|
222
299
|
return {
|
|
223
300
|
ruleId: rule.ruleId,
|
|
@@ -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.
|
|
@@ -121,9 +120,6 @@ function runInPage(ctx) {
|
|
|
121
120
|
: null;
|
|
122
121
|
if (!reasonCode) continue;
|
|
123
122
|
|
|
124
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
125
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
126
|
-
|
|
127
123
|
const summary = invalidTags.length
|
|
128
124
|
? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
|
|
129
125
|
: 'This description list has no <dt>/<dd> term-definition group.';
|
|
@@ -131,24 +127,24 @@ function runInPage(ctx) {
|
|
|
131
127
|
? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
|
|
132
128
|
: 'Add at least one <dt>/<dd> pair inside this <dl>.';
|
|
133
129
|
|
|
134
|
-
occurrences.push(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
: '
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
130
|
+
occurrences.push(
|
|
131
|
+
helpers.reportOccurrence(el, {
|
|
132
|
+
summary,
|
|
133
|
+
hint,
|
|
134
|
+
i18n: {
|
|
135
|
+
summaryKey: invalidTags.length
|
|
136
|
+
? 'definitionListChildrenValid_summary_fail_invalidChild'
|
|
137
|
+
: 'definitionListChildrenValid_summary_fail_noDtDd',
|
|
138
|
+
hintKey: invalidTags.length
|
|
139
|
+
? 'definitionListChildrenValid_hint_fail_invalidChild'
|
|
140
|
+
: 'definitionListChildrenValid_hint_fail_noDtDd',
|
|
141
|
+
params: { invalidChildren: dedupedInvalidTags.join(', ') }
|
|
142
|
+
},
|
|
143
|
+
data: {
|
|
144
|
+
details: { reasonCode, invalidChildren: invalidTags }
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
if (applicableCount === 0) {
|
|
@@ -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
|
|
@@ -72,23 +72,21 @@ function runInPage(ctx) {
|
|
|
72
72
|
applicableCount += 1;
|
|
73
73
|
|
|
74
74
|
const tag = el.tagName.toLowerCase();
|
|
75
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
76
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
77
75
|
|
|
78
|
-
occurrences.push(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
76
|
+
occurrences.push(
|
|
77
|
+
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.',
|
|
80
|
+
i18n: {
|
|
81
|
+
summaryKey: 'deprecatedElements_summary_fail',
|
|
82
|
+
hintKey: 'deprecatedElements_hint_fail',
|
|
83
|
+
params: { element: tag }
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
);
|
|
92
90
|
}
|
|
93
91
|
|
|
94
92
|
if (applicableCount === 0) {
|
|
@@ -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
|
|
@@ -140,24 +158,22 @@ function runInPage(ctx) {
|
|
|
140
158
|
}
|
|
141
159
|
})()
|
|
142
160
|
: null;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
+
|
|
162
|
+
occurrences.push(
|
|
163
|
+
helpers.reportOccurrence(el, {
|
|
164
|
+
summary: 'This dialog has no accessible name.',
|
|
165
|
+
hint: 'Provide aria-labelledby (preferred) or aria-label so assistive technologies can announce the dialog.',
|
|
166
|
+
i18n: {
|
|
167
|
+
summaryKey: 'dialogNamePresent_summary_fail',
|
|
168
|
+
hintKey: 'dialogNamePresent_hint_fail',
|
|
169
|
+
params: { role }
|
|
170
|
+
},
|
|
171
|
+
data: {
|
|
172
|
+
details: { reasonCode: 'name_missing', controlType: role, methodTried: res.method },
|
|
173
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
);
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
if (applicableCount === 0) {
|
|
@@ -80,23 +80,21 @@ function runInPage(ctx) {
|
|
|
80
80
|
if (valid) continue;
|
|
81
81
|
|
|
82
82
|
const tag = el.tagName.toLowerCase();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
});
|
|
83
|
+
|
|
84
|
+
occurrences.push(
|
|
85
|
+
helpers.reportOccurrence(el, {
|
|
86
|
+
summary: 'This description-list item is not contained by a <dl>.',
|
|
87
|
+
hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
|
|
88
|
+
i18n: {
|
|
89
|
+
summaryKey: 'dlitemParentValid_summary_fail',
|
|
90
|
+
hintKey: 'dlitemParentValid_hint_fail',
|
|
91
|
+
params: { element: tag, parentElement: parentTag }
|
|
92
|
+
},
|
|
93
|
+
data: {
|
|
94
|
+
details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
);
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
if (applicableCount === 0) {
|
|
@@ -15,15 +15,18 @@
|
|
|
15
15
|
* attribute (i.e. at least one ARIA ID reference exists to resolve).
|
|
16
16
|
* @expectation
|
|
17
17
|
* For every id value referenced by one of those attributes, exactly one
|
|
18
|
-
* element in the document carries that id. A
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* element in the document carries that id. A duplicate does not break the
|
|
19
|
+
* reference: it resolves to the first element in tree order, so the name is
|
|
20
|
+
* still computed. Whether that element is the intended target depends on
|
|
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
|
-
* whole-document property, not scoped to a sub-root.
|
|
28
|
+
* whole-document property, not scoped to a sub-root. Reported occurrences are
|
|
29
|
+
* limited to the scanned scope.
|
|
27
30
|
*/
|
|
28
31
|
|
|
29
32
|
const id = 'duplicate-id-aria';
|
|
@@ -58,7 +61,7 @@ const meta = {
|
|
|
58
61
|
function runInPage(ctx) {
|
|
59
62
|
const { document, helpers, rule } = ctx;
|
|
60
63
|
|
|
61
|
-
// Declared inside runInPage
|
|
64
|
+
// Declared inside runInPage, see scripts/build-core.js header
|
|
62
65
|
// ("runInPage MUST be self-contained").
|
|
63
66
|
const IDREF_ATTRS = [
|
|
64
67
|
'aria-labelledby',
|
|
@@ -95,51 +98,58 @@ function runInPage(ctx) {
|
|
|
95
98
|
}
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
|
|
101
|
+
// Detection is document-wide; occurrences are limited to the scanned scope.
|
|
102
|
+
let inScope = null;
|
|
103
|
+
if (helpers && typeof helpers.queryAllSmart === 'function') {
|
|
104
|
+
try {
|
|
105
|
+
const scoped = helpers.queryAllSmart('[id]');
|
|
106
|
+
inScope = new Set(Array.isArray(scoped) ? scoped : Array.from(scoped || []));
|
|
107
|
+
} catch {
|
|
108
|
+
inScope = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const cantTellOccurrences = [];
|
|
99
113
|
|
|
100
114
|
for (const refId of referencedIds) {
|
|
101
115
|
const els = idMap.get(refId) || [];
|
|
102
116
|
if (els.length <= 1) continue;
|
|
103
117
|
|
|
104
118
|
for (const el of els) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
id: refId,
|
|
124
|
-
duplicateCount: els.length
|
|
119
|
+
if (inScope && !inScope.has(el)) continue;
|
|
120
|
+
|
|
121
|
+
cantTellOccurrences.push(
|
|
122
|
+
helpers.reportOccurrence(el, {
|
|
123
|
+
summary:
|
|
124
|
+
'This id is referenced by an ARIA attribute but is used by more than one element; the reference resolves to the first.',
|
|
125
|
+
hint: 'Confirm the first element carrying this id is the intended target, or make the id unique.',
|
|
126
|
+
i18n: {
|
|
127
|
+
summaryKey: 'duplicateIdAria_summary_cantTell',
|
|
128
|
+
hintKey: 'duplicateIdAria_hint_cantTell',
|
|
129
|
+
params: { id: refId, duplicateCount: String(els.length) }
|
|
130
|
+
},
|
|
131
|
+
data: {
|
|
132
|
+
details: {
|
|
133
|
+
reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',
|
|
134
|
+
id: refId,
|
|
135
|
+
duplicateCount: els.length
|
|
136
|
+
}
|
|
125
137
|
}
|
|
126
|
-
}
|
|
127
|
-
|
|
138
|
+
})
|
|
139
|
+
);
|
|
128
140
|
}
|
|
129
141
|
}
|
|
130
142
|
|
|
131
143
|
if (referencedIds.size === 0) {
|
|
132
144
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
133
145
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
146
|
+
|
|
147
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
148
|
+
[],
|
|
149
|
+
cantTellOccurrences,
|
|
150
|
+
rule.defaultSeverity || 'serious'
|
|
151
|
+
);
|
|
152
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
module.exports = { id, meta, runInPage };
|