@surea11y/core 1.6.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- 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 +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- 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-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -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 +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- 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-alt-present.js → 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 +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- 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 +243 -29
- 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/landmark-complementary-is-top-level-manual.js +231 -0
- 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/password-paste-enabled-manual.js +255 -0
- 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 +18061 -46194
- 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/earl.js +144 -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 +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -216,16 +216,28 @@ function runInPage(ctx) {
|
|
|
216
216
|
|
|
217
217
|
const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
|
|
218
218
|
|
|
219
|
+
// The background is the only input this rule can fail to resolve; every other
|
|
220
|
+
// reason code here describes a ratio it did compute.
|
|
221
|
+
const uncertainty =
|
|
222
|
+
det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
|
|
223
|
+
? {
|
|
224
|
+
code: 'not-computable',
|
|
225
|
+
needed: 'The effective background colour behind this text.',
|
|
226
|
+
evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
|
|
227
|
+
}
|
|
228
|
+
: null;
|
|
229
|
+
|
|
219
230
|
const occBase = {
|
|
220
231
|
selector: '',
|
|
221
232
|
html: '',
|
|
222
233
|
summary: '',
|
|
223
|
-
hint:
|
|
234
|
+
hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
|
|
224
235
|
i18n: {
|
|
225
236
|
summaryKey: 'contrastEnhanced_fail_belowThreshold',
|
|
226
|
-
hintKey: '',
|
|
237
|
+
hintKey: 'contrastEnhanced_hint_fail',
|
|
227
238
|
params: params && typeof params === 'object' ? params : {}
|
|
228
239
|
},
|
|
240
|
+
...(uncertainty ? { uncertainty } : {}),
|
|
229
241
|
data: { details: det }
|
|
230
242
|
};
|
|
231
243
|
|
|
@@ -486,11 +498,11 @@ function runInPage(ctx) {
|
|
|
486
498
|
{
|
|
487
499
|
selector: '',
|
|
488
500
|
summary: '',
|
|
489
|
-
hint: '
|
|
501
|
+
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).",
|
|
490
502
|
html: '',
|
|
491
503
|
i18n: {
|
|
492
504
|
summaryKey: 'contrastEnhanced_cantTell_engineFailure',
|
|
493
|
-
hintKey: '',
|
|
505
|
+
hintKey: 'contrast_hint_cantTell_manual',
|
|
494
506
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
495
507
|
},
|
|
496
508
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -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,
|
|
@@ -213,16 +236,28 @@ function runInPage(ctx) {
|
|
|
213
236
|
|
|
214
237
|
const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
|
|
215
238
|
|
|
239
|
+
// The background is the only input this rule can fail to resolve; every other
|
|
240
|
+
// reason code here describes a ratio it did compute.
|
|
241
|
+
const uncertainty =
|
|
242
|
+
det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
|
|
243
|
+
? {
|
|
244
|
+
code: 'not-computable',
|
|
245
|
+
needed: 'The effective background colour behind this text.',
|
|
246
|
+
evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
|
|
247
|
+
}
|
|
248
|
+
: null;
|
|
249
|
+
|
|
216
250
|
const occBase = {
|
|
217
251
|
selector: '',
|
|
218
252
|
html: '',
|
|
219
253
|
summary: '',
|
|
220
|
-
hint:
|
|
254
|
+
hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
|
|
221
255
|
i18n: {
|
|
222
256
|
summaryKey: 'contrastMinimum_fail_belowThreshold',
|
|
223
|
-
hintKey: '',
|
|
257
|
+
hintKey: 'contrastMinimum_hint_fail',
|
|
224
258
|
params: params && typeof params === 'object' ? params : {}
|
|
225
259
|
},
|
|
260
|
+
...(uncertainty ? { uncertainty } : {}),
|
|
226
261
|
data: { details: det }
|
|
227
262
|
};
|
|
228
263
|
|
|
@@ -266,9 +301,10 @@ function runInPage(ctx) {
|
|
|
266
301
|
let __elAnalysisCache = new WeakMap();
|
|
267
302
|
if (__contrastSharedCache) {
|
|
268
303
|
try {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
304
|
+
const ANALYSIS_CACHE = '__elAnalysisCacheAA' + SETTINGS_KEY;
|
|
305
|
+
if (!__contrastSharedCache[ANALYSIS_CACHE])
|
|
306
|
+
__contrastSharedCache[ANALYSIS_CACHE] = new WeakMap();
|
|
307
|
+
__elAnalysisCache = __contrastSharedCache[ANALYSIS_CACHE];
|
|
272
308
|
} catch {
|
|
273
309
|
__elAnalysisCache = new WeakMap();
|
|
274
310
|
}
|
|
@@ -360,7 +396,12 @@ function runInPage(ctx) {
|
|
|
360
396
|
const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
|
|
361
397
|
|
|
362
398
|
const font = getFontInfo(el);
|
|
363
|
-
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;
|
|
364
405
|
|
|
365
406
|
analysis = {
|
|
366
407
|
computable: true,
|
|
@@ -479,11 +520,11 @@ function runInPage(ctx) {
|
|
|
479
520
|
{
|
|
480
521
|
selector: '',
|
|
481
522
|
summary: '',
|
|
482
|
-
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).",
|
|
483
524
|
html: '',
|
|
484
525
|
i18n: {
|
|
485
526
|
summaryKey: 'contrastMinimum_cantTell_engineFailure',
|
|
486
|
-
hintKey: '',
|
|
527
|
+
hintKey: 'contrast_hint_cantTell_manual',
|
|
487
528
|
params: { reasonCode: 'ENGINE_EXCEPTION' }
|
|
488
529
|
},
|
|
489
530
|
data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
|
|
@@ -552,4 +593,9 @@ function runInPage(ctx) {
|
|
|
552
593
|
};
|
|
553
594
|
}
|
|
554
595
|
|
|
555
|
-
|
|
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,18 +243,52 @@ 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;
|
|
291
|
+
let unreadableSheetCount = 0;
|
|
243
292
|
|
|
244
293
|
try {
|
|
245
294
|
const sheets = document.styleSheets || [];
|
|
@@ -248,7 +297,10 @@ function runInPage(ctx) {
|
|
|
248
297
|
try {
|
|
249
298
|
rules = sheet && sheet.cssRules ? sheet.cssRules : null;
|
|
250
299
|
} catch {
|
|
251
|
-
|
|
300
|
+
// Cross-origin, not inspectable. Counted, since a lock could be
|
|
301
|
+
// declared there and a `pass` would claim more than was checked.
|
|
302
|
+
unreadableSheetCount += 1;
|
|
303
|
+
continue;
|
|
252
304
|
}
|
|
253
305
|
if (!rules) continue;
|
|
254
306
|
sheetCount += 1;
|
|
@@ -257,22 +309,116 @@ function runInPage(ctx) {
|
|
|
257
309
|
if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
|
|
258
310
|
const mediaText = rule2.media ? rule2.media.mediaText : '';
|
|
259
311
|
if (!isOrientationMedia(mediaText)) continue;
|
|
260
|
-
scanRuleList(rule2.cssRules, mediaText, findings);
|
|
312
|
+
scanRuleList(rule2.cssRules, mediaText, findings, hidings);
|
|
261
313
|
}
|
|
262
314
|
}
|
|
263
315
|
} catch {
|
|
264
316
|
// no-throw: treat as no accessible stylesheets
|
|
265
317
|
}
|
|
266
318
|
|
|
267
|
-
|
|
268
|
-
|
|
319
|
+
const scanTarget = document.documentElement || document.body || null;
|
|
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
|
+
|
|
391
|
+
// A lock found in a readable sheet is still a lock, so `fail` outranks the
|
|
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
|
+
};
|
|
269
403
|
}
|
|
270
404
|
|
|
271
405
|
if (!findings.length) {
|
|
406
|
+
if (unreadableSheetCount > 0) {
|
|
407
|
+
return {
|
|
408
|
+
ruleId: rule.ruleId,
|
|
409
|
+
outcome: 'cantTell',
|
|
410
|
+
severity: rule.defaultSeverity || 'serious',
|
|
411
|
+
confidence: 'low',
|
|
412
|
+
occurrences: [unreadableSheetsOccurrence(unreadableSheetCount)]
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
if (sheetCount === 0) {
|
|
416
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
417
|
+
}
|
|
272
418
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
273
419
|
}
|
|
274
420
|
|
|
275
|
-
const target =
|
|
421
|
+
const target = scanTarget;
|
|
276
422
|
const occurrences = findings.map((f) =>
|
|
277
423
|
helpers.reportOccurrence(target, {
|
|
278
424
|
summary: f.selectorText
|
|
@@ -296,6 +442,19 @@ function runInPage(ctx) {
|
|
|
296
442
|
})
|
|
297
443
|
);
|
|
298
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
|
+
|
|
299
458
|
return {
|
|
300
459
|
ruleId: rule.ruleId,
|
|
301
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: {
|