@surea11y/core 1.7.0 → 1.8.1
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 +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +2 -2
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +26 -11
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +109 -5
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +14285 -2219
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -20,6 +20,16 @@
|
|
|
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
|
|
22
22
|
* display orientation unless that orientation is essential.
|
|
23
|
+
* Such a rotation fails.
|
|
24
|
+
*
|
|
25
|
+
* A second shape is asked about (cantTell): an orientation media block
|
|
26
|
+
* that hides the page's content with `display: none` or `visibility:
|
|
27
|
+
* hidden`, the usual form of WCAG F100 (content replaced by a "rotate
|
|
28
|
+
* your device" message in one orientation). The hidden element counts as
|
|
29
|
+
* the page's content when it is `html` or `body`, the `main` landmark, an
|
|
30
|
+
* ancestor of it, or, on a page without a main landmark, an element
|
|
31
|
+
* holding at least half of the body's text. Whether the orientation is
|
|
32
|
+
* essential, and whether the page stays usable, is left to a person.
|
|
23
33
|
* @implementation-notes
|
|
24
34
|
* - The rotation DEGREE is what makes this the exploit signature, not
|
|
25
35
|
* merely the presence of a `rotate()` function: a small decorative icon
|
|
@@ -38,11 +48,16 @@
|
|
|
38
48
|
* security model) and are skipped, same class of limitation as any
|
|
39
49
|
* check that can only see same-origin/inspectable content (compare
|
|
40
50
|
* `iframe-focusable-content`).
|
|
41
|
-
* -
|
|
42
|
-
* so occurrences are
|
|
51
|
+
* - A rotation lock is not decided per element: this is a
|
|
52
|
+
* whole-document/whole-stylesheet concern, so its occurrences are
|
|
53
|
+
* reported against `document.documentElement`,
|
|
43
54
|
* matching the "whole-document checks" precedent documented in
|
|
44
55
|
* `docs/RULE_AUTHORING.md` §11.2 (e.g. `aria-hidden-body`,
|
|
45
|
-
* `meta-viewport-zoom-enabled`).
|
|
56
|
+
* `meta-viewport-zoom-enabled`). A hidden-content finding is reported
|
|
57
|
+
* against the hidden element.
|
|
58
|
+
* - The rotation lock stays a failure, as in ACT b33eff, which assumes the
|
|
59
|
+
* orientation is not essential; a person reviewing an essential lock (a
|
|
60
|
+
* piano keyboard, a cheque scan) can set that finding aside.
|
|
46
61
|
*/
|
|
47
62
|
|
|
48
63
|
const id = 'css-orientation-lock';
|
|
@@ -50,7 +65,7 @@ const id = 'css-orientation-lock';
|
|
|
50
65
|
const meta = {
|
|
51
66
|
title: 'CSS must not lock the page to a single orientation',
|
|
52
67
|
description:
|
|
53
|
-
|
|
68
|
+
"Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation, and asks about any such rule that hides the page's main content.",
|
|
54
69
|
i18n: {
|
|
55
70
|
titleKey: 'cssOrientationLock_title',
|
|
56
71
|
descriptionKey: 'cssOrientationLock_description'
|
|
@@ -228,17 +243,50 @@ function runInPage(ctx) {
|
|
|
228
243
|
return m.includes('orientation') && (m.includes('portrait') || m.includes('landscape'));
|
|
229
244
|
}
|
|
230
245
|
|
|
231
|
-
function
|
|
246
|
+
function hidesContent(styleDecl) {
|
|
247
|
+
if (!styleDecl || typeof styleDecl.getPropertyValue !== 'function') return false;
|
|
248
|
+
const display = trim(styleDecl.getPropertyValue('display')).toLowerCase();
|
|
249
|
+
const visibility = trim(styleDecl.getPropertyValue('visibility')).toLowerCase();
|
|
250
|
+
return display === 'none' || visibility === 'hidden' || visibility === 'collapse';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function scanRuleList(rules, mediaText, findings, hidings) {
|
|
232
254
|
if (!rules) return;
|
|
233
255
|
for (const r of rules) {
|
|
234
|
-
if (!r) continue;
|
|
235
|
-
if (
|
|
256
|
+
if (!r || r.type !== CSS_STYLE_RULE) continue;
|
|
257
|
+
if (isLockingRotation(r.style)) {
|
|
236
258
|
findings.push({ mediaText, selectorText: trim(r.selectorText) });
|
|
237
259
|
}
|
|
260
|
+
if (r.selectorText && hidesContent(r.style)) {
|
|
261
|
+
hidings.push({ mediaText, selectorText: trim(r.selectorText) });
|
|
262
|
+
}
|
|
238
263
|
}
|
|
239
264
|
}
|
|
240
265
|
|
|
266
|
+
// Whether an element hidden by an orientation block holds the page's
|
|
267
|
+
// content: the root, the body, the main landmark or an ancestor of it, or,
|
|
268
|
+
// with no main landmark, most of the body's text.
|
|
269
|
+
function textLength(el) {
|
|
270
|
+
return String((el && el.textContent) || '').replace(/\s+/g, '').length;
|
|
271
|
+
}
|
|
272
|
+
let mainEl = null;
|
|
273
|
+
try {
|
|
274
|
+
mainEl = document.querySelector('main, [role="main"]');
|
|
275
|
+
} catch {
|
|
276
|
+
mainEl = null;
|
|
277
|
+
}
|
|
278
|
+
const bodyTextLength = document.body ? textLength(document.body) : 0;
|
|
279
|
+
|
|
280
|
+
function holdsPageContent(el) {
|
|
281
|
+
if (!el || el.nodeType !== 1) return false;
|
|
282
|
+
const tag = String(el.localName || '').toLowerCase();
|
|
283
|
+
if (tag === 'html' || tag === 'body') return true;
|
|
284
|
+
if (mainEl) return el === mainEl || (typeof el.contains === 'function' && el.contains(mainEl));
|
|
285
|
+
return bodyTextLength > 0 && textLength(el) * 2 >= bodyTextLength;
|
|
286
|
+
}
|
|
287
|
+
|
|
241
288
|
const findings = [];
|
|
289
|
+
const hidings = [];
|
|
242
290
|
let sheetCount = 0;
|
|
243
291
|
let unreadableSheetCount = 0;
|
|
244
292
|
|
|
@@ -261,7 +309,7 @@ function runInPage(ctx) {
|
|
|
261
309
|
if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
|
|
262
310
|
const mediaText = rule2.media ? rule2.media.mediaText : '';
|
|
263
311
|
if (!isOrientationMedia(mediaText)) continue;
|
|
264
|
-
scanRuleList(rule2.cssRules, mediaText, findings);
|
|
312
|
+
scanRuleList(rule2.cssRules, mediaText, findings, hidings);
|
|
265
313
|
}
|
|
266
314
|
}
|
|
267
315
|
} catch {
|
|
@@ -270,8 +318,90 @@ function runInPage(ctx) {
|
|
|
270
318
|
|
|
271
319
|
const scanTarget = document.documentElement || document.body || null;
|
|
272
320
|
|
|
321
|
+
function unreadableSheetsOccurrence(count) {
|
|
322
|
+
return helpers.reportOccurrence(scanTarget, {
|
|
323
|
+
summary: `${count} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
|
|
324
|
+
hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
|
|
325
|
+
i18n: {
|
|
326
|
+
summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
|
|
327
|
+
hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
|
|
328
|
+
params: { count: String(count) }
|
|
329
|
+
},
|
|
330
|
+
uncertainty: {
|
|
331
|
+
code: 'not-computable',
|
|
332
|
+
needed: 'The contents of the stylesheets this scan could not read.',
|
|
333
|
+
evidence: { unreadableSheetCount: count, reasonCode: 'STYLESHEETS_NOT_READABLE' }
|
|
334
|
+
},
|
|
335
|
+
data: {
|
|
336
|
+
details: {
|
|
337
|
+
reasonCode: 'STYLESHEETS_NOT_READABLE',
|
|
338
|
+
unreadableSheetCount: count
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Orientation blocks hiding the page's content (F100): one question per
|
|
345
|
+
// hidden element.
|
|
346
|
+
const hiddenContent = [];
|
|
347
|
+
const seenHidden = new Set();
|
|
348
|
+
for (const h of hidings) {
|
|
349
|
+
let matched;
|
|
350
|
+
try {
|
|
351
|
+
matched = Array.from(document.querySelectorAll(h.selectorText));
|
|
352
|
+
} catch {
|
|
353
|
+
matched = [];
|
|
354
|
+
}
|
|
355
|
+
for (const el of matched) {
|
|
356
|
+
if (seenHidden.has(el) || !holdsPageContent(el)) continue;
|
|
357
|
+
seenHidden.add(el);
|
|
358
|
+
hiddenContent.push({ el, mediaText: h.mediaText, selectorText: h.selectorText });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const hiddenContentOccurrences = hiddenContent.map((f) =>
|
|
362
|
+
helpers.reportOccurrence(f.el, {
|
|
363
|
+
occurrenceOutcome: 'cantTell',
|
|
364
|
+
summary: `A "${f.mediaText}" media query hides "${f.selectorText}", which holds the page's main content, so the page may not be usable in that orientation.`,
|
|
365
|
+
hint: 'Check that the page can be viewed and operated in both portrait and landscape. If this media query replaces the content with a message asking the user to rotate the device, show the content instead, unless one orientation is essential.',
|
|
366
|
+
i18n: {
|
|
367
|
+
summaryKey: 'cssOrientationLock_summary_cantTell_hidesContent',
|
|
368
|
+
hintKey: 'cssOrientationLock_hint_cantTell_hidesContent',
|
|
369
|
+
params: { mediaText: f.mediaText, selectorText: f.selectorText }
|
|
370
|
+
},
|
|
371
|
+
uncertainty: {
|
|
372
|
+
code: 'judgement-required',
|
|
373
|
+
needed:
|
|
374
|
+
'Whether the page stays usable in that orientation, and whether one orientation is essential.',
|
|
375
|
+
evidence: {
|
|
376
|
+
mediaText: f.mediaText,
|
|
377
|
+
selectorText: f.selectorText,
|
|
378
|
+
reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT'
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
data: {
|
|
382
|
+
details: {
|
|
383
|
+
reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT',
|
|
384
|
+
mediaText: f.mediaText,
|
|
385
|
+
selectorText: f.selectorText
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
})
|
|
389
|
+
);
|
|
390
|
+
|
|
273
391
|
// A lock found in a readable sheet is still a lock, so `fail` outranks the
|
|
274
392
|
// uncertainty below.
|
|
393
|
+
if (!findings.length && hiddenContentOccurrences.length) {
|
|
394
|
+
const unreadable =
|
|
395
|
+
unreadableSheetCount > 0 ? [unreadableSheetsOccurrence(unreadableSheetCount)] : [];
|
|
396
|
+
return {
|
|
397
|
+
ruleId: rule.ruleId,
|
|
398
|
+
outcome: 'cantTell',
|
|
399
|
+
severity: rule.defaultSeverity || 'serious',
|
|
400
|
+
confidence: 'low',
|
|
401
|
+
occurrences: hiddenContentOccurrences.concat(unreadable)
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
275
405
|
if (!findings.length) {
|
|
276
406
|
if (unreadableSheetCount > 0) {
|
|
277
407
|
return {
|
|
@@ -279,28 +409,7 @@ function runInPage(ctx) {
|
|
|
279
409
|
outcome: 'cantTell',
|
|
280
410
|
severity: rule.defaultSeverity || 'serious',
|
|
281
411
|
confidence: 'low',
|
|
282
|
-
occurrences: [
|
|
283
|
-
helpers.reportOccurrence(scanTarget, {
|
|
284
|
-
summary: `${unreadableSheetCount} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
|
|
285
|
-
hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
|
|
286
|
-
i18n: {
|
|
287
|
-
summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
|
|
288
|
-
hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
|
|
289
|
-
params: { count: String(unreadableSheetCount) }
|
|
290
|
-
},
|
|
291
|
-
uncertainty: {
|
|
292
|
-
code: 'not-computable',
|
|
293
|
-
needed: 'The contents of the stylesheets this scan could not read.',
|
|
294
|
-
evidence: { unreadableSheetCount, reasonCode: 'STYLESHEETS_NOT_READABLE' }
|
|
295
|
-
},
|
|
296
|
-
data: {
|
|
297
|
-
details: {
|
|
298
|
-
reasonCode: 'STYLESHEETS_NOT_READABLE',
|
|
299
|
-
unreadableSheetCount
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
})
|
|
303
|
-
]
|
|
412
|
+
occurrences: [unreadableSheetsOccurrence(unreadableSheetCount)]
|
|
304
413
|
};
|
|
305
414
|
}
|
|
306
415
|
if (sheetCount === 0) {
|
|
@@ -333,6 +442,19 @@ function runInPage(ctx) {
|
|
|
333
442
|
})
|
|
334
443
|
);
|
|
335
444
|
|
|
445
|
+
if (hiddenContentOccurrences.length) {
|
|
446
|
+
// See helpers.resolveTieredOutcome: the lock fails, and the hidden-content
|
|
447
|
+
// questions are kept beside it.
|
|
448
|
+
return {
|
|
449
|
+
ruleId: rule.ruleId,
|
|
450
|
+
...helpers.resolveTieredOutcome(
|
|
451
|
+
occurrences,
|
|
452
|
+
hiddenContentOccurrences,
|
|
453
|
+
rule.defaultSeverity || 'serious'
|
|
454
|
+
)
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
336
458
|
return {
|
|
337
459
|
ruleId: rule.ruleId,
|
|
338
460
|
outcome: 'fail',
|
|
@@ -9,16 +9,21 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <dl> elements that have at least one direct element child
|
|
12
|
+
* Applies to <dl> elements that have at least one direct element child or
|
|
13
|
+
* non-whitespace text directly inside them.
|
|
13
14
|
* @expectation
|
|
14
15
|
* Every direct element child is <dt>, <dd>, <script>, <template>, <style>,
|
|
15
16
|
* or a <div> whose own children are drawn from that same set (a single
|
|
16
17
|
* level of wrapping div is allowed, matching how authors commonly group
|
|
17
|
-
* dt/dd pairs).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* dt/dd pairs). Non-whitespace text directly inside the <dl> or a wrapping
|
|
19
|
+
* <div> is an invalid child too. If the flattened set contains any <dt> or
|
|
20
|
+
* <dd> at all, it must contain BOTH (an unbalanced dt-without-dd or
|
|
21
|
+
* dd-without-dt is invalid), and read in order it must be groups of one
|
|
22
|
+
* or more <dt> followed by one or more <dd>: a <dd> before the first <dt>
|
|
23
|
+
* has no term, and a <dt> after the last <dd> has no definition. A
|
|
24
|
+
* flattened set with neither is vacuously fine, not a violation (see
|
|
25
|
+
* implementation-notes). Any other direct or wrapped child breaks the
|
|
26
|
+
* description-list semantics assistive technologies rely on.
|
|
22
27
|
* @implementation-notes
|
|
23
28
|
* - Only one level of <div> wrapping is flattened, a <div> nested inside
|
|
24
29
|
* another wrapping <div> is not flattened further and its contents are
|
|
@@ -27,6 +32,11 @@
|
|
|
27
32
|
* with NEITHER dt nor dd, whether from an empty wrapping <div>, only
|
|
28
33
|
* <script>/<template>/<style> content, or a childless <dl>, is not flagged; only
|
|
29
34
|
* an unbalanced dt/dd pairing is a real structural problem.
|
|
35
|
+
* - The order check reads the flattened dt/dd sequence across wrapping
|
|
36
|
+
* <div> boundaries, so a term and its definition split into two sibling
|
|
37
|
+
* <div>s still pass: assistive technologies get the same terms and
|
|
38
|
+
* definitions. The stricter HTML content model (each <div> holds a whole
|
|
39
|
+
* group) is not checked here.
|
|
30
40
|
* - Distinct, atomic decision from dlitem-parent-valid (the
|
|
31
41
|
* inverse relationship: does a given <dt>/<dd> have a valid parent).
|
|
32
42
|
*/
|
|
@@ -72,17 +82,29 @@ function runInPage(ctx) {
|
|
|
72
82
|
const occurrences = [];
|
|
73
83
|
let applicableCount = 0;
|
|
74
84
|
|
|
85
|
+
// Non-whitespace text directly inside `parent` (the <dl> or a wrapping
|
|
86
|
+
// <div>).
|
|
87
|
+
function hasDirectText(parent) {
|
|
88
|
+
for (const node of parent.childNodes || []) {
|
|
89
|
+
if (node && node.nodeType === 3 && /\S/.test(node.nodeValue || '')) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
for (const el of nodes) {
|
|
76
95
|
if (!el || !el.children) continue;
|
|
77
|
-
|
|
96
|
+
const dlHasText = hasDirectText(el);
|
|
97
|
+
if (!el.children.length && !dlHasText) continue;
|
|
78
98
|
|
|
79
99
|
applicableCount += 1;
|
|
80
100
|
|
|
81
101
|
// Flatten one level of wrapping <div> (common dt/dd grouping pattern).
|
|
82
102
|
const flattened = [];
|
|
103
|
+
let hasText = dlHasText;
|
|
83
104
|
for (const child of el.children) {
|
|
84
105
|
if (!child || !child.tagName) continue;
|
|
85
106
|
if (child.tagName.toLowerCase() === 'div') {
|
|
107
|
+
if (hasDirectText(child)) hasText = true;
|
|
86
108
|
for (const grandchild of child.children || []) {
|
|
87
109
|
if (grandchild && grandchild.tagName) flattened.push(grandchild);
|
|
88
110
|
}
|
|
@@ -94,50 +116,72 @@ function runInPage(ctx) {
|
|
|
94
116
|
let hasDt = false;
|
|
95
117
|
let hasDd = false;
|
|
96
118
|
const invalidTags = [];
|
|
119
|
+
// The dt/dd sequence in document order, for the group-order check.
|
|
120
|
+
const sequence = [];
|
|
97
121
|
for (const node of flattened) {
|
|
98
122
|
const tag = node.tagName.toLowerCase();
|
|
99
123
|
if (tag === 'dt') {
|
|
100
124
|
hasDt = true;
|
|
125
|
+
sequence.push(tag);
|
|
101
126
|
continue;
|
|
102
127
|
}
|
|
103
128
|
if (tag === 'dd') {
|
|
104
129
|
hasDd = true;
|
|
130
|
+
sequence.push(tag);
|
|
105
131
|
continue;
|
|
106
132
|
}
|
|
107
133
|
if (!PASSTHROUGH_TAGS.has(tag)) invalidTags.push(tag);
|
|
108
134
|
}
|
|
135
|
+
if (hasText) invalidTags.push('#text');
|
|
109
136
|
const dedupedInvalidTags = [...new Set(invalidTags)];
|
|
110
137
|
|
|
138
|
+
// Groups of one or more dt then one or more dd: the sequence starts
|
|
139
|
+
// with a dt and ends with a dd.
|
|
140
|
+
const badOrder =
|
|
141
|
+
hasDt && hasDd && (sequence[0] !== 'dt' || sequence[sequence.length - 1] !== 'dd');
|
|
142
|
+
|
|
111
143
|
// The dt/dd pairing is only required "when not empty", a <dl> with
|
|
112
144
|
// NEITHER dt nor dd (whether childless after flattening, only
|
|
113
145
|
// passthrough script/template/style content, or an empty wrapping div)
|
|
114
146
|
// is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
|
|
115
|
-
// present without any dd, or vice versa)
|
|
147
|
+
// present without any dd, or vice versa) or a misordered one is a
|
|
148
|
+
// real structural problem.
|
|
116
149
|
const reasonCode = invalidTags.length
|
|
117
150
|
? 'DL_INVALID_CHILD'
|
|
118
151
|
: (hasDt || hasDd) && !(hasDt && hasDd)
|
|
119
152
|
? 'DL_NO_DT_DD'
|
|
120
|
-
:
|
|
153
|
+
: badOrder
|
|
154
|
+
? 'DL_DT_DD_ORDER'
|
|
155
|
+
: null;
|
|
121
156
|
if (!reasonCode) continue;
|
|
122
157
|
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
const TEXT = {
|
|
159
|
+
DL_INVALID_CHILD: {
|
|
160
|
+
summary:
|
|
161
|
+
'This description list contains a direct or wrapped child that is not part of a dt/dd group.',
|
|
162
|
+
hint: 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.',
|
|
163
|
+
key: 'invalidChild'
|
|
164
|
+
},
|
|
165
|
+
DL_NO_DT_DD: {
|
|
166
|
+
summary: 'This description list has no <dt>/<dd> term-definition group.',
|
|
167
|
+
hint: 'Add at least one <dt>/<dd> pair inside this <dl>.',
|
|
168
|
+
key: 'noDtDd'
|
|
169
|
+
},
|
|
170
|
+
DL_DT_DD_ORDER: {
|
|
171
|
+
summary:
|
|
172
|
+
'In this description list, a <dd> comes before any <dt>, or the last <dt> has no <dd> after it.',
|
|
173
|
+
hint: 'Order each group as one or more <dt> followed by one or more <dd>.',
|
|
174
|
+
key: 'order'
|
|
175
|
+
}
|
|
176
|
+
}[reasonCode];
|
|
129
177
|
|
|
130
178
|
occurrences.push(
|
|
131
179
|
helpers.reportOccurrence(el, {
|
|
132
|
-
summary,
|
|
133
|
-
hint,
|
|
180
|
+
summary: TEXT.summary,
|
|
181
|
+
hint: TEXT.hint,
|
|
134
182
|
i18n: {
|
|
135
|
-
summaryKey:
|
|
136
|
-
|
|
137
|
-
: 'definitionListChildrenValid_summary_fail_noDtDd',
|
|
138
|
-
hintKey: invalidTags.length
|
|
139
|
-
? 'definitionListChildrenValid_hint_fail_invalidChild'
|
|
140
|
-
: 'definitionListChildrenValid_hint_fail_noDtDd',
|
|
183
|
+
summaryKey: `definitionListChildrenValid_summary_fail_${TEXT.key}`,
|
|
184
|
+
hintKey: `definitionListChildrenValid_hint_fail_${TEXT.key}`,
|
|
141
185
|
params: { invalidChildren: dedupedInvalidTags.join(', ') }
|
|
142
186
|
},
|
|
143
187
|
data: {
|
|
@@ -5,34 +5,36 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check deprecated-elements-not-used
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Scrolling <marquee> content needs a way to pause, stop, or hide it
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.2
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to any
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* or hide it.
|
|
12
|
+
* Applies to any scan scope; whether it contains a <marquee> element is
|
|
13
|
+
* always an answerable question. <marquee> is an obsolete, non-standard
|
|
14
|
+
* HTML element that browsers still render as auto-scrolling text, with no
|
|
15
|
+
* built-in user mechanism to pause, stop, or hide it.
|
|
16
16
|
* @expectation
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* Each <marquee> is reported as cantTell: the scrolling itself is certain,
|
|
18
|
+
* but a page can offer its own pause or stop control (a button calling the
|
|
19
|
+
* element's stop() method, for example), and then failure F16 does not
|
|
20
|
+
* apply. Whether such a control exists is for a person to check. A scope
|
|
21
|
+
* with no <marquee> passes; there is no separate not-applicable case.
|
|
21
22
|
* @implementation-notes
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* Engine-level hidden-subtree
|
|
27
|
-
* engineOptions.includeHiddenElements is
|
|
23
|
+
* - <blink> is not reported. No current browser makes it blink: it renders
|
|
24
|
+
* as an unknown inline element, so it holds no blinking content for 2.2.2
|
|
25
|
+
* to govern.
|
|
26
|
+
* - Not rule-gated on isAccTreeEligible: presence in markup is what the rule
|
|
27
|
+
* asks about, independent of visibility. Engine-level hidden-subtree
|
|
28
|
+
* filtering still applies unless engineOptions.includeHiddenElements is
|
|
29
|
+
* true.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
32
|
const id = 'deprecated-elements-not-used';
|
|
31
33
|
|
|
32
34
|
const meta = {
|
|
33
|
-
title: '
|
|
35
|
+
title: 'Scrolling <marquee> content must be possible to pause, stop, or hide',
|
|
34
36
|
description:
|
|
35
|
-
'
|
|
37
|
+
'Asks, for each obsolete <marquee> element, whether the page offers a way to pause, stop, or hide its auto-scrolling content, since the element itself has none.',
|
|
36
38
|
i18n: {
|
|
37
39
|
titleKey: 'deprecatedElements_title',
|
|
38
40
|
descriptionKey: 'deprecatedElements_description'
|
|
@@ -60,47 +62,50 @@ function runInPage(ctx) {
|
|
|
60
62
|
const { helpers, rule } = ctx;
|
|
61
63
|
|
|
62
64
|
const nodes = helpers.queryAllSmart
|
|
63
|
-
? helpers.queryAllSmart('
|
|
64
|
-
: helpers.queryAll('
|
|
65
|
+
? helpers.queryAllSmart('marquee')
|
|
66
|
+
: helpers.queryAll('marquee');
|
|
65
67
|
|
|
66
68
|
const occurrences = [];
|
|
67
|
-
let applicableCount = 0;
|
|
68
69
|
|
|
69
70
|
for (const el of nodes) {
|
|
70
71
|
if (!el || !el.tagName) continue;
|
|
71
72
|
|
|
72
|
-
applicableCount += 1;
|
|
73
|
-
|
|
74
73
|
const tag = el.tagName.toLowerCase();
|
|
75
74
|
|
|
76
75
|
occurrences.push(
|
|
77
76
|
helpers.reportOccurrence(el, {
|
|
78
|
-
summary:
|
|
79
|
-
|
|
77
|
+
summary:
|
|
78
|
+
'This <marquee> scrolls its content, and the element itself gives the user no way to pause, stop, or hide it.',
|
|
79
|
+
hint: 'Check that the page offers a control that pauses, stops, or hides this content. Better: replace it with static content, or with an animation that has a pause/stop control.',
|
|
80
|
+
occurrenceOutcome: 'cantTell',
|
|
80
81
|
i18n: {
|
|
81
|
-
summaryKey: '
|
|
82
|
-
hintKey: '
|
|
82
|
+
summaryKey: 'deprecatedElements_summary_cantTell',
|
|
83
|
+
hintKey: 'deprecatedElements_hint_cantTell',
|
|
83
84
|
params: { element: tag }
|
|
84
85
|
},
|
|
86
|
+
uncertainty: {
|
|
87
|
+
code: 'runtime-dependent',
|
|
88
|
+
needed:
|
|
89
|
+
'Whether the page provides a control that pauses, stops, or hides the scrolling content.',
|
|
90
|
+
evidence: { element: tag }
|
|
91
|
+
},
|
|
85
92
|
data: {
|
|
86
|
-
details: { reasonCode: '
|
|
93
|
+
details: { reasonCode: 'MARQUEE_PAUSE_MECHANISM_UNKNOWN', element: tag }
|
|
87
94
|
}
|
|
88
95
|
})
|
|
89
96
|
);
|
|
90
97
|
}
|
|
91
98
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (occurrences.length) {
|
|
96
|
-
return {
|
|
97
|
-
ruleId: rule.ruleId,
|
|
98
|
-
outcome: 'fail',
|
|
99
|
-
severity: rule.defaultSeverity || 'serious',
|
|
100
|
-
occurrences
|
|
101
|
-
};
|
|
99
|
+
// No <marquee> in scope is itself the passing case (see @expectation above).
|
|
100
|
+
if (!occurrences.length) {
|
|
101
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
102
102
|
}
|
|
103
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
ruleId: rule.ruleId,
|
|
105
|
+
outcome: 'cantTell',
|
|
106
|
+
severity: rule.defaultSeverity || 'serious',
|
|
107
|
+
occurrences
|
|
108
|
+
};
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
module.exports = { id, meta, runInPage };
|
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check dialog-name-present
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Dialogs (role="dialog"/"alertdialog" or a native <dialog>) must have an accessible name
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to elements
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Applies to elements included in the accessibility tree whose role is
|
|
13
|
+
* dialog or alertdialog: an element whose role attribute resolves to one
|
|
14
|
+
* of those roles (the first token that names a concrete ARIA role wins,
|
|
15
|
+
* so role="alertdialog dialog" is an alertdialog), and a native <dialog>
|
|
16
|
+
* whose role attribute is absent or names no concrete role (its implicit
|
|
17
|
+
* role is dialog). A closed <dialog> is hidden and so not in scope.
|
|
16
18
|
* @expectation
|
|
17
19
|
* The element has a non-empty accessible name from aria-label, from an
|
|
18
20
|
* aria-labelledby that resolves to non-empty text, or from title. Both
|
|
@@ -25,7 +27,7 @@ const id = 'dialog-name-present';
|
|
|
25
27
|
const meta = {
|
|
26
28
|
title: 'Dialogs have an accessible name',
|
|
27
29
|
description:
|
|
28
|
-
'Checks that elements with role="dialog" or role="alertdialog" expose a non-empty accessible name.',
|
|
30
|
+
'Checks that dialogs (elements with role="dialog" or role="alertdialog", and native <dialog> elements) expose a non-empty accessible name.',
|
|
29
31
|
i18n: {
|
|
30
32
|
titleKey: 'dialogNamePresent_title',
|
|
31
33
|
descriptionKey: 'dialogNamePresent_description'
|
|
@@ -115,7 +117,8 @@ function runInPage(ctx) {
|
|
|
115
117
|
const occurrences = [];
|
|
116
118
|
let applicableCount = 0;
|
|
117
119
|
|
|
118
|
-
|
|
120
|
+
// Filtered below by the resolved role.
|
|
121
|
+
const selector = 'dialog,[role]';
|
|
119
122
|
const nodes = helpers.queryAllSmart
|
|
120
123
|
? helpers.queryAllSmart(selector)
|
|
121
124
|
: helpers.queryAll(selector);
|
|
@@ -137,12 +140,28 @@ function runInPage(ctx) {
|
|
|
137
140
|
return { ok: false, method: 'none' };
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// The role the browser uses: the first token of the role attribute that
|
|
144
|
+
// names a concrete ARIA role, else the element's implicit role (dialog for
|
|
145
|
+
// a native <dialog>; the others are not in scope here).
|
|
146
|
+
const aria = helpers && helpers.aria;
|
|
147
|
+
function resolveRole(el) {
|
|
148
|
+
const tokens = getAttr(el, 'role').toLowerCase().split(' ').filter(Boolean);
|
|
149
|
+
for (const t of tokens) {
|
|
150
|
+
const concrete =
|
|
151
|
+
aria && typeof aria.isValidConcreteRole === 'function'
|
|
152
|
+
? aria.isValidConcreteRole(t)
|
|
153
|
+
: t === 'dialog' || t === 'alertdialog';
|
|
154
|
+
if (concrete) return t;
|
|
155
|
+
}
|
|
156
|
+
return String(el.tagName || '').toLowerCase() === 'dialog' ? 'dialog' : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
for (const el of nodes) {
|
|
141
160
|
if (!el) continue;
|
|
142
|
-
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
143
161
|
|
|
144
|
-
const role =
|
|
162
|
+
const role = resolveRole(el);
|
|
145
163
|
if (role !== 'dialog' && role !== 'alertdialog') continue;
|
|
164
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
146
165
|
|
|
147
166
|
applicableCount += 1;
|
|
148
167
|
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* element renders or not, which is why ACT 3ea0c8 evaluates hidden
|
|
15
15
|
* elements too.
|
|
16
16
|
* @expectation
|
|
17
|
-
* No other element in the same tree carries the same id value
|
|
17
|
+
* No other element in the same tree carries the same id value, compared
|
|
18
|
+
* exactly as written (id="a " and id="a" are different ids). Ids are
|
|
18
19
|
* scoped per document tree and per shadow tree, so the same id inside
|
|
19
20
|
* two different shadow roots is not a duplicate.
|
|
20
21
|
* @implementation-notes
|
|
@@ -126,7 +127,10 @@ function runInPage(ctx) {
|
|
|
126
127
|
|
|
127
128
|
for (const el of all) {
|
|
128
129
|
if (!el || el.nodeType !== 1 || !el.getAttribute) continue;
|
|
129
|
-
|
|
130
|
+
// Compared as written: "a " and "a" are different ids in the DOM, and
|
|
131
|
+
// getElementById does not trim. Whitespace inside an id is a separate
|
|
132
|
+
// validity error, not a duplicate.
|
|
133
|
+
const value = String(el.getAttribute('id') || '');
|
|
130
134
|
if (!value) continue;
|
|
131
135
|
|
|
132
136
|
applicableCount += 1;
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
* describes one resource, so two frames answering to it must embed the
|
|
21
21
|
* same one.
|
|
22
22
|
* @implementation-notes
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* - Frames are grouped by the computed accessible name, not the title
|
|
24
|
+
* attribute, so a title that aria-label or aria-labelledby overrides does
|
|
25
|
+
* not count. It replaces iframe-title-unique, deprecated in 1.8.0, which
|
|
26
|
+
* grouped them by the title attribute.
|
|
27
27
|
* - src values are compared as resolved absolute URLs with the fragment
|
|
28
28
|
* removed and a trailing slash normalised away, so a directory written
|
|
29
29
|
* both with and without one is a single resource.
|