@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
|
@@ -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">
|
|
@@ -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.
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* accepted: text sitting inside a custom spinbutton widget is not reliably
|
|
21
21
|
* exposed as its name. On a labelable element (<input role="spinbutton">)
|
|
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 = 'spinbutton-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="spinbutton">.
|
|
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="spinbutton" 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' };
|
|
@@ -124,17 +124,6 @@ const meta = {
|
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
-
// ---- constants (deterministic, CSS pixels) ----
|
|
128
|
-
const MIN_SIZE = 24;
|
|
129
|
-
const MIN_RADIUS = 12; // spacing exception circle radius (24px diameter)
|
|
130
|
-
|
|
131
|
-
// Grid sampling configuration (kept small for perf; only used for undersized targets)
|
|
132
|
-
const GRID_POINTS = 25; // 5x5
|
|
133
|
-
const GRID_N = 5;
|
|
134
|
-
|
|
135
|
-
// Circle sampling configuration (perimeter)
|
|
136
|
-
const CIRCLE_SAMPLES = 16; // deterministic, fixed
|
|
137
|
-
|
|
138
127
|
function runInPage(ctx) {
|
|
139
128
|
'use strict';
|
|
140
129
|
|
|
@@ -12,15 +12,23 @@
|
|
|
12
12
|
* `<table>` elements with at least 4 rows and at least 4 columns
|
|
13
13
|
* (a "large" table, where implicit row/column header association is
|
|
14
14
|
* useful; small tables are usually self-evident), and with
|
|
15
|
-
* NO `colspan`/`rowspan` anywhere in the table.
|
|
15
|
+
* NO `colspan`/`rowspan` anywhere in the table. A table whose role (first
|
|
16
|
+
* token) is anything but table, grid or treegrid, such as a layout table
|
|
17
|
+
* with role="presentation", is left out: it has no data cells.
|
|
16
18
|
* @expectation
|
|
17
|
-
* Every `<td>` has an associated header, via one of:
|
|
19
|
+
* Every non-empty `<td>` has an associated header, via one of:
|
|
18
20
|
* - a non-empty `headers` attribute (trusted here; whether it
|
|
19
21
|
* resolves to real `<th>` ids is `table-headers-attr-valid`'s
|
|
20
22
|
* concern, not this rule's), OR
|
|
21
|
-
* - an implicit column header:
|
|
23
|
+
* - an implicit column header: a header cell in the same column, in an
|
|
22
24
|
* earlier row, OR
|
|
23
|
-
* - an implicit row header:
|
|
25
|
+
* - an implicit row header: a header cell earlier in the same row.
|
|
26
|
+
* A header cell is a `<th>` with no other role, or any cell with
|
|
27
|
+
* role="columnheader" or role="rowheader" (such a `<td>` is a header,
|
|
28
|
+
* not a data cell). A `<td>` with no text and no content that could carry
|
|
29
|
+
* a name (an image, a control, an element with an ARIA label) holds no
|
|
30
|
+
* data, so it needs no header; the empty corner cell above row headers is
|
|
31
|
+
* the usual case.
|
|
24
32
|
* @implementation-notes
|
|
25
33
|
* - Closes the gap `table-th-has-data-cells` deferred (see
|
|
26
34
|
* that rule's own implementation notes): this is the fuller positional
|
|
@@ -84,6 +92,25 @@ function runInPage(ctx) {
|
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
const TABLE_ROLES = ['table', 'grid', 'treegrid'];
|
|
96
|
+
|
|
97
|
+
function firstRole(el) {
|
|
98
|
+
return trim(el.getAttribute('role')).toLowerCase().split(/\s+/)[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Content that can carry a name or a value even without text.
|
|
102
|
+
const NAMED_CONTENT =
|
|
103
|
+
'img, svg, canvas, input, select, textarea, button, object, embed, video, audio, iframe, meter, progress, [role], [aria-label], [aria-labelledby], [title]';
|
|
104
|
+
|
|
105
|
+
function isEmptyCell(cell) {
|
|
106
|
+
if (trim(cell.textContent)) return false;
|
|
107
|
+
try {
|
|
108
|
+
return !cell.querySelector(NAMED_CONTENT);
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
|
|
88
115
|
|
|
89
116
|
const occurrences = [];
|
|
@@ -92,6 +119,9 @@ function runInPage(ctx) {
|
|
|
92
119
|
for (const table of tables) {
|
|
93
120
|
if (!table || !table.rows) continue;
|
|
94
121
|
|
|
122
|
+
const tableRole = firstRole(table);
|
|
123
|
+
if (tableRole && !TABLE_ROLES.includes(tableRole)) continue;
|
|
124
|
+
|
|
95
125
|
const rows = Array.from(table.rows);
|
|
96
126
|
if (rows.length < MIN_SIZE) continue;
|
|
97
127
|
|
|
@@ -115,7 +145,10 @@ function runInPage(ctx) {
|
|
|
115
145
|
// another cell's row/column header, even though it's still structurally
|
|
116
146
|
// a <th>.
|
|
117
147
|
function isHeaderCell(cell) {
|
|
118
|
-
|
|
148
|
+
if (!cell || !cell.tagName || !isEligible(cell)) return false;
|
|
149
|
+
const role = firstRole(cell);
|
|
150
|
+
if (role === 'columnheader' || role === 'rowheader') return true;
|
|
151
|
+
return cell.tagName.toLowerCase() === 'th' && !role;
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
// "Was there a <th> above this cell's column" and "was there a <th>
|
|
@@ -148,6 +181,9 @@ function runInPage(ctx) {
|
|
|
148
181
|
const headersAttr = trim(cell.getAttribute('headers'));
|
|
149
182
|
if (headersAttr) continue;
|
|
150
183
|
|
|
184
|
+
// An empty cell holds no data to associate with a header.
|
|
185
|
+
if (isEmptyCell(cell)) continue;
|
|
186
|
+
|
|
151
187
|
if (colHasHeaderAbove[c]) continue;
|
|
152
188
|
if (rowHasHeaderBefore) continue;
|
|
153
189
|
|