@surea11y/core 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +2 -2
- 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 +1 -1
- 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 +152 -26
- 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 +14419 -2231
- 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,10 @@
|
|
|
20
20
|
* size: 3:1 for large text, 4.5:1 for everything else. Text is large at
|
|
21
21
|
* 24px or more, or at 14pt (about 18.667px) or more when the computed font
|
|
22
22
|
* weight is 700 or higher.
|
|
23
|
+
* @implementation-notes
|
|
24
|
+
* - Its thresholds are settings (`settings` below), so another standard's
|
|
25
|
+
* contrast requirement is a variant of this rule rather than a copy
|
|
26
|
+
* (docs/RULE_AUTHORING.md, "Rule variants"). Without settings it is WCAG's.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
const id = 'contrast-minimum';
|
|
@@ -65,6 +69,21 @@ function runInPage(ctx) {
|
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
// Thresholds (see `settings` below): WCAG 1.4.3's by default. A variant of
|
|
73
|
+
// this rule, another standard's contrast requirement, passes its own in
|
|
74
|
+
// ctx.config (docs/RULE_AUTHORING.md, "Rule variants").
|
|
75
|
+
const cfg = ctx.config && typeof ctx.config === 'object' ? ctx.config : {};
|
|
76
|
+
const setting = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
|
77
|
+
const BOLD_LARGE_MIN_PX = setting(cfg.boldLargeMinPx, null);
|
|
78
|
+
const LARGE_TEXT_RATIO = setting(cfg.largeTextRatio, 3);
|
|
79
|
+
const NORMAL_TEXT_RATIO = setting(cfg.normalTextRatio, 4.5);
|
|
80
|
+
// The font and analysis caches hold verdicts that depend on those
|
|
81
|
+
// thresholds, so other thresholds get caches of their own.
|
|
82
|
+
const SETTINGS_KEY =
|
|
83
|
+
BOLD_LARGE_MIN_PX === null && LARGE_TEXT_RATIO === 3 && NORMAL_TEXT_RATIO === 4.5
|
|
84
|
+
? ''
|
|
85
|
+
: '|' + [BOLD_LARGE_MIN_PX, LARGE_TEXT_RATIO, NORMAL_TEXT_RATIO].join('|');
|
|
86
|
+
|
|
68
87
|
const __contrastSharedCache =
|
|
69
88
|
helpers && helpers.contrast && helpers.contrast.sharedCache
|
|
70
89
|
? helpers.contrast.sharedCache
|
|
@@ -82,8 +101,9 @@ function runInPage(ctx) {
|
|
|
82
101
|
? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
|
|
83
102
|
: null;
|
|
84
103
|
|
|
104
|
+
const FONT_CACHE = '__elFontCache' + SETTINGS_KEY;
|
|
85
105
|
const __elFontCache = __contrastSharedCache
|
|
86
|
-
? __contrastSharedCache
|
|
106
|
+
? __contrastSharedCache[FONT_CACHE] || (__contrastSharedCache[FONT_CACHE] = new WeakMap())
|
|
87
107
|
: new WeakMap();
|
|
88
108
|
|
|
89
109
|
function safeComputedStyle(el) {
|
|
@@ -124,7 +144,10 @@ function runInPage(ctx) {
|
|
|
124
144
|
|
|
125
145
|
const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
|
|
126
146
|
const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
|
|
127
|
-
const isLarge =
|
|
147
|
+
const isLarge =
|
|
148
|
+
BOLD_LARGE_MIN_PX === null
|
|
149
|
+
? helpers.contrast.isLargeText(sizePx, fontWeightNum)
|
|
150
|
+
: helpers.contrast.isLargeText(sizePx, fontWeightNum, BOLD_LARGE_MIN_PX);
|
|
128
151
|
|
|
129
152
|
const out = {
|
|
130
153
|
fontSizePx: sizePx,
|
|
@@ -228,10 +251,10 @@ function runInPage(ctx) {
|
|
|
228
251
|
selector: '',
|
|
229
252
|
html: '',
|
|
230
253
|
summary: '',
|
|
231
|
-
hint:
|
|
254
|
+
hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
|
|
232
255
|
i18n: {
|
|
233
256
|
summaryKey: 'contrastMinimum_fail_belowThreshold',
|
|
234
|
-
hintKey: '',
|
|
257
|
+
hintKey: 'contrastMinimum_hint_fail',
|
|
235
258
|
params: params && typeof params === 'object' ? params : {}
|
|
236
259
|
},
|
|
237
260
|
...(uncertainty ? { uncertainty } : {}),
|
|
@@ -278,9 +301,10 @@ function runInPage(ctx) {
|
|
|
278
301
|
let __elAnalysisCache = new WeakMap();
|
|
279
302
|
if (__contrastSharedCache) {
|
|
280
303
|
try {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
304
|
+
const ANALYSIS_CACHE = '__elAnalysisCacheAA' + SETTINGS_KEY;
|
|
305
|
+
if (!__contrastSharedCache[ANALYSIS_CACHE])
|
|
306
|
+
__contrastSharedCache[ANALYSIS_CACHE] = new WeakMap();
|
|
307
|
+
__elAnalysisCache = __contrastSharedCache[ANALYSIS_CACHE];
|
|
284
308
|
} catch {
|
|
285
309
|
__elAnalysisCache = new WeakMap();
|
|
286
310
|
}
|
|
@@ -372,7 +396,12 @@ function runInPage(ctx) {
|
|
|
372
396
|
const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
|
|
373
397
|
|
|
374
398
|
const font = getFontInfo(el);
|
|
375
|
-
const threshold =
|
|
399
|
+
const threshold =
|
|
400
|
+
SETTINGS_KEY === ''
|
|
401
|
+
? helpers.contrast.requiredRatio('AA', font.isLargeText)
|
|
402
|
+
: font.isLargeText
|
|
403
|
+
? LARGE_TEXT_RATIO
|
|
404
|
+
: NORMAL_TEXT_RATIO;
|
|
376
405
|
|
|
377
406
|
analysis = {
|
|
378
407
|
computable: true,
|
|
@@ -491,11 +520,11 @@ function runInPage(ctx) {
|
|
|
491
520
|
{
|
|
492
521
|
selector: '',
|
|
493
522
|
summary: '',
|
|
494
|
-
hint: '
|
|
523
|
+
hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
|
|
495
524
|
html: '',
|
|
496
525
|
i18n: {
|
|
497
526
|
summaryKey: 'contrastMinimum_cantTell_engineFailure',
|
|
498
|
-
hintKey: '',
|
|
527
|
+
hintKey: 'contrast_hint_cantTell_manual',
|
|
499
528
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
500
529
|
},
|
|
501
530
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -564,4 +593,9 @@ function runInPage(ctx) {
|
|
|
564
593
|
};
|
|
565
594
|
}
|
|
566
595
|
|
|
567
|
-
|
|
596
|
+
// The thresholds a variant may change (docs/RULE_AUTHORING.md, "Rule
|
|
597
|
+
// variants"), with WCAG 1.4.3's as defaults: the size from which bold text is
|
|
598
|
+
// large (null: WCAG's 14pt), and the ratios large and other text need.
|
|
599
|
+
const settings = { boldLargeMinPx: null, largeTextRatio: 3, normalTextRatio: 4.5 };
|
|
600
|
+
|
|
601
|
+
module.exports = { id, meta, runInPage, settings };
|
|
@@ -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 };
|