@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
|
@@ -5,33 +5,43 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check iframe-title-unique
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary
|
|
8
|
+
* @summary Deprecated since 1.8.0, reports notApplicable; see identical-iframes-same-purpose
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Nothing. The rule is deprecated and reports notApplicable on every
|
|
13
|
+
* page. Its id stays in the catalog, with meta.deprecated set and
|
|
14
|
+
* deprecation.replacedBy naming the successor, so a runOnly list, a
|
|
15
|
+
* stored baseline or an open Code Scanning alert that holds the id keeps
|
|
16
|
+
* resolving until the file is removed in 2.0.0 (docs/API_STABILITY.md,
|
|
17
|
+
* "Rule-ID deprecation policy").
|
|
14
18
|
* @expectation
|
|
15
|
-
*
|
|
16
|
-
* attribute
|
|
17
|
-
*
|
|
19
|
+
* None. The check this rule used to make, that no two frames share a
|
|
20
|
+
* title attribute, is not a WCAG 4.1.2 requirement: the criterion asks
|
|
21
|
+
* that a frame's name be programmatically determinable, not unique, and
|
|
22
|
+
* ACT rule 4b1c6c accepts identical names on frames that embed equivalent
|
|
23
|
+
* resources. identical-iframes-same-purpose asks the question 4b1c6c does
|
|
24
|
+
* ask, of the computed accessible name, which for a frame is the title
|
|
25
|
+
* attribute unless aria-label or aria-labelledby overrides it.
|
|
18
26
|
* @implementation-notes
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
+
* - Reduced to notApplicable rather than only marked deprecated because a
|
|
28
|
+
* deprecated rule keeps running and reporting normally, and leaving the
|
|
29
|
+
* old fail alive until 2.0.0 would have kept reporting a violation WCAG
|
|
30
|
+
* does not define. docs/DESIGN_CHALLENGES.md records the decision.
|
|
31
|
+
* - IFRAME_TITLE_DUPLICATE is no longer emitted. It retired with the
|
|
32
|
+
* finding it named in 1.8.0 (CHANGELOG.md), so a stored baseline entry or
|
|
33
|
+
* alert for it closes.
|
|
34
|
+
* - The facet this rule covered under SC 4.1.2 is retired; the coverage
|
|
35
|
+
* entry points at the successor's facet, so the catalog still shows where
|
|
36
|
+
* the question is answered.
|
|
27
37
|
*/
|
|
28
38
|
|
|
29
39
|
const id = 'iframe-title-unique';
|
|
30
40
|
|
|
31
41
|
const meta = {
|
|
32
|
-
title: 'Frame
|
|
42
|
+
title: 'Frame title uniqueness (deprecated)',
|
|
33
43
|
description:
|
|
34
|
-
'
|
|
44
|
+
'Deprecated since 1.8.0 and always notApplicable: whether frames sharing a name embed the same resource is checked by identical-iframes-same-purpose.',
|
|
35
45
|
i18n: {
|
|
36
46
|
titleKey: 'iframeTitleUnique_title',
|
|
37
47
|
descriptionKey: 'iframeTitleUnique_description'
|
|
@@ -52,74 +62,19 @@ const meta = {
|
|
|
52
62
|
category: 'robust',
|
|
53
63
|
type: 'automatic',
|
|
54
64
|
defaultConfidence: 'high',
|
|
55
|
-
|
|
65
|
+
deprecated: true,
|
|
66
|
+
deprecation: {
|
|
67
|
+
replacedBy: 'identical-iframes-same-purpose',
|
|
68
|
+
reason:
|
|
69
|
+
'A repeated title attribute is not a WCAG 4.1.2 violation, and identical-iframes-same-purpose already checks what ACT rule 4b1c6c asks: that frames sharing a name embed the same resource.',
|
|
70
|
+
sinceVersion: '1.8.0'
|
|
71
|
+
},
|
|
72
|
+
coverage: { facetsBySc: { '4.1.2': ['identical-iframes-same-purpose'] } }
|
|
56
73
|
};
|
|
57
74
|
|
|
58
75
|
function runInPage(ctx) {
|
|
59
|
-
const {
|
|
60
|
-
|
|
61
|
-
const nodes = helpers.queryAllSmart
|
|
62
|
-
? helpers.queryAllSmart('iframe, frame')
|
|
63
|
-
: helpers.queryAll('iframe, frame');
|
|
64
|
-
|
|
65
|
-
const groups = new Map(); // trimmed title -> elements[]
|
|
66
|
-
let applicableCount = 0;
|
|
67
|
-
|
|
68
|
-
for (const el of nodes) {
|
|
69
|
-
if (!el || !el.getAttribute) continue;
|
|
70
|
-
|
|
71
|
-
const title = String(el.getAttribute('title') || '').trim();
|
|
72
|
-
if (!title) continue;
|
|
73
|
-
|
|
74
|
-
applicableCount += 1;
|
|
75
|
-
|
|
76
|
-
const list = groups.get(title);
|
|
77
|
-
if (list) list.push(el);
|
|
78
|
-
else groups.set(title, [el]);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (applicableCount === 0) {
|
|
82
|
-
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const occurrences = [];
|
|
86
|
-
|
|
87
|
-
for (const [title, els] of groups) {
|
|
88
|
-
if (els.length < 2) continue;
|
|
89
|
-
|
|
90
|
-
for (const el of els) {
|
|
91
|
-
const tag = el.tagName.toLowerCase();
|
|
92
|
-
occurrences.push(
|
|
93
|
-
helpers.reportOccurrence(el, {
|
|
94
|
-
summary: 'This frame’s title is not unique among the frames on this page.',
|
|
95
|
-
hint: 'Give each frame a distinct title describing its specific content or purpose.',
|
|
96
|
-
i18n: {
|
|
97
|
-
summaryKey: 'iframeTitleUnique_summary_fail',
|
|
98
|
-
hintKey: 'iframeTitleUnique_hint_fail',
|
|
99
|
-
params: { element: tag, title }
|
|
100
|
-
},
|
|
101
|
-
data: {
|
|
102
|
-
details: {
|
|
103
|
-
reasonCode: 'IFRAME_TITLE_DUPLICATE',
|
|
104
|
-
element: tag,
|
|
105
|
-
title,
|
|
106
|
-
duplicateCount: els.length
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
})
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (occurrences.length) {
|
|
115
|
-
return {
|
|
116
|
-
ruleId: rule.ruleId,
|
|
117
|
-
outcome: 'fail',
|
|
118
|
-
severity: rule.defaultSeverity || 'moderate',
|
|
119
|
-
occurrences
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
76
|
+
const { rule } = ctx;
|
|
77
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
123
78
|
}
|
|
124
79
|
|
|
125
80
|
module.exports = { id, meta, runInPage };
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* @check
|
|
6
|
+
* @check input-image-alt-present
|
|
7
7
|
* @atomic true
|
|
8
8
|
* @summary Accessible <input type="image"> elements must have an alt attribute
|
|
9
9
|
* @standard WCAG 2.2
|
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <input type="image"> elements included in the accessibility tree.
|
|
13
13
|
* @expectation
|
|
14
|
-
* Each applicable <input type="image"> element has
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Each applicable <input type="image"> element has a non-empty accessible
|
|
15
|
+
* name from aria-labelledby, aria-label, alt or title. An empty name
|
|
16
|
+
* fails, whether alt is missing or empty (alt="").
|
|
17
|
+
* An author-supplied name that equals a browser default for an image
|
|
18
|
+
* button ("Submit", "Submit Query") is not an empty name: it may describe
|
|
19
|
+
* the button, so it is reported as cantTell for a person to judge.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
const id = 'input-image-alt-present';
|
|
@@ -99,6 +102,7 @@ function runInPage(ctx) {
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
const occurrences = [];
|
|
105
|
+
const cantTellOccurrences = [];
|
|
102
106
|
let applicableCount = 0;
|
|
103
107
|
|
|
104
108
|
for (const el of inputs) {
|
|
@@ -118,10 +122,12 @@ function runInPage(ctx) {
|
|
|
118
122
|
|
|
119
123
|
applicableCount += 1;
|
|
120
124
|
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
+
// An author-supplied name equal to the browser's own fallback name for an
|
|
126
|
+
// image button ("Submit Query" from HTML-AAM, "Submit" from Chrome) may
|
|
127
|
+
// be the browser default copied by hand, or may describe a submit button
|
|
128
|
+
// accurately. It is a name, so ACT 59796f passes it; whether it conveys
|
|
129
|
+
// the button's purpose is asked, not failed. Only the English defaults
|
|
130
|
+
// are recognised. With no author name at all the control fails below.
|
|
125
131
|
const effectiveName = (() => {
|
|
126
132
|
let v = '';
|
|
127
133
|
if (getAriaNameInfo) {
|
|
@@ -149,19 +155,25 @@ function runInPage(ctx) {
|
|
|
149
155
|
: null;
|
|
150
156
|
const defaultNameOccurrence = {
|
|
151
157
|
summary:
|
|
152
|
-
'
|
|
153
|
-
hint: '
|
|
158
|
+
'The accessible name matches the browser default for an image button; check that it describes what the button does.',
|
|
159
|
+
hint: 'If the name does not say what the button does, replace it with text that does, for example "Search".',
|
|
160
|
+
occurrenceOutcome: 'cantTell',
|
|
154
161
|
i18n: {
|
|
155
|
-
summaryKey: '
|
|
156
|
-
hintKey: '
|
|
162
|
+
summaryKey: 'inputImage_altPresent_summary_cantTell_defaultName',
|
|
163
|
+
hintKey: 'inputImage_altPresent_hint_cantTell_defaultName',
|
|
157
164
|
params: { element: 'input[type=image]' }
|
|
158
165
|
},
|
|
166
|
+
uncertainty: {
|
|
167
|
+
code: 'judgement-required',
|
|
168
|
+
needed: 'Whether this default-looking name describes what the button does.',
|
|
169
|
+
evidence: { name: effectiveName }
|
|
170
|
+
},
|
|
159
171
|
data: {
|
|
160
172
|
visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
173
|
details: { reasonCode: 'default_name' }
|
|
162
174
|
}
|
|
163
175
|
};
|
|
164
|
-
|
|
176
|
+
cantTellOccurrences.push(
|
|
165
177
|
helpers && typeof helpers.reportOccurrence === 'function'
|
|
166
178
|
? helpers.reportOccurrence(el, defaultNameOccurrence)
|
|
167
179
|
: { selector: '', html: '', ...defaultNameOccurrence }
|
|
@@ -220,16 +232,16 @@ function runInPage(ctx) {
|
|
|
220
232
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
221
233
|
}
|
|
222
234
|
|
|
223
|
-
if (!occurrences.length) {
|
|
235
|
+
if (!occurrences.length && !cantTellOccurrences.length) {
|
|
224
236
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
225
237
|
}
|
|
226
238
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
};
|
|
239
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
240
|
+
occurrences,
|
|
241
|
+
cantTellOccurrences,
|
|
242
|
+
rule.defaultSeverity || 'minor'
|
|
243
|
+
);
|
|
244
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
233
245
|
}
|
|
234
246
|
|
|
235
247
|
module.exports = { id, meta, runInPage };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.5.3
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to controls
|
|
12
|
+
* Applies to controls named by aria-label or aria-labelledby, are
|
|
13
13
|
* visually rendered, and have visible label text this engine can extract
|
|
14
14
|
* deterministically, from an associated <label>, from the control's own
|
|
15
15
|
* rendered text, or from the elements aria-labelledby points at. The
|
|
@@ -18,13 +18,20 @@
|
|
|
18
18
|
* menuitemcheckbox, menuitemradio, option, treeitem and gridcell roles,
|
|
19
19
|
* minus anything hidden or disabled. aria-hidden is not
|
|
20
20
|
* excluded: it changes nothing about what is rendered on screen, which is
|
|
21
|
-
* what this SC is about.
|
|
21
|
+
* what this SC is about. An aria-label that is empty once trimmed, or an
|
|
22
|
+
* aria-labelledby whose ids point at nothing or only at elements with no
|
|
23
|
+
* text, names nothing: the accessible name then comes from the next
|
|
24
|
+
* source (the content, a <label>, title...), as for a control without
|
|
25
|
+
* them, so the control is out of scope.
|
|
22
26
|
* @expectation
|
|
23
27
|
* The accessible name contains the visible label's words, adjacent and in
|
|
24
28
|
* order. The comparison is over words rather than characters:
|
|
25
|
-
* parenthesised text is dropped, case is folded, text is
|
|
26
|
-
* and every
|
|
27
|
-
*
|
|
29
|
+
* parenthesised text is dropped, case is folded, text is NFKC-normalised,
|
|
30
|
+
* and every character that is not a letter, digit or combining mark
|
|
31
|
+
* becomes a separator, so punctuation and spacing differences never
|
|
32
|
+
* decide the outcome. Accents are not folded: "Déposer" stays one word,
|
|
33
|
+
* and a name that drops an accent ("Deposer") does not contain it. Four
|
|
34
|
+
* shapes markup cannot
|
|
28
35
|
* settle are reported as cantTell instead of fail: a word hyphenated
|
|
29
36
|
* differently in the two places; a visible word the author may have
|
|
30
37
|
* abbreviated, marked by its trailing period; visible text rendered
|
|
@@ -86,20 +93,23 @@ function runInPage(ctx) {
|
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
// WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
|
|
89
|
-
// parenthesised text, case-fold and
|
|
90
|
-
//
|
|
96
|
+
// parenthesised text, case-fold and NFKC-normalise, then reduce every
|
|
97
|
+
// character that is not a letter, digit or combining mark to a space.
|
|
98
|
+
// NFKC folds compatibility forms (ligatures, full-width letters) but keeps
|
|
99
|
+
// accented letters whole; with NFKD, "déposer" split at its combining
|
|
100
|
+
// accent into "de" and "poser". `hyphensJoin` deletes hyphens instead of
|
|
91
101
|
// splitting on them, which distinguishes a real mismatch from one that is
|
|
92
102
|
// only a hyphenation difference.
|
|
93
103
|
function tokenize(s, hyphensJoin) {
|
|
94
104
|
let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
|
|
95
105
|
try {
|
|
96
|
-
v = v.normalize('
|
|
106
|
+
v = v.normalize('NFKC');
|
|
97
107
|
} catch {
|
|
98
108
|
// Realm without String#normalize: the word comparison below still holds.
|
|
99
109
|
}
|
|
100
110
|
if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
|
|
101
111
|
return v
|
|
102
|
-
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
112
|
+
.replace(/[^\p{L}\p{N}\p{M}]+/gu, ' ')
|
|
103
113
|
.split(' ')
|
|
104
114
|
.filter(Boolean);
|
|
105
115
|
}
|
|
@@ -129,7 +139,7 @@ function runInPage(ctx) {
|
|
|
129
139
|
function abbreviatedWords(s) {
|
|
130
140
|
const out = new Set();
|
|
131
141
|
for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
|
|
132
|
-
const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
|
|
142
|
+
const m = /^([\p{L}\p{N}\p{M}]+)\.$/u.exec(w);
|
|
133
143
|
if (m) out.add(m[1].toLowerCase());
|
|
134
144
|
}
|
|
135
145
|
return out;
|
|
@@ -231,7 +241,6 @@ function runInPage(ctx) {
|
|
|
231
241
|
|
|
232
242
|
const parts = [];
|
|
233
243
|
let n;
|
|
234
|
-
// eslint-disable-next-line no-cond-assign
|
|
235
244
|
while ((n = walker.nextNode())) {
|
|
236
245
|
try {
|
|
237
246
|
const raw = n && n.nodeValue != null ? String(n.nodeValue) : '';
|
|
@@ -254,55 +263,22 @@ function runInPage(ctx) {
|
|
|
254
263
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
// Real <label> elements associated with a native form control -- the
|
|
267
|
+
// shared dom-helpers.js implementation (a `for`-attribute index plus a
|
|
268
|
+
// bounded closest('label') walk), not the native `.labels`/`.control`
|
|
269
|
+
// pair: jsdom implements those as a whole-document walk on every access
|
|
270
|
+
// (see that function's own header comment for the full explanation),
|
|
271
|
+
// real cost this engine's Node/jsdom runtime pays, not just a browser
|
|
272
|
+
// realm this code happens to also run in.
|
|
257
273
|
function getAssociatedLabelElements(control) {
|
|
258
|
-
|
|
259
|
-
try {
|
|
260
|
-
if (control && control.labels && typeof control.labels.length === 'number') {
|
|
261
|
-
for (const l of control.labels) labels.push(l);
|
|
262
|
-
}
|
|
263
|
-
} catch {
|
|
264
|
-
// ignore
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Fallback: wrapped label
|
|
268
|
-
try {
|
|
269
|
-
const w = control && control.closest ? control.closest('label') : null;
|
|
270
|
-
if (w) labels.push(w);
|
|
271
|
-
} catch {
|
|
272
|
-
// ignore
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Fallback: label[for=id]. Uses `document` directly rather than
|
|
276
|
-
// ctx.root -- label[for] association is a document-wide relationship
|
|
277
|
-
// (IDs are document-unique), not bounded by whatever contextSelector
|
|
278
|
-
// region happens to be scanned, and ctx.root is an array (multi-region
|
|
279
|
-
// contextSelector support), not a single element with its own
|
|
280
|
-
// .querySelector to call directly.
|
|
281
|
-
try {
|
|
282
|
-
const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
|
|
283
|
-
const key = String(idAttribute || '').trim();
|
|
284
|
-
if (key && document && document.querySelector) {
|
|
285
|
-
const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
|
|
286
|
-
if (l) labels.push(l);
|
|
287
|
-
}
|
|
288
|
-
} catch {
|
|
289
|
-
// ignore
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// De-dupe in document order
|
|
293
|
-
const seen = new Set();
|
|
294
|
-
const out = [];
|
|
295
|
-
for (const l of labels) {
|
|
274
|
+
if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
|
|
296
275
|
try {
|
|
297
|
-
|
|
298
|
-
if (seen.has(l)) continue;
|
|
299
|
-
seen.add(l);
|
|
300
|
-
out.push(l);
|
|
276
|
+
return helpers.getAssociatedLabelElements(control) || [];
|
|
301
277
|
} catch {
|
|
302
|
-
|
|
278
|
+
return [];
|
|
303
279
|
}
|
|
304
280
|
}
|
|
305
|
-
return
|
|
281
|
+
return [];
|
|
306
282
|
}
|
|
307
283
|
|
|
308
284
|
function getVisibleTextLabelInfo(el) {
|
|
@@ -468,8 +444,6 @@ function runInPage(ctx) {
|
|
|
468
444
|
// Applicability: only when we can deterministically extract visible label text.
|
|
469
445
|
if (!visibleNorm) continue;
|
|
470
446
|
|
|
471
|
-
applicableCount += 1;
|
|
472
|
-
|
|
473
447
|
let acc = { present: false, value: '', mechanism: 'none', flags: [] };
|
|
474
448
|
try {
|
|
475
449
|
acc = helpers.getAccessibleNameInfo
|
|
@@ -478,6 +452,26 @@ function runInPage(ctx) {
|
|
|
478
452
|
} catch {
|
|
479
453
|
acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
|
|
480
454
|
}
|
|
455
|
+
|
|
456
|
+
// An aria-label empty once trimmed, or an aria-labelledby that yields no
|
|
457
|
+
// text, is skipped by the accessible name computation (AccName 1.2 steps
|
|
458
|
+
// 2B and 2C): the name comes from the next source, the content for a
|
|
459
|
+
// button or link, as if neither attribute were there. Such a control is
|
|
460
|
+
// not named by aria-label or aria-labelledby, so it is out of scope.
|
|
461
|
+
// getAccessibleNameInfo leaves name from content to its callers, which
|
|
462
|
+
// made `<button aria-labelledby="missing">Save</button>` fail with an
|
|
463
|
+
// empty name although its name is "Save".
|
|
464
|
+
const flags = acc && Array.isArray(acc.flags) ? acc.flags : [];
|
|
465
|
+
const ariaNamed =
|
|
466
|
+
acc && (acc.mechanism === 'aria-label' || acc.mechanism === 'aria-labelledby');
|
|
467
|
+
if (
|
|
468
|
+
!ariaNamed &&
|
|
469
|
+
(flags.includes('aria-labelledby-empty-or-unresolvable') ||
|
|
470
|
+
flags.includes('aria-label-empty'))
|
|
471
|
+
)
|
|
472
|
+
continue;
|
|
473
|
+
|
|
474
|
+
applicableCount += 1;
|
|
481
475
|
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
482
476
|
const accNorm = norm(accName);
|
|
483
477
|
|
|
@@ -490,44 +484,45 @@ function runInPage(ctx) {
|
|
|
490
484
|
// character) is not something markup settles: the author may have meant
|
|
491
485
|
// either. Report without asserting a defect instead of failing or
|
|
492
486
|
// staying silent.
|
|
493
|
-
let
|
|
487
|
+
let uncertainReason = '';
|
|
494
488
|
if (!contains) {
|
|
495
489
|
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
496
|
-
|
|
490
|
+
uncertainReason = 'HYPHENATION_DIFFERS';
|
|
497
491
|
} else {
|
|
498
492
|
const abbreviated = abbreviatedWords(visibleLabel);
|
|
499
493
|
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
|
|
500
|
-
|
|
494
|
+
uncertainReason = 'POSSIBLE_ABBREVIATION';
|
|
501
495
|
} else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
|
|
502
|
-
|
|
496
|
+
uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
|
|
503
497
|
} else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
|
|
504
|
-
|
|
498
|
+
uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
505
499
|
}
|
|
506
500
|
}
|
|
507
501
|
}
|
|
508
502
|
|
|
509
503
|
const isSymbolicUncertainty =
|
|
510
|
-
|
|
504
|
+
uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
|
|
505
|
+
uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
511
506
|
|
|
512
507
|
if (!contains) {
|
|
513
508
|
occurrences.push(
|
|
514
509
|
helpers.reportOccurrence(el, {
|
|
515
|
-
...(
|
|
516
|
-
summary:
|
|
510
|
+
...(uncertainReason ? { outcome: 'cantTell' } : null),
|
|
511
|
+
summary: uncertainReason
|
|
517
512
|
? 'Accessible name may not contain the visible label text.'
|
|
518
513
|
: 'Accessible name does not contain the visible label text.',
|
|
519
|
-
hint: !
|
|
514
|
+
hint: !uncertainReason
|
|
520
515
|
? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
|
|
521
516
|
: isSymbolicUncertainty
|
|
522
517
|
? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
|
|
523
518
|
: 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
|
|
524
519
|
i18n: {
|
|
525
|
-
summaryKey: !
|
|
520
|
+
summaryKey: !uncertainReason
|
|
526
521
|
? 'labelInName_summary_fail'
|
|
527
522
|
: isSymbolicUncertainty
|
|
528
523
|
? 'labelInName_summary_cantTell_symbolic'
|
|
529
524
|
: 'labelInName_summary_cantTell',
|
|
530
|
-
hintKey: !
|
|
525
|
+
hintKey: !uncertainReason
|
|
531
526
|
? 'labelInName_hint_fail'
|
|
532
527
|
: isSymbolicUncertainty
|
|
533
528
|
? 'labelInName_hint_cantTell_symbolic'
|
|
@@ -539,9 +534,23 @@ function runInPage(ctx) {
|
|
|
539
534
|
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
540
535
|
}
|
|
541
536
|
},
|
|
537
|
+
...(uncertainReason
|
|
538
|
+
? {
|
|
539
|
+
uncertainty: {
|
|
540
|
+
code: 'equivalence-unknown',
|
|
541
|
+
needed:
|
|
542
|
+
'Whether the accessible name and the visible label say the same thing to a user.',
|
|
543
|
+
evidence: {
|
|
544
|
+
visibleLabel,
|
|
545
|
+
accessibleName: accName,
|
|
546
|
+
difference: uncertainReason
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
: null),
|
|
542
551
|
data: {
|
|
543
552
|
details: {
|
|
544
|
-
reasonCode:
|
|
553
|
+
reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
545
554
|
visibleLabel,
|
|
546
555
|
accessibleName: accName,
|
|
547
556
|
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
@@ -16,16 +16,21 @@
|
|
|
16
16
|
*
|
|
17
17
|
* @expectation
|
|
18
18
|
* The <html> element has a lang attribute.
|
|
19
|
-
* The lang attribute is not empty and
|
|
19
|
+
* The lang attribute is not empty and its primary language subtag (the
|
|
20
|
+
* part before the first hyphen) is a registered language subtag, as ACT
|
|
21
|
+
* bf051a requires. A malformed later
|
|
22
|
+
* subtag (lang="fr-FR-!!") still identifies French, so it passes here;
|
|
23
|
+
* it is a markup validity error, not a missing language.
|
|
20
24
|
*
|
|
21
25
|
* @outcomes
|
|
22
26
|
* Pass:
|
|
23
|
-
* The <html> element has a non-empty lang attribute
|
|
27
|
+
* The <html> element has a non-empty lang attribute whose primary subtag is valid.
|
|
24
28
|
*
|
|
25
29
|
* Fail:
|
|
26
30
|
* The <html> element does not have a lang attribute.
|
|
27
31
|
* The <html> element has a lang attribute that is present but empty or whitespace.
|
|
28
|
-
* The <html> element has a lang attribute
|
|
32
|
+
* The <html> element has a lang attribute whose primary subtag is not a
|
|
33
|
+
* registered language subtag.
|
|
29
34
|
*
|
|
30
35
|
* @notes
|
|
31
36
|
* This rule does not verify that the declared language matches the actual language
|
|
@@ -143,12 +148,13 @@ function runInPage(ctx) {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
//
|
|
151
|
+
// Only the primary subtag is judged (ACT bf051a); the shared helper checks
|
|
152
|
+
// its shape and that the IANA registry lists it.
|
|
147
153
|
const isValidTag =
|
|
148
154
|
helpers && typeof helpers.isValidLanguageTag === 'function'
|
|
149
155
|
? helpers.isValidLanguageTag
|
|
150
|
-
: (v) => /^[a-zA-Z]{2,3}
|
|
151
|
-
if (!isValidTag(lang)) {
|
|
156
|
+
: (v) => /^[a-zA-Z]{2,3}$/.test(String(v || ''));
|
|
157
|
+
if (!isValidTag(lang.split('-')[0])) {
|
|
152
158
|
return {
|
|
153
159
|
ruleId: rule.ruleId,
|
|
154
160
|
outcome: 'fail',
|