@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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Links must have an accessible name
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
|
-
* @sc 4.1.2
|
|
10
|
+
* @sc 2.4.4, 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <a href>, <area href> and elements with role="link" that are
|
|
13
13
|
* included in the accessibility tree. An <a> without an href is not a link
|
|
@@ -33,9 +33,18 @@ const meta = {
|
|
|
33
33
|
descriptionKey: 'linkNamePresent_description'
|
|
34
34
|
},
|
|
35
35
|
helpUrl: null,
|
|
36
|
-
tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
|
|
37
|
-
wcagSc: ['4.1.2'],
|
|
36
|
+
tags: ['wcag2a', 'wcag244', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
|
|
37
|
+
wcagSc: ['2.4.4', '4.1.2'],
|
|
38
|
+
// 2.4.4 as well as 4.1.2, as ACT rule c487ae maps it: a link with no name
|
|
39
|
+
// has no purpose to determine, in context or otherwise.
|
|
38
40
|
normativeMappings: [
|
|
41
|
+
{
|
|
42
|
+
standard: 'WCAG',
|
|
43
|
+
version: '2.2',
|
|
44
|
+
requirement: '2.4.4',
|
|
45
|
+
title: 'Link Purpose (In Context)',
|
|
46
|
+
conformanceLevel: 'A'
|
|
47
|
+
},
|
|
39
48
|
{
|
|
40
49
|
standard: 'WCAG',
|
|
41
50
|
version: '2.2',
|
|
@@ -48,8 +57,7 @@ const meta = {
|
|
|
48
57
|
category: 'robust',
|
|
49
58
|
type: 'automatic',
|
|
50
59
|
defaultConfidence: 'high',
|
|
51
|
-
|
|
52
|
-
coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
|
|
60
|
+
coverage: { facetsBySc: { '2.4.4': ['link-name-present'], '4.1.2': ['link-name-present'] } }
|
|
53
61
|
};
|
|
54
62
|
|
|
55
63
|
function runInPage(ctx) {
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <ul>/<ol> elements that have at least one direct element
|
|
13
|
-
* child
|
|
13
|
+
* child and whose role is list: no role attribute, role="list", or a role
|
|
14
|
+
* attribute naming no concrete ARIA role. A <ul>/<ol> given another role
|
|
15
|
+
* (listbox, menubar, tablist, none, ...) is not a list, so its children
|
|
16
|
+
* follow that role's rules instead.
|
|
14
17
|
* @expectation
|
|
15
18
|
* Every direct element child is <li>, <script>, or <template>. UNLESS it
|
|
16
19
|
* has an explicit `role` attribute, in which case the explicit role wins
|
|
@@ -96,9 +99,23 @@ function runInPage(ctx) {
|
|
|
96
99
|
const occurrences = [];
|
|
97
100
|
let applicableCount = 0;
|
|
98
101
|
|
|
102
|
+
// The first role token that names a concrete ARIA role, or '' when none
|
|
103
|
+
// does (the element keeps its native list role).
|
|
104
|
+
const aria = helpers && helpers.aria;
|
|
105
|
+
function resolvedExplicitRole(el) {
|
|
106
|
+
const tokens = String((el.getAttribute && el.getAttribute('role')) || '')
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.split(/\s+/)
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
if (!aria || typeof aria.isValidConcreteRole !== 'function') return tokens[0] || '';
|
|
111
|
+
return tokens.find((t) => aria.isValidConcreteRole(t)) || '';
|
|
112
|
+
}
|
|
113
|
+
|
|
99
114
|
for (const el of nodes) {
|
|
100
115
|
if (!el || !el.children) continue;
|
|
101
116
|
if (!el.children.length) continue;
|
|
117
|
+
const listRole = resolvedExplicitRole(el);
|
|
118
|
+
if (listRole && listRole !== 'list') continue;
|
|
102
119
|
|
|
103
120
|
applicableCount += 1;
|
|
104
121
|
|
|
@@ -149,21 +149,6 @@ function runInPage(ctx) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function buildLabelForMap(doc) {
|
|
153
|
-
const map = new Map(); // id -> label element (first)
|
|
154
|
-
try {
|
|
155
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
156
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
157
|
-
const lab = labels[i];
|
|
158
|
-
if (!lab || !lab.getAttribute) continue;
|
|
159
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
160
|
-
if (!f) continue;
|
|
161
|
-
if (!map.has(f)) map.set(f, lab);
|
|
162
|
-
}
|
|
163
|
-
} catch {}
|
|
164
|
-
return map;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
152
|
const occurrences = [];
|
|
168
153
|
let applicableCount = 0;
|
|
169
154
|
|
|
@@ -172,42 +157,27 @@ function runInPage(ctx) {
|
|
|
172
157
|
? helpers.queryAllSmart(selector)
|
|
173
158
|
: helpers.queryAll(selector);
|
|
174
159
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
160
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
161
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
162
|
+
// only ever associates with a genuinely labelable element, so this
|
|
163
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
164
|
+
// next to, and the real thing for a labelable element like
|
|
165
|
+
// <select multiple role="listbox">.
|
|
179
166
|
function getNativeLabelText(el) {
|
|
167
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
168
|
+
let labels;
|
|
180
169
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {}
|
|
193
|
-
try {
|
|
194
|
-
if (el.closest) {
|
|
195
|
-
const wrap = el.closest('label');
|
|
196
|
-
if (wrap) {
|
|
197
|
-
const t = getLabelText(wrap);
|
|
198
|
-
if (t) return t;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
try {
|
|
203
|
-
const idAttr = getAttr(el, 'id');
|
|
204
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
205
|
-
const lab = labelForMap.get(idAttr);
|
|
206
|
-
const t = lab ? getLabelText(lab) : '';
|
|
207
|
-
if (t) return t;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
return '';
|
|
170
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
171
|
+
} catch {
|
|
172
|
+
labels = [];
|
|
173
|
+
}
|
|
174
|
+
const parts = [];
|
|
175
|
+
const max = Math.min(4, labels.length);
|
|
176
|
+
for (let i = 0; i < max; i += 1) {
|
|
177
|
+
const t = getLabelText(labels[i]);
|
|
178
|
+
if (t) parts.push(t);
|
|
179
|
+
}
|
|
180
|
+
return normalizeWs(parts.join(' '));
|
|
211
181
|
}
|
|
212
182
|
|
|
213
183
|
function hasName(el) {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <li> elements that have a parent element.
|
|
13
13
|
* @expectation
|
|
14
|
-
* The parent is <ul>/<ol> with no role override
|
|
14
|
+
* The parent is <ul>/<ol>/<menu> with no role override (all three have
|
|
15
|
+
* the implicit role list), or an element with an
|
|
15
16
|
* explicit role of "list", "presentation", or "none". An <li> used
|
|
16
17
|
* outside a real list container (e.g. as a generic flex/grid item under
|
|
17
18
|
* a <div>) is not exposed as a list item to assistive technologies.
|
|
@@ -47,7 +48,7 @@ const id = 'listitem-parent-valid';
|
|
|
47
48
|
const meta = {
|
|
48
49
|
title: 'List items must be inside a list container',
|
|
49
50
|
description:
|
|
50
|
-
'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
|
|
51
|
+
'Checks that <li> elements are contained by <ul>, <ol>, <menu>, or an element with role="list".',
|
|
51
52
|
i18n: {
|
|
52
53
|
titleKey: 'listitemParentValid_title',
|
|
53
54
|
descriptionKey: 'listitemParentValid_description'
|
|
@@ -111,7 +112,7 @@ function runInPage(ctx) {
|
|
|
111
112
|
// direction, see the header comment.
|
|
112
113
|
valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
|
|
113
114
|
} else {
|
|
114
|
-
valid = parentTag === 'ul' || parentTag === 'ol';
|
|
115
|
+
valid = parentTag === 'ul' || parentTag === 'ol' || parentTag === 'menu';
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
if (valid) continue;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check meta-refresh-no-exceptions
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary At the AAA level, a meta refresh must not
|
|
8
|
+
* @summary At the AAA level, a meta refresh must not use a positive delay, however long
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.2.4, 3.2.5
|
|
11
11
|
* @applicability
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
const id = 'meta-refresh-no-exceptions';
|
|
38
38
|
|
|
39
39
|
const meta = {
|
|
40
|
-
title: 'Page must not use a meta refresh
|
|
40
|
+
title: 'Page must not use a timed meta refresh (AAA)',
|
|
41
41
|
description:
|
|
42
|
-
'Checks that <meta http-equiv="refresh">
|
|
42
|
+
'Checks that <meta http-equiv="refresh"> does not set a positive delay, however long; an immediate redirect (delay 0) passes. This is the stricter AAA-level counterpart of the A-level check, which exempts delays over 20 hours.',
|
|
43
43
|
i18n: {
|
|
44
44
|
titleKey: 'metaRefreshNoExceptions_title',
|
|
45
45
|
descriptionKey: 'metaRefreshNoExceptions_description'
|
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
* The document has a <title> element, and document.title with whitespace
|
|
17
17
|
* collapsed is non-empty. The element is looked for anywhere in the
|
|
18
18
|
* document, not only inside <head>: a <title> the parser leaves outside
|
|
19
|
-
* <head> is still the document title in every browser.
|
|
20
|
-
*
|
|
19
|
+
* <head> is still the document title in every browser. Only an
|
|
20
|
+
* HTML-namespace <title> counts; the <title> of an inline <svg> names the
|
|
21
|
+
* graphic, so a page whose only <title> is inside an <svg> is missing its
|
|
22
|
+
* title element. Whether that title describes the page is
|
|
23
|
+
* page-title-patterns' question.
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
const id = 'page-title-present';
|
|
@@ -66,8 +69,17 @@ function runInPage(ctx) {
|
|
|
66
69
|
// after <head> has closed is not re-parented into <head>, yet the browser
|
|
67
70
|
// (and document.title, used below) still recognizes it as the document's
|
|
68
71
|
// title, matching that here avoids a false "missing title" report for a
|
|
69
|
-
// <title> that legitimately ended up outside <head>.
|
|
70
|
-
|
|
72
|
+
// <title> that legitimately ended up outside <head>. Only an HTML-namespace
|
|
73
|
+
// <title> counts: an inline <svg><title> names the graphic, not the page,
|
|
74
|
+
// and document.title ignores it.
|
|
75
|
+
const HTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
76
|
+
let titleEl = null;
|
|
77
|
+
for (const t of Array.from(document.getElementsByTagName('title'))) {
|
|
78
|
+
if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
|
|
79
|
+
titleEl = t;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
71
83
|
const titleText = (document.title || '').replace(/\s+/g, ' ').trim();
|
|
72
84
|
|
|
73
85
|
const missingTitleEl = !titleEl;
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* aria-labelledby that resolves to non-empty text, or from title.
|
|
19
19
|
* role="progressbar" is name-from-author-only, so subtree text is never
|
|
20
20
|
* accepted: text sitting inside a custom progressbar widget is not
|
|
21
|
-
* reliably exposed as its name.
|
|
21
|
+
* reliably exposed as its name. On a labelable element
|
|
22
|
+
* (<progress role="progressbar">) an associated <label> counts as well.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
const id = 'progressbar-name-present';
|
|
@@ -124,6 +125,15 @@ function runInPage(ctx) {
|
|
|
124
125
|
const title = getAttr(el, 'title');
|
|
125
126
|
if (title) return { ok: true, method: 'title' };
|
|
126
127
|
|
|
128
|
+
// An associated <label> on a labelable host
|
|
129
|
+
// (<progress role="progressbar">, <meter role="progressbar">).
|
|
130
|
+
if (helpers.getNativeHostNameInfo) {
|
|
131
|
+
try {
|
|
132
|
+
const host = helpers.getNativeHostNameInfo(el, ctx);
|
|
133
|
+
if (host && host.present) return { ok: true, method: host.mechanism };
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
// role="progressbar" is name-from-author-only per WAI-ARIA: aria-label,
|
|
128
138
|
// aria-labelledby, or title, no content-based naming method at all. It
|
|
129
139
|
// must NOT fall back to subtree content: e.g. a <ul role="progressbar">
|
package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js}
RENAMED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* aria-hidden-focus and duplicate-id-aria own those separately). Per ACT
|
|
18
18
|
* 7d6734, this reaches any element carrying one of these roles, not only
|
|
19
19
|
* the <svg> root, e.g. a <circle role="graphics-symbol"> nested inside a
|
|
20
|
-
* plain <svg>.
|
|
21
|
-
* own (already ACT-clean) title/aria-name
|
|
22
|
-
*
|
|
20
|
+
* plain <svg>. Every <svg> element itself is left to
|
|
21
|
+
* svg-text-alternative-present's own (already ACT-clean) title/aria-name
|
|
22
|
+
* handling, so an unnamed <svg role="img"> is reported once, there; the
|
|
23
|
+
* <img> tag is excluded here since it has its own dedicated rule.
|
|
23
24
|
* @expectation
|
|
24
25
|
* Each applicable element has an accessible text alternative:
|
|
25
26
|
* - aria-label with a non-empty value; OR
|
|
@@ -95,8 +96,11 @@ function runInPage(ctx) {
|
|
|
95
96
|
};
|
|
96
97
|
|
|
97
98
|
const imgElements = (() => {
|
|
98
|
-
//
|
|
99
|
-
|
|
99
|
+
// <img> and <svg> are left out: each has its own rule
|
|
100
|
+
// (img-alt-present, svg-text-alternative-present), and counting an
|
|
101
|
+
// unnamed <svg role="img"> here too would report it twice.
|
|
102
|
+
const sel =
|
|
103
|
+
'[role="img" i]:not(img):not(svg), [role="graphics-symbol" i]:not(svg), [role="graphics-document" i]:not(svg)';
|
|
100
104
|
try {
|
|
101
105
|
return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
|
|
102
106
|
} catch {
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* accepted: text sitting inside a custom searchbox widget is not reliably
|
|
21
21
|
* exposed as its name. On a labelable element (<input role="searchbox">)
|
|
22
22
|
* an associated <label> counts as well.
|
|
23
|
+
* On a text-like <input> or a <textarea>, the placeholder counts last
|
|
24
|
+
* (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
|
|
25
|
+
* question, not a missing name.
|
|
23
26
|
*/
|
|
24
27
|
|
|
25
28
|
const id = 'searchbox-name-present';
|
|
@@ -149,21 +152,6 @@ function runInPage(ctx) {
|
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
function buildLabelForMap(doc) {
|
|
153
|
-
const map = new Map(); // id -> label element (first)
|
|
154
|
-
try {
|
|
155
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
156
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
157
|
-
const lab = labels[i];
|
|
158
|
-
if (!lab || !lab.getAttribute) continue;
|
|
159
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
160
|
-
if (!f) continue;
|
|
161
|
-
if (!map.has(f)) map.set(f, lab);
|
|
162
|
-
}
|
|
163
|
-
} catch {}
|
|
164
|
-
return map;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
155
|
const occurrences = [];
|
|
168
156
|
let applicableCount = 0;
|
|
169
157
|
|
|
@@ -172,42 +160,27 @@ function runInPage(ctx) {
|
|
|
172
160
|
? helpers.queryAllSmart(selector)
|
|
173
161
|
: helpers.queryAll(selector);
|
|
174
162
|
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
163
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
164
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
165
|
+
// only ever associates with a genuinely labelable element, so this
|
|
166
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
167
|
+
// next to, and the real thing for a labelable element like
|
|
168
|
+
// <input role="searchbox">.
|
|
179
169
|
function getNativeLabelText(el) {
|
|
170
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
171
|
+
let labels;
|
|
180
172
|
try {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
} catch {}
|
|
193
|
-
try {
|
|
194
|
-
if (el.closest) {
|
|
195
|
-
const wrap = el.closest('label');
|
|
196
|
-
if (wrap) {
|
|
197
|
-
const t = getLabelText(wrap);
|
|
198
|
-
if (t) return t;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {}
|
|
202
|
-
try {
|
|
203
|
-
const idAttr = getAttr(el, 'id');
|
|
204
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
205
|
-
const lab = labelForMap.get(idAttr);
|
|
206
|
-
const t = lab ? getLabelText(lab) : '';
|
|
207
|
-
if (t) return t;
|
|
208
|
-
}
|
|
209
|
-
} catch {}
|
|
210
|
-
return '';
|
|
173
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
174
|
+
} catch {
|
|
175
|
+
labels = [];
|
|
176
|
+
}
|
|
177
|
+
const parts = [];
|
|
178
|
+
const max = Math.min(4, labels.length);
|
|
179
|
+
for (let i = 0; i < max; i += 1) {
|
|
180
|
+
const t = getLabelText(labels[i]);
|
|
181
|
+
if (t) parts.push(t);
|
|
182
|
+
}
|
|
183
|
+
return normalizeWs(parts.join(' '));
|
|
211
184
|
}
|
|
212
185
|
|
|
213
186
|
function hasName(el) {
|
|
@@ -224,6 +197,16 @@ function runInPage(ctx) {
|
|
|
224
197
|
const lab = getNativeLabelText(el);
|
|
225
198
|
if (lab) return { ok: true, method: 'label' };
|
|
226
199
|
|
|
200
|
+
// HTML-AAM's last name source for a text-like <input> or a <textarea>,
|
|
201
|
+
// after title: the placeholder. A placeholder-only label is a 3.3.2
|
|
202
|
+
// question, not a missing name.
|
|
203
|
+
if (helpers.getNativeHostNameInfo) {
|
|
204
|
+
try {
|
|
205
|
+
const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
|
|
206
|
+
if (host && host.present) return { ok: true, method: host.mechanism };
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
227
210
|
// role="searchbox" is name-from-author-only per WAI-ARIA: it must NOT
|
|
228
211
|
// fall back to subtree content.
|
|
229
212
|
return { ok: false, method: 'none' };
|
|
@@ -5,29 +5,34 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check server-side-image-map-absent
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary A server-side image map (ismap) needs a keyboard-operable alternative
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <img>
|
|
12
|
+
* Applies to any scan scope. An <img ismap> inside an <a href> is a
|
|
13
|
+
* server-side image map: the browser sends the click coordinates to the
|
|
14
|
+
* link's URL, which has no keyboard-operable equivalent and exposes no
|
|
15
|
+
* individual regions to assistive technology.
|
|
13
16
|
* @expectation
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* are
|
|
17
|
+
* Each server-side image map is reported as cantTell: 2.1.1 is met when
|
|
18
|
+
* the same destinations are also offered as links a keyboard can reach,
|
|
19
|
+
* which the rule cannot verify.
|
|
20
|
+
* Client-side image maps (<map>/<area>) are not flagged.
|
|
21
|
+
* A scope with no <img ismap> passes. One whose only <img ismap> elements
|
|
22
|
+
* are outside a link is notApplicable: ismap does nothing there, so there
|
|
23
|
+
* is no server-side image map (the misplaced attribute is invalid HTML,
|
|
24
|
+
* a matter for the validator).
|
|
20
25
|
* @implementation-notes
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
26
|
+
* - There is no automatable way to verify that a usable alternative exists
|
|
27
|
+
* elsewhere on the page, so the rule never fails.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
30
|
const id = 'server-side-image-map-absent';
|
|
26
31
|
|
|
27
32
|
const meta = {
|
|
28
|
-
title: '
|
|
33
|
+
title: 'Server-side image maps must have a keyboard-operable alternative',
|
|
29
34
|
description:
|
|
30
|
-
'
|
|
35
|
+
'Asks, for each <img ismap> inside a link, whether the page offers the same destinations as links a keyboard can reach, since a server-side image map has no keyboard-operable regions of its own.',
|
|
31
36
|
i18n: {
|
|
32
37
|
titleKey: 'serverSideImageMapAbsent_title',
|
|
33
38
|
descriptionKey: 'serverSideImageMapAbsent_description'
|
|
@@ -59,23 +64,36 @@ function runInPage(ctx) {
|
|
|
59
64
|
: helpers.queryAll('img[ismap]');
|
|
60
65
|
|
|
61
66
|
const occurrences = [];
|
|
62
|
-
let applicableCount = 0;
|
|
63
67
|
|
|
64
68
|
for (const el of nodes) {
|
|
65
69
|
if (!el) continue;
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
// ismap does something only on an image inside a hyperlink.
|
|
72
|
+
let link;
|
|
73
|
+
try {
|
|
74
|
+
link = el.closest ? el.closest('a[href]') : null;
|
|
75
|
+
} catch {
|
|
76
|
+
link = null;
|
|
77
|
+
}
|
|
78
|
+
if (!link) continue;
|
|
68
79
|
|
|
69
80
|
occurrences.push(
|
|
70
81
|
helpers.reportOccurrence(el, {
|
|
71
82
|
summary:
|
|
72
|
-
'This image
|
|
73
|
-
hint: '
|
|
83
|
+
'This image is a server-side image map (ismap inside a link), whose regions cannot be reached from the keyboard.',
|
|
84
|
+
hint: 'Check that the page offers the same destinations as separate links. Better: replace the server-side image map with a client-side image map (<map>/<area>) or separate links/buttons.',
|
|
85
|
+
occurrenceOutcome: 'cantTell',
|
|
74
86
|
i18n: {
|
|
75
|
-
summaryKey: '
|
|
76
|
-
hintKey: '
|
|
87
|
+
summaryKey: 'serverSideImageMapAbsent_summary_cantTell',
|
|
88
|
+
hintKey: 'serverSideImageMapAbsent_hint_cantTell',
|
|
77
89
|
params: {}
|
|
78
90
|
},
|
|
91
|
+
uncertainty: {
|
|
92
|
+
code: 'equivalence-unknown',
|
|
93
|
+
needed:
|
|
94
|
+
'Whether the destinations of this image map are also offered as keyboard-operable links.',
|
|
95
|
+
evidence: { href: link.getAttribute('href') }
|
|
96
|
+
},
|
|
79
97
|
data: {
|
|
80
98
|
details: { reasonCode: 'SERVER_SIDE_IMAGE_MAP' }
|
|
81
99
|
}
|
|
@@ -83,18 +101,20 @@ function runInPage(ctx) {
|
|
|
83
101
|
);
|
|
84
102
|
}
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
// No <img ismap> at all is the passing case (see @expectation above);
|
|
105
|
+
// ismap only outside links means there is no server-side image map.
|
|
106
|
+
if (!nodes.length) {
|
|
107
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
88
108
|
}
|
|
89
|
-
if (occurrences.length) {
|
|
90
|
-
return {
|
|
91
|
-
ruleId: rule.ruleId,
|
|
92
|
-
outcome: 'fail',
|
|
93
|
-
severity: rule.defaultSeverity || 'serious',
|
|
94
|
-
occurrences
|
|
95
|
-
};
|
|
109
|
+
if (!occurrences.length) {
|
|
110
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
96
111
|
}
|
|
97
|
-
return {
|
|
112
|
+
return {
|
|
113
|
+
ruleId: rule.ruleId,
|
|
114
|
+
outcome: 'cantTell',
|
|
115
|
+
severity: rule.defaultSeverity || 'serious',
|
|
116
|
+
occurrences
|
|
117
|
+
};
|
|
98
118
|
}
|
|
99
119
|
|
|
100
120
|
module.exports = { id, meta, runInPage };
|
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
* accepted: text sitting inside a custom slider widget is not reliably
|
|
22
22
|
* exposed as its name. On a labelable element (<input role="slider">) an
|
|
23
23
|
* associated <label> counts as well.
|
|
24
|
+
* On a text-like <input> or a <textarea>, the placeholder counts last
|
|
25
|
+
* (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
|
|
26
|
+
* question, not a missing name.
|
|
24
27
|
*/
|
|
25
28
|
|
|
26
29
|
const id = 'slider-name-present';
|
|
@@ -72,21 +75,6 @@ function runInPage(ctx) {
|
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
function buildLabelForMap(doc) {
|
|
76
|
-
const map = new Map(); // id -> label element (first)
|
|
77
|
-
try {
|
|
78
|
-
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
79
|
-
for (let i = 0; i < labels.length; i += 1) {
|
|
80
|
-
const lab = labels[i];
|
|
81
|
-
if (!lab || !lab.getAttribute) continue;
|
|
82
|
-
const f = normalizeWs(lab.getAttribute('for'));
|
|
83
|
-
if (!f) continue;
|
|
84
|
-
if (!map.has(f)) map.set(f, lab);
|
|
85
|
-
}
|
|
86
|
-
} catch {}
|
|
87
|
-
return map;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
78
|
function getConservativeSubtreeText(document, container) {
|
|
91
79
|
// "Name from content", recurses into descendants and uses each one's
|
|
92
80
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
@@ -176,41 +164,27 @@ function runInPage(ctx) {
|
|
|
176
164
|
? helpers.queryAllSmart(selector)
|
|
177
165
|
: helpers.queryAll(selector);
|
|
178
166
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
167
|
+
// Delegates to the shared, spec-guarded lookup (dom-helpers.js's
|
|
168
|
+
// getAssociatedLabelElements): a <label> -- wrapping or via `for` --
|
|
169
|
+
// only ever associates with a genuinely labelable element, so this
|
|
170
|
+
// correctly returns nothing for role-only widgets a <label> merely sits
|
|
171
|
+
// next to, and the real thing for a labelable element like
|
|
172
|
+
// <input type="range">.
|
|
182
173
|
function getNativeLabelText(el) {
|
|
174
|
+
if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
|
|
175
|
+
let labels;
|
|
183
176
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
} catch {}
|
|
196
|
-
try {
|
|
197
|
-
if (el.closest) {
|
|
198
|
-
const wrap = el.closest('label');
|
|
199
|
-
if (wrap) {
|
|
200
|
-
const t = getLabelText(wrap);
|
|
201
|
-
if (t) return t;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
} catch {}
|
|
205
|
-
try {
|
|
206
|
-
const idAttr = getAttr(el, 'id');
|
|
207
|
-
if (idAttr && labelForMap.has(idAttr)) {
|
|
208
|
-
const lab = labelForMap.get(idAttr);
|
|
209
|
-
const t = lab ? getLabelText(lab) : '';
|
|
210
|
-
if (t) return t;
|
|
211
|
-
}
|
|
212
|
-
} catch {}
|
|
213
|
-
return '';
|
|
177
|
+
labels = helpers.getAssociatedLabelElements(el) || [];
|
|
178
|
+
} catch {
|
|
179
|
+
labels = [];
|
|
180
|
+
}
|
|
181
|
+
const parts = [];
|
|
182
|
+
const max = Math.min(4, labels.length);
|
|
183
|
+
for (let i = 0; i < max; i += 1) {
|
|
184
|
+
const t = getLabelText(labels[i]);
|
|
185
|
+
if (t) parts.push(t);
|
|
186
|
+
}
|
|
187
|
+
return normalizeWs(parts.join(' '));
|
|
214
188
|
}
|
|
215
189
|
|
|
216
190
|
function evaluate(el, kind) {
|
|
@@ -223,10 +197,22 @@ function runInPage(ctx) {
|
|
|
223
197
|
const title = getAttr(el, 'title');
|
|
224
198
|
if (title) return { ok: true, method: 'title' };
|
|
225
199
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
200
|
+
// An associated <label> names any labelable host, whether it is a
|
|
201
|
+
// native <input type="range"> or carries role="slider"
|
|
202
|
+
// (<input type="number" role="slider">); getAssociatedLabelElements
|
|
203
|
+
// returns nothing for a non-labelable one.
|
|
204
|
+
const lab = getNativeLabelText(el);
|
|
205
|
+
if (lab) return { ok: true, method: 'label' };
|
|
206
|
+
if (kind === 'native-slider') return { ok: false, method: 'none' };
|
|
207
|
+
|
|
208
|
+
// HTML-AAM's last name source for a text-like <input> or a <textarea>,
|
|
209
|
+
// after title: the placeholder. A placeholder-only label is a 3.3.2
|
|
210
|
+
// question, not a missing name.
|
|
211
|
+
if (helpers.getNativeHostNameInfo) {
|
|
212
|
+
try {
|
|
213
|
+
const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
|
|
214
|
+
if (host && host.present) return { ok: true, method: host.mechanism };
|
|
215
|
+
} catch {}
|
|
230
216
|
}
|
|
231
217
|
|
|
232
218
|
// role="slider" is name-from-author-only per WAI-ARIA (unlike e.g.
|