@surea11y/core 1.4.0 → 1.4.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 +41 -0
- package/bin/surea11y-core.js +0 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +3 -1
- package/src/checks/automatic/aria-allowed-attr.js +648 -83
- package/src/checks/automatic/aria-deprecated-role.js +105 -37
- package/src/checks/automatic/aria-roles-valid.js +31 -6
- package/src/checks/automatic/autocomplete-valid.js +37 -1
- package/src/checks/automatic/avoid-inline-spacing.js +179 -19
- package/src/checks/automatic/binary-control-name-present.js +11 -3
- package/src/checks/automatic/button-name-present.js +50 -17
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +8 -1
- package/src/checks/automatic/dialog-name-present.js +8 -1
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +107 -39
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +82 -8
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-name-present.js +50 -17
- package/src/checks/automatic/listbox-name-present.js +8 -1
- package/src/checks/automatic/menuitem-name-present.js +8 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
- package/src/checks/automatic/meter-name-present.js +8 -1
- package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +8 -1
- package/src/checks/automatic/progressbar-name-present.js +8 -1
- package/src/checks/automatic/searchbox-name-present.js +8 -1
- package/src/checks/automatic/slider-name-present.js +11 -2
- package/src/checks/automatic/spinbutton-name-present.js +8 -1
- package/src/checks/automatic/summary-name-present.js +8 -1
- package/src/checks/automatic/tab-name-present.js +8 -1
- package/src/checks/automatic/table-th-has-data-cells.js +67 -6
- package/src/checks/automatic/textbox-name-present.js +8 -1
- package/src/checks/automatic/tooltip-name-present.js +8 -1
- package/src/checks/automatic/treeitem-name-present.js +8 -1
- package/src/checks/automatic/valid-lang.js +16 -3
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
- package/src/core.js +4853 -964
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +2256 -433
|
@@ -52,6 +52,56 @@ function runInPage(ctx) {
|
|
|
52
52
|
return v.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
|
|
56
|
+
// parenthesised text, case-fold and NFKD-normalise, then reduce every
|
|
57
|
+
// non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
|
|
58
|
+
// splitting on them, which distinguishes a real mismatch from one that is
|
|
59
|
+
// only a hyphenation difference.
|
|
60
|
+
function tokenize(s, hyphensJoin) {
|
|
61
|
+
let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
|
|
62
|
+
try {
|
|
63
|
+
v = v.normalize('NFKD');
|
|
64
|
+
} catch {
|
|
65
|
+
// Realm without String#normalize: the word comparison below still holds.
|
|
66
|
+
}
|
|
67
|
+
if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
|
|
68
|
+
return v
|
|
69
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
70
|
+
.split(' ')
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The label's words must appear adjacent and in order inside the name, so a
|
|
75
|
+
// scattered subsequence does not count. Words in `prefixable` may match a
|
|
76
|
+
// longer name word they start.
|
|
77
|
+
function containsWordRun(needle, hay, prefixable) {
|
|
78
|
+
if (!needle.length) return true;
|
|
79
|
+
for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (let j = 0; j < needle.length; j++) {
|
|
82
|
+
const want = needle[j];
|
|
83
|
+
const got = hay[i + j];
|
|
84
|
+
if (got === want) continue;
|
|
85
|
+
if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
|
|
86
|
+
ok = false;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (ok) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A trailing period marks a word the author may have shortened ("Ave." for
|
|
95
|
+
// "Avenue"). Tokenizing removes the period, so collect these beforehand.
|
|
96
|
+
function abbreviatedWords(s) {
|
|
97
|
+
const out = new Set();
|
|
98
|
+
for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
|
|
99
|
+
const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
|
|
100
|
+
if (m) out.add(m[1].toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
function getElementDescriptor(el) {
|
|
56
106
|
const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
|
|
57
107
|
let role;
|
|
@@ -297,7 +347,23 @@ function runInPage(ctx) {
|
|
|
297
347
|
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
298
348
|
const accNorm = norm(accName);
|
|
299
349
|
|
|
300
|
-
const
|
|
350
|
+
const labelTokens = tokenize(visibleLabel, false);
|
|
351
|
+
const nameTokens = tokenize(accName, false);
|
|
352
|
+
const contains = containsWordRun(labelTokens, nameTokens, null);
|
|
353
|
+
|
|
354
|
+
// An abbreviation, or a word hyphenated differently in the two places, is
|
|
355
|
+
// not something markup settles: the author may have meant either. Report
|
|
356
|
+
// without asserting a defect instead of failing or staying silent.
|
|
357
|
+
let uncertainty = '';
|
|
358
|
+
if (!contains) {
|
|
359
|
+
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
360
|
+
uncertainty = 'HYPHENATION_DIFFERS';
|
|
361
|
+
} else {
|
|
362
|
+
const abbreviated = abbreviatedWords(visibleLabel);
|
|
363
|
+
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
|
|
364
|
+
uncertainty = 'POSSIBLE_ABBREVIATION';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
301
367
|
|
|
302
368
|
if (!contains) {
|
|
303
369
|
const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
@@ -308,11 +374,16 @@ function runInPage(ctx) {
|
|
|
308
374
|
occurrences.push({
|
|
309
375
|
selector: selectorOut,
|
|
310
376
|
html,
|
|
311
|
-
|
|
312
|
-
|
|
377
|
+
...(uncertainty ? { outcome: 'cantTell' } : null),
|
|
378
|
+
summary: uncertainty
|
|
379
|
+
? 'Accessible name may not contain the visible label text.'
|
|
380
|
+
: 'Accessible name does not contain the visible label text.',
|
|
381
|
+
hint: uncertainty
|
|
382
|
+
? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
|
|
383
|
+
: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
|
|
313
384
|
i18n: {
|
|
314
|
-
summaryKey: 'labelInName_summary_fail',
|
|
315
|
-
hintKey: 'labelInName_hint_fail',
|
|
385
|
+
summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
|
|
386
|
+
hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
|
|
316
387
|
params: {
|
|
317
388
|
element: getElementDescriptor(el),
|
|
318
389
|
visibleLabel: clipForSummary(visibleLabel),
|
|
@@ -322,10 +393,11 @@ function runInPage(ctx) {
|
|
|
322
393
|
},
|
|
323
394
|
data: {
|
|
324
395
|
details: {
|
|
325
|
-
reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
396
|
+
reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
326
397
|
visibleLabel,
|
|
327
398
|
accessibleName: accName,
|
|
328
399
|
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
400
|
+
tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
|
|
329
401
|
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
330
402
|
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
331
403
|
}
|
|
@@ -336,13 +408,15 @@ function runInPage(ctx) {
|
|
|
336
408
|
|
|
337
409
|
if (applicableCount === 0)
|
|
338
410
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
339
|
-
if (occurrences.length)
|
|
411
|
+
if (occurrences.length) {
|
|
412
|
+
const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
|
|
340
413
|
return {
|
|
341
414
|
ruleId: rule.ruleId,
|
|
342
|
-
outcome: 'fail',
|
|
415
|
+
outcome: anyFail ? 'fail' : 'cantTell',
|
|
343
416
|
severity: rule.defaultSeverity || 'minor',
|
|
344
417
|
occurrences
|
|
345
418
|
};
|
|
419
|
+
}
|
|
346
420
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
347
421
|
}
|
|
348
422
|
|
|
@@ -144,7 +144,11 @@ function runInPage(ctx) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Minimal BCP47 primary subtag check
|
|
147
|
-
|
|
147
|
+
const isValidTag =
|
|
148
|
+
helpers && typeof helpers.isValidLanguageTag === 'function'
|
|
149
|
+
? helpers.isValidLanguageTag
|
|
150
|
+
: (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
|
|
151
|
+
if (!isValidTag(lang)) {
|
|
148
152
|
return {
|
|
149
153
|
ruleId: rule.ruleId,
|
|
150
154
|
outcome: 'fail',
|
|
@@ -59,7 +59,14 @@ function runInPage(ctx) {
|
|
|
59
59
|
|
|
60
60
|
for (const el of nodes) {
|
|
61
61
|
// isAccTreeEligible returns { eligible, reasons }, not a boolean.
|
|
62
|
-
|
|
62
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
63
|
+
// (ACT c487ae), which excludes focusable aria-hidden content;
|
|
64
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
65
|
+
const eligResult = helpers.isIncludedInAccessibilityTree
|
|
66
|
+
? helpers.isIncludedInAccessibilityTree(el, ctx)
|
|
67
|
+
: helpers.isAccTreeEligible
|
|
68
|
+
? helpers.isAccTreeEligible(el, ctx)
|
|
69
|
+
: true;
|
|
63
70
|
const eligible =
|
|
64
71
|
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
65
72
|
if (!eligible) continue;
|
|
@@ -69,33 +76,59 @@ function runInPage(ctx) {
|
|
|
69
76
|
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
70
77
|
const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
|
|
71
78
|
|
|
72
|
-
// A native <a href>/<area href> (or [role="link"]) whose role has been
|
|
73
|
-
// overridden to one of these roles is no longer semantically a link —
|
|
74
|
-
// per the WAI-ARIA Accessible Name and Description Computation spec
|
|
75
|
-
// these roles are name-from-author-only, and their rendered content
|
|
76
|
-
// represents a VALUE, not a NAME (see button-name-present.js's identical
|
|
77
|
-
// fix for a real-world example — <button role="combobox">List</button> —
|
|
78
|
-
// of the same class of bug on a different host tag).
|
|
79
79
|
const role = el.getAttribute ? el.getAttribute('role') : null;
|
|
80
80
|
const roleNorm = String(role || '')
|
|
81
81
|
.replace(/\s+/g, ' ')
|
|
82
82
|
.trim()
|
|
83
83
|
.toLowerCase();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
'
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'
|
|
84
|
+
// ARIA 1.2 "Name From: author, contents". Every other known role is
|
|
85
|
+
// name-from-author-only. An unknown role falls back to the implicit role.
|
|
86
|
+
// <generated:aria-name-from-content>
|
|
87
|
+
const NAME_FROM_CONTENT_ROLES = [
|
|
88
|
+
'button',
|
|
89
|
+
'cell',
|
|
90
|
+
'checkbox',
|
|
91
|
+
'columnheader',
|
|
92
|
+
'doc-backlink',
|
|
93
|
+
'doc-biblioref',
|
|
94
|
+
'doc-glossref',
|
|
95
|
+
'doc-noteref',
|
|
96
|
+
'graphics-object',
|
|
97
|
+
'gridcell',
|
|
98
|
+
'heading',
|
|
99
|
+
'link',
|
|
100
|
+
'menuitem',
|
|
101
|
+
'menuitemcheckbox',
|
|
102
|
+
'menuitemradio',
|
|
103
|
+
'option',
|
|
104
|
+
'radio',
|
|
105
|
+
'row',
|
|
106
|
+
'rowgroup',
|
|
107
|
+
'rowheader',
|
|
108
|
+
'switch',
|
|
109
|
+
'tab',
|
|
110
|
+
'tooltip',
|
|
111
|
+
'treeitem'
|
|
92
112
|
];
|
|
93
|
-
|
|
113
|
+
// </generated:aria-name-from-content>
|
|
114
|
+
const isKnownRoleToken =
|
|
115
|
+
helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
|
|
116
|
+
? (() => {
|
|
117
|
+
try {
|
|
118
|
+
return !!helpers.aria.isKnownRole(roleNorm);
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
: false;
|
|
124
|
+
const isContentNameCandidate =
|
|
125
|
+
!roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm);
|
|
94
126
|
|
|
95
127
|
const contentName =
|
|
96
128
|
programmaticName.trim().length === 0 && isContentNameCandidate
|
|
97
129
|
? getConservativeSubtreeText(el)
|
|
98
130
|
: '';
|
|
131
|
+
|
|
99
132
|
const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
|
|
100
133
|
|
|
101
134
|
if (finalName.length === 0) {
|
|
@@ -109,9 +109,16 @@ function runInPage(ctx) {
|
|
|
109
109
|
return '';
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
113
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
114
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
112
115
|
function isEligibleAcc(helpers, el, ctx) {
|
|
113
116
|
const fn =
|
|
114
|
-
helpers && typeof helpers.
|
|
117
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
118
|
+
? helpers.isIncludedInAccessibilityTree
|
|
119
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
120
|
+
? helpers.isAccTreeEligible
|
|
121
|
+
: null;
|
|
115
122
|
if (!fn) return true;
|
|
116
123
|
try {
|
|
117
124
|
const r = fn(el, ctx);
|
|
@@ -84,9 +84,16 @@ function runInPage(ctx) {
|
|
|
84
84
|
return '';
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
88
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
89
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
87
90
|
function isEligibleAcc(helpers, el, ctx) {
|
|
88
91
|
const fn =
|
|
89
|
-
helpers && typeof helpers.
|
|
92
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
93
|
+
? helpers.isIncludedInAccessibilityTree
|
|
94
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
95
|
+
? helpers.isAccTreeEligible
|
|
96
|
+
: null;
|
|
90
97
|
if (!fn) return true;
|
|
91
98
|
try {
|
|
92
99
|
const r = fn(el, ctx);
|
|
@@ -87,6 +87,34 @@ function runInPage(ctx) {
|
|
|
87
87
|
: [];
|
|
88
88
|
|
|
89
89
|
const occurrences = [];
|
|
90
|
+
// HTML's shared declarative refresh steps. Returns the delay in seconds, or
|
|
91
|
+
// null when the value is not a valid refresh directive, in which case the
|
|
92
|
+
// browser never refreshes and there is nothing to report. Rejects a leading
|
|
93
|
+
// sign ("+72001"), a non-numeric time ("foo"), and a separator other than
|
|
94
|
+
// "," or ";" ("0:1").
|
|
95
|
+
function parseRefreshDelay(input) {
|
|
96
|
+
const s = String(input == null ? '' : input);
|
|
97
|
+
const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < s.length && isSpace(s[i])) i++;
|
|
100
|
+
let digits = '';
|
|
101
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
|
|
102
|
+
if (!digits) return null;
|
|
103
|
+
if (s[i] === '.') {
|
|
104
|
+
i++;
|
|
105
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
|
|
106
|
+
}
|
|
107
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
108
|
+
let sawSpace = false;
|
|
109
|
+
while (i < s.length && isSpace(s[i])) {
|
|
110
|
+
sawSpace = true;
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
114
|
+
if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
let applicableCount = 0;
|
|
91
119
|
|
|
92
120
|
for (const el of nodes) {
|
|
@@ -94,6 +122,7 @@ function runInPage(ctx) {
|
|
|
94
122
|
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
|
|
95
123
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
96
124
|
if (!raw) continue;
|
|
125
|
+
if (parseRefreshDelay(raw) === null) continue;
|
|
97
126
|
|
|
98
127
|
applicableCount += 1;
|
|
99
128
|
|
|
@@ -79,16 +79,42 @@ function runInPage(ctx) {
|
|
|
79
79
|
const occurrences = [];
|
|
80
80
|
let applicableCount = 0;
|
|
81
81
|
|
|
82
|
+
// HTML's shared declarative refresh steps. Returns the delay in seconds, or
|
|
83
|
+
// null when the value is not a valid refresh directive, in which case the
|
|
84
|
+
// browser never refreshes and there is nothing to report. Rejects a leading
|
|
85
|
+
// sign ("+72001"), a non-numeric time ("foo"), and a separator other than
|
|
86
|
+
// "," or ";" ("0:1").
|
|
87
|
+
function parseRefreshDelay(input) {
|
|
88
|
+
const s = String(input == null ? '' : input);
|
|
89
|
+
const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
90
|
+
let i = 0;
|
|
91
|
+
while (i < s.length && isSpace(s[i])) i++;
|
|
92
|
+
let digits = '';
|
|
93
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
|
|
94
|
+
if (!digits) return null;
|
|
95
|
+
if (s[i] === '.') {
|
|
96
|
+
i++;
|
|
97
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
|
|
98
|
+
}
|
|
99
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
100
|
+
let sawSpace = false;
|
|
101
|
+
while (i < s.length && isSpace(s[i])) {
|
|
102
|
+
sawSpace = true;
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
106
|
+
if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
for (const el of nodes) {
|
|
83
111
|
if (!el || !el.getAttribute) continue;
|
|
84
112
|
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
|
|
85
113
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
86
114
|
if (!raw) continue;
|
|
87
115
|
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
const delay = parseFloat(match[1]);
|
|
91
|
-
if (Number.isNaN(delay)) continue;
|
|
116
|
+
const delay = parseRefreshDelay(raw);
|
|
117
|
+
if (delay === null) continue;
|
|
92
118
|
|
|
93
119
|
applicableCount += 1;
|
|
94
120
|
|
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.4.4
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <meta name="viewport"> elements
|
|
13
|
-
*
|
|
12
|
+
* Applies to <meta name="viewport"> elements whose content attribute sets
|
|
13
|
+
* maximum-scale or user-scalable. Content setting neither cannot restrict
|
|
14
|
+
* zoom.
|
|
14
15
|
* @expectation
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* user-scalable is absent, yes, device-width, device-height, or a number
|
|
17
|
+
* outside the range -1 to 1; and maximum-scale is absent, device-width,
|
|
18
|
+
* device-height, negative, or 2 or more. Anything else stops the user
|
|
19
|
+
* zooming text to 200%, which WCAG 1.4.4 (Resize Text) requires.
|
|
19
20
|
* @implementation-notes
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
21
|
+
* - An unparseable value counts as a restriction, because CSS Device
|
|
22
|
+
* Adaptation translates it to 0: maximum-scale=yes disables zoom exactly
|
|
23
|
+
* as maximum-scale=0 does. A negative maximum-scale is out of range and
|
|
24
|
+
* dropped by the browser, so it restricts nothing and passes.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
const id = 'meta-viewport-zoom-enabled';
|
|
@@ -89,20 +91,40 @@ function runInPage(ctx) {
|
|
|
89
91
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
90
92
|
if (!raw) continue;
|
|
91
93
|
|
|
92
|
-
applicableCount += 1;
|
|
93
|
-
|
|
94
94
|
const parsed = parseContent(raw);
|
|
95
|
+
|
|
96
|
+
// ACT b4f0c3 applies only when content carries maximum-scale or
|
|
97
|
+
// user-scalable; content that sets neither cannot restrict zoom.
|
|
98
|
+
if (parsed['user-scalable'] === undefined && parsed['maximum-scale'] === undefined) continue;
|
|
99
|
+
|
|
100
|
+
applicableCount += 1;
|
|
95
101
|
const reasons = [];
|
|
96
102
|
|
|
103
|
+
// CSS Device Adaptation translates an unparseable value to 0, so
|
|
104
|
+
// user-scalable=invalid and maximum-scale=yes disable zoom just as
|
|
105
|
+
// user-scalable=no does. A negative maximum-scale is out of range and
|
|
106
|
+
// dropped instead, which is why it does not restrict anything.
|
|
97
107
|
const userScalable = parsed['user-scalable'];
|
|
98
|
-
if (
|
|
99
|
-
|
|
108
|
+
if (
|
|
109
|
+
userScalable !== undefined &&
|
|
110
|
+
userScalable !== 'yes' &&
|
|
111
|
+
userScalable !== 'device-width' &&
|
|
112
|
+
userScalable !== 'device-height'
|
|
113
|
+
) {
|
|
114
|
+
const scale = parseFloat(userScalable);
|
|
115
|
+
if (Number.isNaN(scale) || (scale > -1 && scale < 1)) {
|
|
116
|
+
reasons.push('user-scalable=' + userScalable);
|
|
117
|
+
}
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
const maxScaleRaw = parsed['maximum-scale'];
|
|
103
|
-
if (
|
|
121
|
+
if (
|
|
122
|
+
maxScaleRaw !== undefined &&
|
|
123
|
+
maxScaleRaw !== 'device-width' &&
|
|
124
|
+
maxScaleRaw !== 'device-height'
|
|
125
|
+
) {
|
|
104
126
|
const maxScale = parseFloat(maxScaleRaw);
|
|
105
|
-
if (
|
|
127
|
+
if (Number.isNaN(maxScale) || (maxScale >= 0 && maxScale < 2)) {
|
|
106
128
|
reasons.push('maximum-scale=' + maxScaleRaw);
|
|
107
129
|
}
|
|
108
130
|
}
|
|
@@ -67,9 +67,16 @@ function runInPage(ctx) {
|
|
|
67
67
|
return '';
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
71
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
72
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
70
73
|
function isEligibleAcc(helpers, el, ctx) {
|
|
71
74
|
const fn =
|
|
72
|
-
helpers && typeof helpers.
|
|
75
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
76
|
+
? helpers.isIncludedInAccessibilityTree
|
|
77
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
78
|
+
? helpers.isAccTreeEligible
|
|
79
|
+
: null;
|
|
73
80
|
if (!fn) return true;
|
|
74
81
|
try {
|
|
75
82
|
const r = fn(el, ctx);
|