@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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @check password-paste-enabled
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary An authentication field must not block pasting into it
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 3.3.8
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to any control whose autocomplete token is current-password,
|
|
13
|
+
* new-password or one-time-code, and to <input type="password"> unless its
|
|
14
|
+
* autocomplete names another purpose. A disabled or readonly field takes no
|
|
15
|
+
* input to block, and one outside the accessibility tree is not being asked
|
|
16
|
+
* for, so neither is in scope.
|
|
17
|
+
* @expectation
|
|
18
|
+
* A reviewer confirms the field can still be pasted into. Remembering a
|
|
19
|
+
* password is a cognitive function test, and 3.3.8 asks for a mechanism
|
|
20
|
+
* that helps the user through one; a password manager, or the clipboard
|
|
21
|
+
* for a one-time code, is that mechanism.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Advisory and capped at cantTell. Whether a handler really stops the user
|
|
24
|
+
* depends on script the markup does not carry, so no reading of it is safe
|
|
25
|
+
* enough to fail on: a split one-time-code field cancels the default paste
|
|
26
|
+
* and then spreads the digits across its boxes. The two cases are reported
|
|
27
|
+
* apart -- a handler that only cancels, and one that goes on to do more --
|
|
28
|
+
* so a reviewer knows which to look at first. `return true` allows the
|
|
29
|
+
* paste and reports nothing.
|
|
30
|
+
* - type="password" masks rather than authenticates, and a card security code
|
|
31
|
+
* is masked the same way, so an autocomplete naming another purpose puts the
|
|
32
|
+
* field out of scope. A masked field with no autocomplete cannot be told
|
|
33
|
+
* apart, which is the one case left.
|
|
34
|
+
* - `autocomplete="off"` is not reported. Browsers override it for password
|
|
35
|
+
* fields so password managers keep working, and the mechanism survives.
|
|
36
|
+
* - A handler attached with addEventListener leaves nothing to read, the
|
|
37
|
+
* limit every static scan shares.
|
|
38
|
+
* - 3.3.8 also excuses the test where the process offers another
|
|
39
|
+
* authentication method. A sibling button proves nothing about whether it
|
|
40
|
+
* works or replaces this step, so it is not inferred; the facet is recorded
|
|
41
|
+
* as partial for that reason.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const id = 'password-paste-enabled';
|
|
45
|
+
|
|
46
|
+
const meta = {
|
|
47
|
+
title: 'Authentication fields must not block pasting',
|
|
48
|
+
description:
|
|
49
|
+
'Checks that a password or one-time-code field carries no inline paste handler that cancels the paste, which would remove the password manager or clipboard that WCAG 3.3.8 relies on as the assisting mechanism.',
|
|
50
|
+
i18n: {
|
|
51
|
+
titleKey: 'passwordPasteEnabled_title',
|
|
52
|
+
descriptionKey: 'passwordPasteEnabled_description'
|
|
53
|
+
},
|
|
54
|
+
helpUrl: null,
|
|
55
|
+
tags: ['wcag22aa', 'wcag338', 'forms', 'authentication', 'atomic', 'manual', 'acc'],
|
|
56
|
+
wcagSc: ['3.3.8'],
|
|
57
|
+
normativeMappings: [
|
|
58
|
+
{
|
|
59
|
+
standard: 'WCAG',
|
|
60
|
+
version: '2.2',
|
|
61
|
+
requirement: '3.3.8',
|
|
62
|
+
title: 'Accessible Authentication (Minimum)',
|
|
63
|
+
conformanceLevel: 'AA'
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
defaultSeverity: 'serious',
|
|
67
|
+
category: 'understandable',
|
|
68
|
+
type: 'manual',
|
|
69
|
+
defaultConfidence: 'medium',
|
|
70
|
+
coverage: { facetsBySc: { '3.3.8': ['authentication-paste-not-blocked'] } }
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function runInPage(ctx) {
|
|
74
|
+
const { helpers, rule } = ctx;
|
|
75
|
+
|
|
76
|
+
// Declared inside runInPage; see scripts/build-core.js header
|
|
77
|
+
// ("runInPage MUST be self-contained").
|
|
78
|
+
const AUTH_AUTOCOMPLETE_TOKENS = ['current-password', 'new-password', 'one-time-code'];
|
|
79
|
+
|
|
80
|
+
function normalizeWs(s) {
|
|
81
|
+
return String(s || '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function autocompleteTokens(el) {
|
|
87
|
+
return normalizeWs(el.getAttribute && el.getAttribute('autocomplete'))
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.split(' ')
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A field an authentication step reads: a password input, or any control
|
|
94
|
+
// whose autocomplete token names an authentication secret. The autocomplete
|
|
95
|
+
// route matters for one-time codes, which are ordinary text inputs.
|
|
96
|
+
//
|
|
97
|
+
// type="password" is a masking control, not only an authentication one: a
|
|
98
|
+
// card security code is routinely masked the same way. An explicit
|
|
99
|
+
// autocomplete purpose that is not an authentication one says so, and takes
|
|
100
|
+
// the field back out of scope.
|
|
101
|
+
function isAuthField(el) {
|
|
102
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
103
|
+
if (tag !== 'input' && tag !== 'textarea') return false;
|
|
104
|
+
|
|
105
|
+
const tokens = autocompleteTokens(el);
|
|
106
|
+
if (AUTH_AUTOCOMPLETE_TOKENS.some((t) => tokens.includes(t))) return true;
|
|
107
|
+
|
|
108
|
+
const type = normalizeWs(el.getAttribute && el.getAttribute('type')).toLowerCase();
|
|
109
|
+
if (tag !== 'input' || type !== 'password') return false;
|
|
110
|
+
|
|
111
|
+
const declaresOtherPurpose = tokens.some(
|
|
112
|
+
(t) => t !== 'on' && t !== 'off' && !AUTH_AUTOCOMPLETE_TOKENS.includes(t)
|
|
113
|
+
);
|
|
114
|
+
return !declaresOtherPurpose;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A field that takes no input at all cannot be pasted into either, so a
|
|
118
|
+
// paste handler on it blocks nothing.
|
|
119
|
+
function acceptsInput(el) {
|
|
120
|
+
if (el.hasAttribute && (el.hasAttribute('disabled') || el.hasAttribute('readonly'))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Classifies an inline handler as 'cancelOnly', 'opaque' or 'none'.
|
|
127
|
+
//
|
|
128
|
+
// Cancelling is not the same as blocking. Replacing the default paste is
|
|
129
|
+
// how a split one-time-code field distributes the digits across its boxes,
|
|
130
|
+
// and how a password field strips stray whitespace from what was pasted --
|
|
131
|
+
// both call preventDefault and then insert the text themselves, which
|
|
132
|
+
// helps the user rather than stopping them. So a handler counts as
|
|
133
|
+
// blocking only when cancelling is the whole of what it does. Anything
|
|
134
|
+
// further -- a call, an assignment, a condition -- may well be putting the
|
|
135
|
+
// text back, and the markup does not say, so it reports cantTell.
|
|
136
|
+
function classifyHandler(source) {
|
|
137
|
+
const src = String(source || '').trim();
|
|
138
|
+
if (!src) return 'none';
|
|
139
|
+
|
|
140
|
+
const CANCEL = [
|
|
141
|
+
/\breturn\s+false\b/gi,
|
|
142
|
+
/\breturn\s*!1\b/gi,
|
|
143
|
+
/\bpreventDefault\s*\(\s*\)/gi,
|
|
144
|
+
/\breturnValue\s*=\s*(?:false|!1)\b/gi
|
|
145
|
+
];
|
|
146
|
+
// Says "yes, paste" outright, so it blocks nothing.
|
|
147
|
+
const ALLOW = [/\breturn\s+true\b/gi, /\breturn\s*!0\b/gi];
|
|
148
|
+
// Neither cancels nor re-inserts.
|
|
149
|
+
const NEUTRAL = [/\bstop(?:Immediate)?Propagation\s*\(\s*\)/gi, /\breturn\b/gi];
|
|
150
|
+
// What is left of an object reference once its method call is removed.
|
|
151
|
+
const REFS = /\b(?:window|document|this|event|evt|ev|e|arguments\[0\])\b/gi;
|
|
152
|
+
const ONLY_PUNCTUATION = /^[\s;.,()[\]{}]*$/;
|
|
153
|
+
|
|
154
|
+
let rest = src;
|
|
155
|
+
let sawCancel = false;
|
|
156
|
+
for (const re of CANCEL) {
|
|
157
|
+
const next = rest.replace(re, ' ');
|
|
158
|
+
if (next !== rest) sawCancel = true;
|
|
159
|
+
rest = next;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!sawCancel) {
|
|
163
|
+
for (const re of ALLOW) rest = rest.replace(re, ' ');
|
|
164
|
+
for (const re of NEUTRAL) rest = rest.replace(re, ' ');
|
|
165
|
+
rest = rest.replace(REFS, ' ');
|
|
166
|
+
return ONLY_PUNCTUATION.test(rest) ? 'none' : 'opaque';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const re of NEUTRAL) rest = rest.replace(re, ' ');
|
|
170
|
+
rest = rest.replace(REFS, ' ');
|
|
171
|
+
// Anything left beyond punctuation is the handler doing more than cancel.
|
|
172
|
+
return ONLY_PUNCTUATION.test(rest) ? 'cancelOnly' : 'opaque';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nodes = helpers.queryAllSmart
|
|
176
|
+
? helpers.queryAllSmart('input, textarea')
|
|
177
|
+
: helpers.queryAll('input, textarea');
|
|
178
|
+
|
|
179
|
+
const cancelling = [];
|
|
180
|
+
const undetermined = [];
|
|
181
|
+
|
|
182
|
+
for (const el of nodes) {
|
|
183
|
+
if (!el || !el.getAttribute) continue;
|
|
184
|
+
if (!isAuthField(el)) continue;
|
|
185
|
+
|
|
186
|
+
if (!acceptsInput(el)) continue;
|
|
187
|
+
|
|
188
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
189
|
+
const eligible =
|
|
190
|
+
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
191
|
+
if (!eligible) continue;
|
|
192
|
+
|
|
193
|
+
const handler = el.getAttribute('onpaste');
|
|
194
|
+
if (handler === null) continue;
|
|
195
|
+
|
|
196
|
+
const verdict = classifyHandler(handler);
|
|
197
|
+
if (verdict === 'none') continue;
|
|
198
|
+
|
|
199
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
200
|
+
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
201
|
+
: { targetSet: 'acc', accEligible: null, reasons: [] };
|
|
202
|
+
|
|
203
|
+
if (verdict === 'cancelOnly') {
|
|
204
|
+
cancelling.push(
|
|
205
|
+
helpers.reportOccurrence(el, {
|
|
206
|
+
summary:
|
|
207
|
+
'This authentication field has a paste handler whose only effect is to cancel the paste.',
|
|
208
|
+
hint: 'Confirm by hand whether pasting still works. If it is blocked, remove the handler so a password manager, or the clipboard for a one-time code, can fill the field.',
|
|
209
|
+
i18n: {
|
|
210
|
+
summaryKey: 'passwordPasteEnabled_summary_fail',
|
|
211
|
+
hintKey: 'passwordPasteEnabled_hint_fail',
|
|
212
|
+
params: {}
|
|
213
|
+
},
|
|
214
|
+
data: {
|
|
215
|
+
visibilityFilter: eligInfo,
|
|
216
|
+
details: { reasonCode: 'PASTE_CANCELLED', handler: normalizeWs(handler) }
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// An inline handler that delegates: whether it cancels lives in code the
|
|
224
|
+
// markup does not carry.
|
|
225
|
+
undetermined.push(
|
|
226
|
+
helpers.reportOccurrence(el, {
|
|
227
|
+
summary:
|
|
228
|
+
'This authentication field has a paste handler, and whether it cancels pasting could not be determined.',
|
|
229
|
+
hint: 'Check by hand that pasting into the field still works, so a password manager or the clipboard can fill it.',
|
|
230
|
+
i18n: {
|
|
231
|
+
summaryKey: 'passwordPasteEnabled_summary_cantTell',
|
|
232
|
+
hintKey: 'passwordPasteEnabled_hint_cantTell',
|
|
233
|
+
params: {}
|
|
234
|
+
},
|
|
235
|
+
data: {
|
|
236
|
+
visibilityFilter: eligInfo,
|
|
237
|
+
details: { reasonCode: 'PASTE_HANDLER_OPAQUE', handler: normalizeWs(handler) }
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const occurrences = cancelling.concat(undetermined);
|
|
244
|
+
if (!occurrences.length) {
|
|
245
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
ruleId: rule.ruleId,
|
|
249
|
+
outcome: 'cantTell',
|
|
250
|
+
severity: rule.defaultSeverity || 'serious',
|
|
251
|
+
occurrences
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -8,15 +8,19 @@
|
|
|
8
8
|
* @summary A "skip" link must resolve to a real, usable target
|
|
9
9
|
* @standard Best Practices (no formal WCAG Success Criterion)
|
|
10
10
|
* @applicability
|
|
11
|
-
* Applies to <a href="#fragment"> elements
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* Applies to <a href="#fragment"> elements that are skip links by one of
|
|
12
|
+
* two signs:
|
|
13
|
+
* - the accessible name follows a common skip-link wording in one of the
|
|
14
|
+
* shipped locales: "skip" or "jump to" (English); "aller au contenu",
|
|
15
|
+
* "passer au contenu", "accéder au contenu", "accès direct", "évitement"
|
|
16
|
+
* (French); "springen", "überspringen", "direkt zum", "zum Inhalt"
|
|
17
|
+
* (German); "saltar", "ir al contenido" (Spanish); "スキップ", "本文へ"
|
|
18
|
+
* (Japanese). "jump to" sits beside "skip" because real skip links use
|
|
19
|
+
* both conventions (e.g. a "Jump to section" link);
|
|
20
|
+
* - or it is the first link in the document, and it comes before the
|
|
21
|
+
* `main` element (or `[role="main"]`): the usual place of a skip link
|
|
22
|
+
* whatever its wording.
|
|
23
|
+
* Other same-page anchor links are not skip links and are left alone.
|
|
20
24
|
* @expectation
|
|
21
25
|
* The link's fragment resolves to a real element in the document
|
|
22
26
|
* (via a matching id, or a legacy <a name="...">), and that target is
|
|
@@ -28,10 +32,12 @@
|
|
|
28
32
|
* - Not WCAG-normative, authored as an advisory, cantTell-capped
|
|
29
33
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
30
34
|
* header comment for the shared rationale/precedent.
|
|
31
|
-
* - Keyed on the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
+
* - Keyed mainly on the wording, matching the same deliberate-leniency
|
|
36
|
+
* reasoning documented in bypass-blocks-present's implementation notes.
|
|
37
|
+
* The one positional sign is narrow on purpose: only the page's very
|
|
38
|
+
* first link, and only when a main landmark follows it, so an ordinary
|
|
39
|
+
* in-page link (a "Menu" toggle after other links, a table of contents)
|
|
40
|
+
* is not taken for a skip link.
|
|
35
41
|
*/
|
|
36
42
|
|
|
37
43
|
const id = 'skip-link';
|
|
@@ -109,10 +115,32 @@ function runInPage(ctx) {
|
|
|
109
115
|
|
|
110
116
|
const geometrySupported = hasReliableGeometrySupport();
|
|
111
117
|
|
|
118
|
+
// Skip-link wording in the shipped locales, one list for every rule that
|
|
119
|
+
// looks for a skip link (helpers.hasSkipLinkWording, docs/RULE_HELPERS.md).
|
|
120
|
+
const hasSkipWording = (name) => helpers.hasSkipLinkWording(name);
|
|
121
|
+
|
|
112
122
|
const nodes = helpers.queryAllSmart
|
|
113
123
|
? helpers.queryAllSmart('a[href]')
|
|
114
124
|
: helpers.queryAll('a[href]');
|
|
115
125
|
|
|
126
|
+
// The page's first link, when it comes before the main landmark, is where a
|
|
127
|
+
// skip link sits whatever its wording.
|
|
128
|
+
let positionalSkipLink = null;
|
|
129
|
+
try {
|
|
130
|
+
const main = document.querySelector('main, [role="main"]');
|
|
131
|
+
const first = nodes.length ? nodes[0] : null;
|
|
132
|
+
if (
|
|
133
|
+
main &&
|
|
134
|
+
first &&
|
|
135
|
+
typeof first.compareDocumentPosition === 'function' &&
|
|
136
|
+
first.compareDocumentPosition(main) & 4 // Node.DOCUMENT_POSITION_FOLLOWING
|
|
137
|
+
) {
|
|
138
|
+
positionalSkipLink = first;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
positionalSkipLink = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
const occurrences = [];
|
|
117
145
|
let applicableCount = 0;
|
|
118
146
|
|
|
@@ -123,7 +151,7 @@ function runInPage(ctx) {
|
|
|
123
151
|
if (href.length < 2 || href.charAt(0) !== '#') continue;
|
|
124
152
|
|
|
125
153
|
const name = getAccessibleNameText(el);
|
|
126
|
-
if (
|
|
154
|
+
if (el !== positionalSkipLink && !hasSkipWording(name)) continue;
|
|
127
155
|
|
|
128
156
|
applicableCount += 1;
|
|
129
157
|
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* `<table>` elements with no `<caption>` child, at least two rows, and
|
|
13
13
|
* a first row containing exactly one non-empty-text cell while at
|
|
14
|
-
* least one other row has more than one cell.
|
|
14
|
+
* least one other row has more than one cell. Left out: a table whose
|
|
15
|
+
* role (first token) is anything but table, grid or treegrid, such as a
|
|
16
|
+
* layout table with role="presentation", which needs no caption; and a
|
|
17
|
+
* table already named by a non-empty aria-label, an aria-labelledby that
|
|
18
|
+
* resolves to text, or a non-empty title, which WCAG accepts as the
|
|
19
|
+
* table's title.
|
|
15
20
|
* @expectation
|
|
16
21
|
* A single lone cell in the first row, sitting above rows that clearly
|
|
17
22
|
* have multiple columns, strongly suggests the author is using it as a
|
|
@@ -79,6 +84,31 @@ function runInPage(ctx) {
|
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
const getAriaNameInfo =
|
|
88
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
89
|
+
|
|
90
|
+
const TABLE_ROLES = ['table', 'grid', 'treegrid'];
|
|
91
|
+
|
|
92
|
+
function hasOtherRole(table) {
|
|
93
|
+
const role = trim(table.getAttribute('role')).toLowerCase().split(/\s+/)[0];
|
|
94
|
+
return !!role && !TABLE_ROLES.includes(role);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A name from aria-labelledby, aria-label or title already gives the table
|
|
98
|
+
// a title that assistive technology announces.
|
|
99
|
+
function isNamed(table) {
|
|
100
|
+
if (trim(table.getAttribute('title'))) return true;
|
|
101
|
+
if (getAriaNameInfo) {
|
|
102
|
+
try {
|
|
103
|
+
const aria = getAriaNameInfo(table, ctx);
|
|
104
|
+
return !!(aria && aria.present && trim(aria.value));
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return !!trim(table.getAttribute('aria-label'));
|
|
110
|
+
}
|
|
111
|
+
|
|
82
112
|
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
|
|
83
113
|
|
|
84
114
|
const occurrences = [];
|
|
@@ -89,6 +119,7 @@ function runInPage(ctx) {
|
|
|
89
119
|
|
|
90
120
|
const hasCaption = !!(table.querySelector && table.querySelector('caption'));
|
|
91
121
|
if (hasCaption) continue;
|
|
122
|
+
if (hasOtherRole(table) || isNamed(table)) continue;
|
|
92
123
|
|
|
93
124
|
// An aria-hidden row (or cell) isn't part of the AT-perceived table
|
|
94
125
|
// structure at all -- it must not be treated as the table's "first
|
|
@@ -19,14 +19,15 @@
|
|
|
19
19
|
* fail), matching the precedent set by
|
|
20
20
|
* `media-alternative-transcript-evidence` for the same class
|
|
21
21
|
* of "normatively mapped but not statically verifiable" gap. A <video>
|
|
22
|
-
* with a `<track kind="captions">`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* with a `<track kind="captions">` whose `src` is non-empty is not
|
|
23
|
+
* flagged; everything else is flagged for human review. A video whose
|
|
24
|
+
* only text tracks are subtitles (`kind="subtitles"`, or no `kind`,
|
|
25
|
+
* which HTML treats as subtitles) gets its own question: subtitles may
|
|
26
|
+
* be a translation of the dialogue only, without the speaker and sound
|
|
27
|
+
* information captions carry.
|
|
26
28
|
* @implementation-notes
|
|
27
29
|
* - Does not attempt to verify the referenced track file's content,
|
|
28
|
-
* only that a captions
|
|
29
|
-
* `src`.
|
|
30
|
+
* only that a captions track is declared with a non-empty `src`.
|
|
30
31
|
*/
|
|
31
32
|
|
|
32
33
|
const id = 'video-caption';
|
|
@@ -34,7 +35,7 @@ const id = 'video-caption';
|
|
|
34
35
|
const meta = {
|
|
35
36
|
title: 'Prerecorded video should provide a captions track',
|
|
36
37
|
description:
|
|
37
|
-
'Flags <video> elements with no <track kind="captions"
|
|
38
|
+
'Flags <video> elements with no <track kind="captions"> child, for manual review of whether the video has an audio track that needs captions; a subtitles track alone may be a translation only.',
|
|
38
39
|
i18n: {
|
|
39
40
|
titleKey: 'videoCaption_title',
|
|
40
41
|
descriptionKey: 'videoCaption_description'
|
|
@@ -72,14 +73,20 @@ function runInPage(ctx) {
|
|
|
72
73
|
applicableCount += 1;
|
|
73
74
|
|
|
74
75
|
let hasCaptionsTrack = false;
|
|
76
|
+
let hasSubtitlesTrack = false;
|
|
75
77
|
const tracks = el.querySelectorAll('track');
|
|
76
78
|
for (const t of tracks) {
|
|
77
|
-
|
|
79
|
+
// A missing kind means subtitles (HTML's missing-value default).
|
|
80
|
+
const kind = t.hasAttribute('kind')
|
|
81
|
+
? (t.getAttribute('kind') || '').trim().toLowerCase()
|
|
82
|
+
: 'subtitles';
|
|
78
83
|
const src = (t.getAttribute('src') || '').trim();
|
|
79
|
-
if (
|
|
84
|
+
if (!src) continue;
|
|
85
|
+
if (kind === 'captions') {
|
|
80
86
|
hasCaptionsTrack = true;
|
|
81
87
|
break;
|
|
82
88
|
}
|
|
89
|
+
if (kind === 'subtitles') hasSubtitlesTrack = true;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
if (hasCaptionsTrack) continue;
|
|
@@ -87,20 +94,36 @@ function runInPage(ctx) {
|
|
|
87
94
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
88
95
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
89
96
|
|
|
90
|
-
const baseOccurrence =
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
const baseOccurrence = hasSubtitlesTrack
|
|
98
|
+
? {
|
|
99
|
+
selector: stableSelector,
|
|
100
|
+
html,
|
|
101
|
+
summary:
|
|
102
|
+
'This video has only subtitles tracks, which may translate the dialogue without the speaker and sound information captions carry.',
|
|
103
|
+
hint: 'If this video has an audio track that conveys information, check that a subtitles track is in fact captions, and mark it <track kind="captions">; otherwise add a captions track.',
|
|
104
|
+
i18n: {
|
|
105
|
+
summaryKey: 'videoCaption_summary_cantTell_subtitlesOnly',
|
|
106
|
+
hintKey: 'videoCaption_hint_cantTell_subtitlesOnly',
|
|
107
|
+
params: {}
|
|
108
|
+
},
|
|
109
|
+
data: {
|
|
110
|
+
details: { reasonCode: 'SUBTITLES_TRACK_ONLY' }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
: {
|
|
114
|
+
selector: stableSelector,
|
|
115
|
+
html,
|
|
116
|
+
summary: 'This video has no captions track.',
|
|
117
|
+
hint: 'If this video has an audio track that conveys information, add a <track kind="captions" src="..."> with the captioned content.',
|
|
118
|
+
i18n: {
|
|
119
|
+
summaryKey: 'videoCaption_summary_cantTell',
|
|
120
|
+
hintKey: 'videoCaption_hint_cantTell',
|
|
121
|
+
params: {}
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
details: { reasonCode: 'CAPTIONS_TRACK_NOT_DETECTED' }
|
|
125
|
+
}
|
|
126
|
+
};
|
|
104
127
|
|
|
105
128
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
106
129
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
@@ -123,7 +146,7 @@ function runInPage(ctx) {
|
|
|
123
146
|
}
|
|
124
147
|
|
|
125
148
|
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|
|
126
|
-
// every <video> already has a captions
|
|
149
|
+
// every <video> already has a captions track.
|
|
127
150
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
128
151
|
}
|
|
129
152
|
|
|
@@ -63,10 +63,6 @@ const meta = {
|
|
|
63
63
|
url: 'https://www.w3.org/TR/WCAG22/#focus-visible'
|
|
64
64
|
},
|
|
65
65
|
|
|
66
|
-
{ standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.1.1', title: 'Keyboard' },
|
|
67
|
-
{ standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.4.3', title: 'Focus Order' },
|
|
68
|
-
{ standard: 'EN 301 549', version: 'V3.2.1', requirement: '9.2.4.7', title: 'Focus Visible' },
|
|
69
|
-
|
|
70
66
|
{
|
|
71
67
|
standard: 'WCAG',
|
|
72
68
|
version: '2.2',
|