@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
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check p-as-heading
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Text styled to look like a heading should probably be a real heading
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* `<p>` elements
|
|
13
|
-
*
|
|
14
|
-
* `font-weight` >= 700,
|
|
15
|
-
* `<strong>`/`<b>`
|
|
12
|
+
* `<p>` elements, and `<div>` elements that hold only text and inline
|
|
13
|
+
* markup, with short (<=120 char), non-empty trimmed text in which every
|
|
14
|
+
* piece of text is bold (computed `font-weight` >= 700, however it got
|
|
15
|
+
* there: on the element itself, a `<strong>`/`<b>` or a styled `<span>`)
|
|
16
|
+
* and rendered at >=18px.
|
|
16
17
|
* @expectation
|
|
17
18
|
* Text styled to visually read as a heading (bold, larger-than-body
|
|
18
19
|
* size, short) should be marked up with a real heading element
|
|
@@ -30,14 +31,22 @@
|
|
|
30
31
|
* surrounding text (unlike `link-in-text-block`); it's simpler on
|
|
31
32
|
* purpose, since "looks like a heading" is closer to an absolute
|
|
32
33
|
* judgment than a relative-contrast one.
|
|
34
|
+
* - Weight and size are read from the element that holds each piece of
|
|
35
|
+
* text, so `<p><span style="font-weight:bold">` counts and a `<p>` with
|
|
36
|
+
* one normal-weight word does not.
|
|
37
|
+
* - A `<div>` is only considered when it has no block, list, table, form
|
|
38
|
+
* control or image inside it, so only the innermost block is asked
|
|
39
|
+
* about. A `<div>` with a role, and text inside a heading, button,
|
|
40
|
+
* label, legend, caption, table header or `<summary>`, are left out:
|
|
41
|
+
* that text already has a role of its own.
|
|
33
42
|
*/
|
|
34
43
|
|
|
35
44
|
const id = 'p-as-heading';
|
|
36
45
|
|
|
37
46
|
const meta = {
|
|
38
|
-
title: '
|
|
47
|
+
title: 'Text styled to look like a heading should probably be a real heading',
|
|
39
48
|
description:
|
|
40
|
-
'Flags short <p> elements whose
|
|
49
|
+
'Flags short <p> and <div> elements whose text is all bold and rendered at >=18px, for manual review of whether a real heading element should be used instead.',
|
|
41
50
|
i18n: {
|
|
42
51
|
titleKey: 'pAsHeading_title',
|
|
43
52
|
descriptionKey: 'pAsHeading_description'
|
|
@@ -93,65 +102,101 @@ function runInPage(ctx) {
|
|
|
93
102
|
return Number.isFinite(n) && n >= 700;
|
|
94
103
|
}
|
|
95
104
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
// Elements whose text already has a role of its own.
|
|
106
|
+
const OWN_ROLE_ANCESTORS =
|
|
107
|
+
'h1, h2, h3, h4, h5, h6, [role="heading"], button, [role="button"], label, legend, caption, th, [role="columnheader"], [role="rowheader"], summary';
|
|
108
|
+
|
|
109
|
+
// Anything but text and inline markup makes a <div> a container, not a
|
|
110
|
+
// passage of text.
|
|
111
|
+
const NOT_INLINE =
|
|
112
|
+
'address, article, aside, blockquote, details, dialog, div, dl, fieldset, figure, figcaption, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, hr, li, main, nav, ol, p, pre, section, table, ul, img, svg, picture, video, audio, canvas, iframe, object, embed, input, select, textarea, button';
|
|
113
|
+
|
|
114
|
+
function textPieces(el) {
|
|
115
|
+
const pieces = [];
|
|
116
|
+
const doc = el.ownerDocument;
|
|
117
|
+
const walker = doc.createTreeWalker(el, 4 /* NodeFilter.SHOW_TEXT */);
|
|
118
|
+
let node = walker.nextNode();
|
|
119
|
+
while (node) {
|
|
120
|
+
if (trim(node.nodeValue) && node.parentElement) pieces.push(node.parentElement);
|
|
121
|
+
node = walker.nextNode();
|
|
109
122
|
}
|
|
110
|
-
return
|
|
123
|
+
return pieces;
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
126
|
+
// Every piece of text is bold, and the smallest is the size reported.
|
|
127
|
+
function boldSize(el) {
|
|
128
|
+
let minPx = Infinity;
|
|
129
|
+
for (const holder of textPieces(el)) {
|
|
130
|
+
const cs = safeComputedStyle(holder);
|
|
131
|
+
if (!isBoldWeight(cs)) return 0;
|
|
132
|
+
const px = Number.parseFloat(cs.fontSize);
|
|
133
|
+
if (!Number.isFinite(px)) return 0;
|
|
134
|
+
minPx = Math.min(minPx, px);
|
|
135
|
+
}
|
|
136
|
+
return Number.isFinite(minPx) ? minPx : 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isCandidate(el) {
|
|
140
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
141
|
+
if (el.closest && el.closest(OWN_ROLE_ANCESTORS)) return false;
|
|
142
|
+
if (tag === 'p') return true;
|
|
143
|
+
if (tag !== 'div') return false;
|
|
144
|
+
if (trim(el.getAttribute('role'))) return false;
|
|
145
|
+
return !el.querySelector(NOT_INLINE);
|
|
118
146
|
}
|
|
119
147
|
|
|
120
|
-
const nodes = helpers.queryAllSmart
|
|
148
|
+
const nodes = helpers.queryAllSmart
|
|
149
|
+
? helpers.queryAllSmart('p, div')
|
|
150
|
+
: helpers.queryAll('p, div');
|
|
121
151
|
|
|
122
152
|
const occurrences = [];
|
|
123
153
|
let applicableCount = 0;
|
|
124
154
|
|
|
125
155
|
for (const el of nodes) {
|
|
126
156
|
if (!el || !el.getAttribute) continue;
|
|
157
|
+
if (!isCandidate(el)) continue;
|
|
127
158
|
|
|
128
159
|
const text = trim(el.textContent || '');
|
|
129
160
|
if (!text || text.length > MAX_HEADING_LIKE_CHARS) continue;
|
|
130
161
|
|
|
131
162
|
applicableCount += 1;
|
|
132
163
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const fontSizePx = getFontSizePx(el);
|
|
164
|
+
const fontSizePx = boldSize(el);
|
|
136
165
|
if (fontSizePx < MIN_FONT_SIZE_PX) continue;
|
|
137
166
|
|
|
167
|
+
const isParagraph = (el.tagName || '').toLowerCase() === 'p';
|
|
138
168
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
139
169
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
140
170
|
|
|
141
|
-
const baseOccurrence =
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
171
|
+
const baseOccurrence = isParagraph
|
|
172
|
+
? {
|
|
173
|
+
selector: stableSelector,
|
|
174
|
+
html,
|
|
175
|
+
summary: 'This paragraph is entirely bold and rendered at a heading-like size.',
|
|
176
|
+
hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a paragraph to look like one.',
|
|
177
|
+
i18n: {
|
|
178
|
+
summaryKey: 'pAsHeading_summary_cantTell',
|
|
179
|
+
hintKey: 'pAsHeading_hint_cantTell',
|
|
180
|
+
params: { fontSizePx: String(fontSizePx) }
|
|
181
|
+
},
|
|
182
|
+
data: {
|
|
183
|
+
details: { reasonCode: 'BOLD_LARGE_PARAGRAPH', fontSizePx }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
: {
|
|
187
|
+
selector: stableSelector,
|
|
188
|
+
html,
|
|
189
|
+
summary: 'This block of text is entirely bold and rendered at a heading-like size.',
|
|
190
|
+
hint: 'If this text introduces a new section, use a real heading element (<h1>-<h6> or role="heading") instead of styling a <div> to look like one.',
|
|
191
|
+
i18n: {
|
|
192
|
+
summaryKey: 'pAsHeading_summary_cantTell_div',
|
|
193
|
+
hintKey: 'pAsHeading_hint_cantTell_div',
|
|
194
|
+
params: { fontSizePx: String(fontSizePx) }
|
|
195
|
+
},
|
|
196
|
+
data: {
|
|
197
|
+
details: { reasonCode: 'BOLD_LARGE_DIV', fontSizePx }
|
|
198
|
+
}
|
|
199
|
+
};
|
|
155
200
|
|
|
156
201
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
157
202
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* @sc 2.4.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to a run over a whole document whose <title> resolves to
|
|
13
|
-
* non-empty text
|
|
13
|
+
* non-empty text. The title element is the first HTML <title> anywhere in
|
|
14
|
+
* the document, as for document.title, so one the parser left in <body>
|
|
15
|
+
* counts; a missing or empty title is page-title-present's
|
|
14
16
|
* failure, not a pattern to review. A run narrowed by contextSelector or
|
|
15
17
|
* by engineOptions.fragment is notApplicable, as is a title matching none
|
|
16
18
|
* of the patterns below.
|
|
@@ -78,7 +80,24 @@ function runInPage(ctx) {
|
|
|
78
80
|
const occurrences = [];
|
|
79
81
|
let applicableCount = 1;
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
// The document's title element, found as page-title-present finds it: the
|
|
84
|
+
// first HTML-namespace <title> anywhere in the document, since a <title>
|
|
85
|
+
// the parser leaves in <body> is still what document.title reads. An inline
|
|
86
|
+
// <svg><title> is not the page title.
|
|
87
|
+
const HTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
88
|
+
let titleEl = null;
|
|
89
|
+
for (const t of Array.from(document.getElementsByTagName('title'))) {
|
|
90
|
+
if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
|
|
91
|
+
titleEl = t;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Kept as the stable selector for the usual place; a <title> elsewhere gets
|
|
96
|
+
// the selector the engine builds for the node.
|
|
97
|
+
const titleSelector =
|
|
98
|
+
titleEl && titleEl.parentElement && titleEl.parentElement.localName === 'head'
|
|
99
|
+
? 'head > title'
|
|
100
|
+
: undefined;
|
|
82
101
|
const rawTitle = document.title || '';
|
|
83
102
|
const titleText = rawTitle.replace(/\s+/g, ' ').trim();
|
|
84
103
|
const titleLc = titleText.toLowerCase();
|
|
@@ -176,7 +195,7 @@ function runInPage(ctx) {
|
|
|
176
195
|
};
|
|
177
196
|
|
|
178
197
|
const occBase = {
|
|
179
|
-
selector:
|
|
198
|
+
selector: titleSelector,
|
|
180
199
|
html: '',
|
|
181
200
|
summary:
|
|
182
201
|
'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
@@ -244,18 +263,68 @@ function runInPage(ctx) {
|
|
|
244
263
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
245
264
|
}
|
|
246
265
|
|
|
247
|
-
const GENERIC_TITLES =
|
|
266
|
+
const GENERIC_TITLES = {
|
|
267
|
+
en: new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']),
|
|
268
|
+
de: new Set(['startseite', 'willkommen', 'unbenannt', 'ohne titel', 'seite', 'dokument']),
|
|
269
|
+
es: new Set([
|
|
270
|
+
'inicio',
|
|
271
|
+
'página de inicio',
|
|
272
|
+
'bienvenido',
|
|
273
|
+
'bienvenida',
|
|
274
|
+
'sin título',
|
|
275
|
+
'página',
|
|
276
|
+
'documento'
|
|
277
|
+
]),
|
|
278
|
+
fr: new Set(['accueil', "page d'accueil", 'bienvenue', 'sans titre', 'page', 'document']),
|
|
279
|
+
ja: new Set(['ホーム', 'トップページ', 'トップ', 'ようこそ', '無題', 'ページ', 'ドキュメント'])
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// The page-specific half of "Brand | Home" in languages other than
|
|
283
|
+
// English. English keeps its word-boundary patterns below.
|
|
284
|
+
const TEMPLATE_TOKENS = {
|
|
285
|
+
de: ['startseite', 'willkommen'],
|
|
286
|
+
es: ['inicio', 'página de inicio', 'bienvenido', 'bienvenida'],
|
|
287
|
+
fr: ['accueil', "page d'accueil", 'bienvenue'],
|
|
288
|
+
ja: ['ホーム', 'トップページ', 'トップ', 'ようこそ']
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const htmlEl = document.documentElement;
|
|
292
|
+
const pageLang =
|
|
293
|
+
htmlEl && htmlEl.getAttribute && htmlEl.getAttribute('lang')
|
|
294
|
+
? htmlEl.getAttribute('lang').trim().split('-')[0].toLowerCase()
|
|
295
|
+
: '';
|
|
296
|
+
const titleNorm = titleLc.normalize('NFKC').replace(/[\u2018\u2019]/g, "'");
|
|
297
|
+
|
|
298
|
+
// Chinese, Japanese and Korean characters each carry roughly a word, so
|
|
299
|
+
// they count double: 「お問い合わせ」 is a full title in six characters.
|
|
300
|
+
function effectiveLength(s) {
|
|
301
|
+
let n = 0;
|
|
302
|
+
for (const ch of s)
|
|
303
|
+
n += /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\uf900-\ufaff]/.test(ch) ? 2 : 1;
|
|
304
|
+
return n;
|
|
305
|
+
}
|
|
248
306
|
|
|
249
307
|
// Conservative signals:
|
|
250
308
|
// - very short title (likely non-descriptive)
|
|
251
309
|
// - title is one of a small set of generic titles
|
|
252
|
-
const isVeryShort = titleText.length > 0 && titleText
|
|
253
|
-
const isGeneric =
|
|
310
|
+
const isVeryShort = titleText.length > 0 && effectiveLength(titleText) < 8;
|
|
311
|
+
const isGeneric =
|
|
312
|
+
GENERIC_TITLES.en.has(titleNorm) ||
|
|
313
|
+
!!(pageLang && GENERIC_TITLES[pageLang] && GENERIC_TITLES[pageLang].has(titleNorm));
|
|
314
|
+
|
|
315
|
+
function isLocalTemplate(title) {
|
|
316
|
+
const tokens = TEMPLATE_TOKENS[pageLang];
|
|
317
|
+
if (!tokens) return false;
|
|
318
|
+
const parts = title.split(/\s*(?:\||-|—|:)\s*/).filter(Boolean);
|
|
319
|
+
if (parts.length < 2) return false;
|
|
320
|
+
return tokens.includes(parts[0]) || tokens.includes(parts[parts.length - 1]);
|
|
321
|
+
}
|
|
254
322
|
|
|
255
323
|
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
256
324
|
const templateLike =
|
|
257
325
|
/\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
|
|
258
|
-
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText)
|
|
326
|
+
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText) ||
|
|
327
|
+
isLocalTemplate(titleNorm);
|
|
259
328
|
|
|
260
329
|
if (isGeneric || isVeryShort || templateLike) {
|
|
261
330
|
const reasonCode = isGeneric
|
|
@@ -271,7 +340,7 @@ function runInPage(ctx) {
|
|
|
271
340
|
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
272
341
|
: 'pageTitlePatterns_summary_cantTell_templateLike';
|
|
273
342
|
const occBase = {
|
|
274
|
-
selector:
|
|
343
|
+
selector: titleSelector,
|
|
275
344
|
html: '',
|
|
276
345
|
summary:
|
|
277
346
|
'The page title may not be descriptive enough to identify the page topic or purpose.',
|
|
@@ -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',
|