@surea11y/core 1.7.0 → 1.8.1
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 +103 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +8 -7
- 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 +2 -2
- 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 +26 -11
- 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 +14285 -2219
- 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
|
@@ -386,10 +386,13 @@ function runInPage(ctx) {
|
|
|
386
386
|
if (runtimeProbe && runtimeProbe.redirected) {
|
|
387
387
|
cantTellOccurrences.push(
|
|
388
388
|
helpers.reportOccurrence(el, {
|
|
389
|
-
summary:
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
389
|
+
summary: `This <${tag}> has tabindex="-1" and focusable content, but focus moves immediately to another target. Verify keyboard reachability in a real browser.`,
|
|
390
|
+
hint: 'If this is an intentional focus handoff, make sure keyboard users cannot remain on hidden or intermediate frame content.',
|
|
391
|
+
i18n: {
|
|
392
|
+
summaryKey: 'iframeFocusableContent_summary_cantTell_redirect',
|
|
393
|
+
hintKey: 'iframeFocusableContent_hint_cantTell_redirect',
|
|
394
|
+
params: { element: tag }
|
|
395
|
+
},
|
|
393
396
|
uncertainty: {
|
|
394
397
|
code: 'runtime-dependent',
|
|
395
398
|
needed: 'Whether a keyboard user can reach this frame’s content in a real browser.',
|
|
@@ -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) : '';
|
|
@@ -435,8 +444,6 @@ function runInPage(ctx) {
|
|
|
435
444
|
// Applicability: only when we can deterministically extract visible label text.
|
|
436
445
|
if (!visibleNorm) continue;
|
|
437
446
|
|
|
438
|
-
applicableCount += 1;
|
|
439
|
-
|
|
440
447
|
let acc = { present: false, value: '', mechanism: 'none', flags: [] };
|
|
441
448
|
try {
|
|
442
449
|
acc = helpers.getAccessibleNameInfo
|
|
@@ -445,6 +452,26 @@ function runInPage(ctx) {
|
|
|
445
452
|
} catch {
|
|
446
453
|
acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
|
|
447
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;
|
|
448
475
|
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
449
476
|
const accNorm = norm(accName);
|
|
450
477
|
|
|
@@ -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',
|