@surea11y/core 1.4.0 → 1.4.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 +41 -0
- package/bin/surea11y-core.js +0 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +3 -1
- package/src/checks/automatic/aria-allowed-attr.js +648 -83
- package/src/checks/automatic/aria-deprecated-role.js +105 -37
- package/src/checks/automatic/aria-roles-valid.js +31 -6
- package/src/checks/automatic/autocomplete-valid.js +37 -1
- package/src/checks/automatic/avoid-inline-spacing.js +179 -19
- package/src/checks/automatic/binary-control-name-present.js +11 -3
- package/src/checks/automatic/button-name-present.js +50 -17
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +8 -1
- package/src/checks/automatic/dialog-name-present.js +8 -1
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +107 -39
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +82 -8
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-name-present.js +50 -17
- package/src/checks/automatic/listbox-name-present.js +8 -1
- package/src/checks/automatic/menuitem-name-present.js +8 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
- package/src/checks/automatic/meter-name-present.js +8 -1
- package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +8 -1
- package/src/checks/automatic/progressbar-name-present.js +8 -1
- package/src/checks/automatic/searchbox-name-present.js +8 -1
- package/src/checks/automatic/slider-name-present.js +11 -2
- package/src/checks/automatic/spinbutton-name-present.js +8 -1
- package/src/checks/automatic/summary-name-present.js +8 -1
- package/src/checks/automatic/tab-name-present.js +8 -1
- package/src/checks/automatic/table-th-has-data-cells.js +67 -6
- package/src/checks/automatic/textbox-name-present.js +8 -1
- package/src/checks/automatic/tooltip-name-present.js +8 -1
- package/src/checks/automatic/treeitem-name-present.js +8 -1
- package/src/checks/automatic/valid-lang.js +16 -3
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
- package/src/core.js +4853 -964
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +2256 -433
|
@@ -5,30 +5,36 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check aria-deprecated-role
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary An explicit role attribute
|
|
8
|
+
* @summary An explicit role attribute should not use a deprecated or author-discouraged ARIA role
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to any element whose role attribute's first (used) token is a
|
|
13
|
-
* valid, non-abstract ARIA role that authors
|
|
13
|
+
* valid, non-abstract ARIA role that authors should not explicitly
|
|
14
14
|
* declare — either because WAI-ARIA has deprecated it (e.g. "directory",
|
|
15
|
-
* superseded by role="list") or because it
|
|
16
|
-
* internal use
|
|
17
|
-
*
|
|
18
|
-
* MDN's "It should not be used by web authors" guidance).
|
|
15
|
+
* superseded by role="list") or because it is reserved for user-agent-
|
|
16
|
+
* internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
|
|
17
|
+
* NOT use in content).
|
|
19
18
|
* @expectation
|
|
20
|
-
* The role in use is neither deprecated nor
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* The role in use is neither deprecated nor reserved. Graded by the
|
|
20
|
+
* strength of the rule ARIA states:
|
|
21
|
+
* - CANTTELL at SHOULD NOT, which leaves the usage conforming, so the
|
|
22
|
+
* author decides whether it matters: a deprecated role ("directory") or
|
|
23
|
+
* one reserved for user agents ("generic").
|
|
24
|
+
* - FAIL at MUST NOT. No ARIA 1.2 or 1.3 role carries an author MUST NOT
|
|
25
|
+
* outside the abstract roles, so this outcome is reserved for a later
|
|
26
|
+
* revision promoting a role to that strength.
|
|
27
|
+
* Distinct, atomic decision from aria-roles-valid (existence/
|
|
28
|
+
* abstractness): a role can be valid and non-abstract while still being
|
|
29
|
+
* discouraged in explicit author use.
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
const id = 'aria-deprecated-role';
|
|
27
33
|
|
|
28
34
|
const meta = {
|
|
29
|
-
title: 'role attribute
|
|
35
|
+
title: 'role attribute should not use a deprecated or author-discouraged ARIA role',
|
|
30
36
|
description:
|
|
31
|
-
'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use
|
|
37
|
+
'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use (e.g. role="generic").',
|
|
32
38
|
i18n: {
|
|
33
39
|
titleKey: 'ariaDeprecatedRole_title',
|
|
34
40
|
descriptionKey: 'ariaDeprecatedRole_description'
|
|
@@ -64,12 +70,31 @@ function runInPage(ctx) {
|
|
|
64
70
|
? helpers.queryAllSmart('[role]')
|
|
65
71
|
: helpers.queryAll('[role]');
|
|
66
72
|
|
|
67
|
-
const
|
|
73
|
+
const failOccurrences = [];
|
|
74
|
+
const cantTellOccurrences = [];
|
|
68
75
|
let applicableCount = 0;
|
|
69
76
|
|
|
77
|
+
// A role on an element hidden from assistive technology has no effect, so
|
|
78
|
+
// ACT 674b10 does not apply to it.
|
|
79
|
+
function isHidden(el) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof helpers.isDomVisibleEligible === 'function') {
|
|
82
|
+
if (!helpers.isDomVisibleEligible(el, ctx)) return true;
|
|
83
|
+
}
|
|
84
|
+
for (let n = el; n && n.getAttribute; n = n.parentElement) {
|
|
85
|
+
if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
for (const el of nodes) {
|
|
71
94
|
if (!el || !el.getAttribute) continue;
|
|
72
95
|
|
|
96
|
+
if (isHidden(el)) continue;
|
|
97
|
+
|
|
73
98
|
const role = ariaHelpers.getExplicitRole(el);
|
|
74
99
|
if (!role) continue;
|
|
75
100
|
|
|
@@ -79,7 +104,14 @@ function runInPage(ctx) {
|
|
|
79
104
|
|
|
80
105
|
applicableCount += 1;
|
|
81
106
|
|
|
82
|
-
|
|
107
|
+
const deprecated = ariaHelpers.isDeprecatedRole(role);
|
|
108
|
+
const discouraged =
|
|
109
|
+
typeof ariaHelpers.isAuthorDiscouragedRole === 'function' &&
|
|
110
|
+
ariaHelpers.isAuthorDiscouragedRole(role);
|
|
111
|
+
const prohibited =
|
|
112
|
+
typeof ariaHelpers.isAuthorProhibitedRole === 'function' &&
|
|
113
|
+
ariaHelpers.isAuthorProhibitedRole(role);
|
|
114
|
+
if (!deprecated && !discouraged && !prohibited) continue;
|
|
83
115
|
|
|
84
116
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
85
117
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
@@ -87,34 +119,70 @@ function runInPage(ctx) {
|
|
|
87
119
|
? ariaHelpers.getDeprecatedRoleGuidance(role)
|
|
88
120
|
: 'Replace the deprecated role with its recommended replacement.';
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
122
|
+
if (prohibited) {
|
|
123
|
+
// Author MUST NOT: the usage is non-conforming, not merely discouraged.
|
|
124
|
+
failOccurrences.push({
|
|
125
|
+
selector: stableSelector,
|
|
126
|
+
html,
|
|
127
|
+
summary: `This element uses role="${role}", which authors must not explicitly declare.`,
|
|
128
|
+
hint: guidance,
|
|
129
|
+
occurrenceOutcome: 'fail',
|
|
130
|
+
i18n: {
|
|
131
|
+
summaryKey: 'ariaDeprecatedRole_summary_fail',
|
|
132
|
+
hintKey: 'ariaDeprecatedRole_hint_fail',
|
|
133
|
+
params: { role, guidance }
|
|
134
|
+
},
|
|
135
|
+
data: {
|
|
136
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance }
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
} else if (discouraged) {
|
|
140
|
+
// Reserved for user-agent-internal use, at SHOULD NOT strength.
|
|
141
|
+
cantTellOccurrences.push({
|
|
142
|
+
selector: stableSelector,
|
|
143
|
+
html,
|
|
144
|
+
summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
|
|
145
|
+
hint: guidance,
|
|
146
|
+
occurrenceOutcome: 'cantTell',
|
|
147
|
+
i18n: {
|
|
148
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
|
|
149
|
+
hintKey: 'ariaDeprecatedRole_hint_cantTell',
|
|
150
|
+
params: { role, guidance }
|
|
151
|
+
},
|
|
152
|
+
data: {
|
|
153
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance }
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
// Deprecated but still valid: surfaced for the author to decide.
|
|
158
|
+
cantTellOccurrences.push({
|
|
159
|
+
selector: stableSelector,
|
|
160
|
+
html,
|
|
161
|
+
summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
|
|
162
|
+
hint: guidance,
|
|
163
|
+
occurrenceOutcome: 'cantTell',
|
|
164
|
+
i18n: {
|
|
165
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell',
|
|
166
|
+
hintKey: 'ariaDeprecatedRole_hint_cantTell',
|
|
167
|
+
params: { role, guidance }
|
|
168
|
+
},
|
|
169
|
+
data: {
|
|
170
|
+
details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
104
174
|
}
|
|
105
175
|
|
|
106
176
|
if (applicableCount === 0) {
|
|
107
177
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
108
178
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
179
|
+
|
|
180
|
+
const resolved = helpers.resolveTieredOutcome(
|
|
181
|
+
failOccurrences,
|
|
182
|
+
cantTellOccurrences,
|
|
183
|
+
rule.defaultSeverity || 'moderate'
|
|
184
|
+
);
|
|
185
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
118
186
|
}
|
|
119
187
|
|
|
120
188
|
module.exports = { id, meta, runInPage };
|
|
@@ -67,19 +67,44 @@ function runInPage(ctx) {
|
|
|
67
67
|
const occurrences = [];
|
|
68
68
|
let applicableCount = 0;
|
|
69
69
|
|
|
70
|
+
// Programmatically hidden per the ACT glossary: display:none, visibility not
|
|
71
|
+
// visible, or aria-hidden on the element or an ancestor.
|
|
72
|
+
function isHidden(el) {
|
|
73
|
+
try {
|
|
74
|
+
if (typeof helpers.isDomVisibleEligible === 'function') {
|
|
75
|
+
if (!helpers.isDomVisibleEligible(el, ctx)) return true;
|
|
76
|
+
}
|
|
77
|
+
for (let n = el; n && n.getAttribute; n = n.parentElement) {
|
|
78
|
+
if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
for (const el of nodes) {
|
|
71
87
|
if (!el || !el.getAttribute) continue;
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
if (
|
|
89
|
+
// ACT 674b10 is not applicable to a programmatically hidden element.
|
|
90
|
+
if (isHidden(el)) continue;
|
|
75
91
|
|
|
76
|
-
|
|
92
|
+
// role takes a fallback list and the first token the browser recognises
|
|
93
|
+
// wins, so role="searchfield searchbox" resolves to searchbox. The rule
|
|
94
|
+
// fails only when no token names a concrete role.
|
|
95
|
+
const tokens =
|
|
96
|
+
typeof ariaHelpers.getAllRoleTokens === 'function'
|
|
97
|
+
? ariaHelpers.getAllRoleTokens(el)
|
|
98
|
+
: [ariaHelpers.getExplicitRole(el)].filter(Boolean);
|
|
99
|
+
if (!tokens.length) continue; // role="" or whitespace-only: not this rule's concern
|
|
77
100
|
|
|
78
|
-
|
|
79
|
-
const isKnown = ariaHelpers.isKnownRole(role);
|
|
101
|
+
applicableCount += 1;
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
const usable = tokens.find((t) => ariaHelpers.isKnownRole(t) && !ariaHelpers.isAbstractRole(t));
|
|
104
|
+
if (usable) continue;
|
|
82
105
|
|
|
106
|
+
const role = tokens[0];
|
|
107
|
+
const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
|
|
83
108
|
const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
|
|
84
109
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
85
110
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
@@ -130,7 +130,15 @@ function runInPage(ctx) {
|
|
|
130
130
|
if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
|
|
131
131
|
i += 1;
|
|
132
132
|
if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
|
|
133
|
-
|
|
133
|
+
// A contact modality token is only allowed when the field that follows is
|
|
134
|
+
// a contact field, so "work photo" is invalid while "work email" is not.
|
|
135
|
+
if (CONTACT_MODALITY.has(tokens[i])) {
|
|
136
|
+
const next = tokens[i + 1];
|
|
137
|
+
const isContactField =
|
|
138
|
+
next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
|
|
139
|
+
if (!isContactField) return false;
|
|
140
|
+
i += 1;
|
|
141
|
+
}
|
|
134
142
|
|
|
135
143
|
let end = tokens.length;
|
|
136
144
|
if (tokens[end - 1] === 'webauthn') end -= 1;
|
|
@@ -147,11 +155,39 @@ function runInPage(ctx) {
|
|
|
147
155
|
const occurrences = [];
|
|
148
156
|
let applicableCount = 0;
|
|
149
157
|
|
|
158
|
+
// ACT 73f2c2 exempts controls where the attribute cannot describe an input
|
|
159
|
+
// purpose: the on/off toggle, disabled controls, input types with a fixed
|
|
160
|
+
// value, and controls that take no input.
|
|
161
|
+
const FIXED_VALUE_TYPES = new Set([
|
|
162
|
+
'button',
|
|
163
|
+
'checkbox',
|
|
164
|
+
'file',
|
|
165
|
+
'image',
|
|
166
|
+
'radio',
|
|
167
|
+
'reset',
|
|
168
|
+
'submit'
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
function isExempt(el) {
|
|
172
|
+
const tag = String(el.tagName || '').toLowerCase();
|
|
173
|
+
if (tag === 'input') {
|
|
174
|
+
const type = String(el.getAttribute('type') || 'text').toLowerCase();
|
|
175
|
+
if (FIXED_VALUE_TYPES.has(type)) return true;
|
|
176
|
+
}
|
|
177
|
+
if (el.hasAttribute && el.hasAttribute('disabled')) return true;
|
|
178
|
+
if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
150
182
|
for (const el of nodes) {
|
|
151
183
|
if (!el || !el.getAttribute) continue;
|
|
152
184
|
const raw = String(el.getAttribute('autocomplete') || '').trim();
|
|
153
185
|
if (!raw) continue;
|
|
154
186
|
|
|
187
|
+
const tokens = raw.toLowerCase().split(/\s+/).filter(Boolean);
|
|
188
|
+
if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) continue;
|
|
189
|
+
if (isExempt(el)) continue;
|
|
190
|
+
|
|
155
191
|
applicableCount += 1;
|
|
156
192
|
|
|
157
193
|
if (isValidAutocomplete(raw)) continue;
|
|
@@ -5,31 +5,36 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @check avoid-inline-spacing
|
|
7
7
|
* @atomic true
|
|
8
|
-
* @summary Inline style must not force text
|
|
8
|
+
* @summary Inline style must not force text spacing below the WCAG metric
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.4.12
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to
|
|
13
|
-
* line-height, letter-spacing
|
|
12
|
+
* Applies to a rendered element with visible text of its own whose style
|
|
13
|
+
* attribute declares line-height, letter-spacing or word-spacing as
|
|
14
|
+
* `!important` with a real value. A CSS-wide keyword (inherit, initial,
|
|
15
|
+
* unset, revert) specifies no spacing of its own and is out of scope.
|
|
14
16
|
* @expectation
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* defeats that override (inline `!important` wins over nearly every
|
|
20
|
-
* other origin, including user stylesheets applied the normal way).
|
|
17
|
+
* Each such declaration already meets WCAG 1.4.12's own metric for that
|
|
18
|
+
* property, as a multiple of the font size: line-height at least 1.5,
|
|
19
|
+
* letter-spacing at least 0.12, word-spacing at least 0.16. A forced value
|
|
20
|
+
* that already satisfies the criterion leaves the user nothing to override.
|
|
21
21
|
* @implementation-notes
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* - Computed style resolves the cascade and the units; the declared value is
|
|
23
|
+
* only a fallback for environments that do not lay the document out. Spacing
|
|
24
|
+
* that resolves to neither is left unreported rather than failed.
|
|
25
|
+
* - Within one declaration block, importance outranks order, so the effective
|
|
26
|
+
* declaration is the last `!important` one for that property.
|
|
27
|
+
* - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
|
|
28
|
+
* wrap break, which cannot be determined without layout. Not implemented, so
|
|
29
|
+
* a forced value on text that never wraps is still reported.
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
const id = 'avoid-inline-spacing';
|
|
28
33
|
|
|
29
34
|
const meta = {
|
|
30
|
-
title: 'Inline style must not force text spacing
|
|
35
|
+
title: 'Inline style must not force text spacing below the WCAG metric',
|
|
31
36
|
description:
|
|
32
|
-
'Checks that inline style
|
|
37
|
+
'Checks that where inline style forces line-height, letter-spacing or word-spacing with !important, the value already meets WCAG 1.4.12, so the user has nothing left to override.',
|
|
33
38
|
i18n: {
|
|
34
39
|
titleKey: 'avoidInlineSpacing_title',
|
|
35
40
|
descriptionKey: 'avoidInlineSpacing_description'
|
|
@@ -57,6 +62,20 @@ function runInPage(ctx) {
|
|
|
57
62
|
const { helpers, rule } = ctx;
|
|
58
63
|
|
|
59
64
|
const SPACING_PROPS = ['line-height', 'letter-spacing', 'word-spacing'];
|
|
65
|
+
// WCAG 1.4.12's own metrics, as multiples of the font size.
|
|
66
|
+
const MIN_RATIO = { 'line-height': 1.5, 'letter-spacing': 0.12, 'word-spacing': 0.16 };
|
|
67
|
+
// Only these two take the value from the parent, leaving this declaration
|
|
68
|
+
// specifying no spacing of its own. `initial` and `revert` resolve to a
|
|
69
|
+
// concrete value (`normal` for all three properties), so they stay in scope.
|
|
70
|
+
const INHERITED_KEYWORDS = ['inherit', 'unset'];
|
|
71
|
+
// No user agent's `normal` line height reaches 1.5, so a forced `normal`
|
|
72
|
+
// always falls short of the metric.
|
|
73
|
+
const NORMAL_LINE_HEIGHT_RATIO = 1.2;
|
|
74
|
+
const CAMEL = {
|
|
75
|
+
'line-height': 'lineHeight',
|
|
76
|
+
'letter-spacing': 'letterSpacing',
|
|
77
|
+
'word-spacing': 'wordSpacing'
|
|
78
|
+
};
|
|
60
79
|
|
|
61
80
|
const nodes = helpers.queryAllSmart
|
|
62
81
|
? helpers.queryAllSmart('[style]')
|
|
@@ -65,6 +84,136 @@ function runInPage(ctx) {
|
|
|
65
84
|
const occurrences = [];
|
|
66
85
|
let applicableCount = 0;
|
|
67
86
|
|
|
87
|
+
// Within one declaration block, importance wins over order, so the last
|
|
88
|
+
// important declaration is the one that takes effect. Passed Example 5 of ACT
|
|
89
|
+
// 78fd32 turns on this and Passed Example 6 on the non-important half.
|
|
90
|
+
function effectiveDeclaration(raw, prop) {
|
|
91
|
+
const re = new RegExp('(?:^|;)\\s*' + prop + '\\s*:\\s*([^;]*)', 'gi');
|
|
92
|
+
let chosen = null;
|
|
93
|
+
let m;
|
|
94
|
+
while ((m = re.exec(raw))) {
|
|
95
|
+
const value = String(m[1] || '').trim();
|
|
96
|
+
const important = /!\s*important\s*$/i.test(value);
|
|
97
|
+
const clean = value.replace(/!\s*important\s*$/i, '').trim();
|
|
98
|
+
if (!clean) continue;
|
|
99
|
+
if (!chosen || important || !chosen.important) {
|
|
100
|
+
if (chosen && chosen.important && !important) continue;
|
|
101
|
+
chosen = { value: clean, important };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return chosen;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasVisibleTextChild(el) {
|
|
108
|
+
let kids;
|
|
109
|
+
try {
|
|
110
|
+
kids = el.childNodes ? Array.from(el.childNodes) : [];
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return kids.some((n) => n && n.nodeType === 3 && String(n.nodeValue || '').trim() !== '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isRendered(el) {
|
|
118
|
+
if (helpers.isDomVisibleEligible) {
|
|
119
|
+
try {
|
|
120
|
+
return !!helpers.isDomVisibleEligible(el, ctx, { targetSet: 'dom' }).eligible;
|
|
121
|
+
} catch {
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const px = (v) => {
|
|
129
|
+
const n = parseFloat(v);
|
|
130
|
+
return Number.isFinite(n) && /px\s*$/.test(String(v)) ? n : null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function computedStyleOf(el) {
|
|
134
|
+
if (helpers && typeof helpers.computedStyle === 'function') {
|
|
135
|
+
try {
|
|
136
|
+
const cs = helpers.computedStyle(el);
|
|
137
|
+
if (cs) return cs;
|
|
138
|
+
} catch {
|
|
139
|
+
// fall through to the realm's own view
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const view = el.ownerDocument && el.ownerDocument.defaultView;
|
|
144
|
+
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
|
|
145
|
+
} catch {
|
|
146
|
+
// no computed style available
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ACT scopes these rules to text visible on screen, and text pushed far off
|
|
152
|
+
// canvas is the one hidden shape the shared eligibility check keeps eligible.
|
|
153
|
+
function isOffScreen(el) {
|
|
154
|
+
if (!helpers.getVisibilityHintsInfo) return false;
|
|
155
|
+
try {
|
|
156
|
+
const info = helpers.getVisibilityHintsInfo(el, ctx, {});
|
|
157
|
+
return !!(info && Array.isArray(info.hints) && info.hints.indexOf('offscreen') !== -1);
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A realm that does not lay the document out reports font-size as the CSS
|
|
164
|
+
// absolute-size keyword rather than a length. Resolving those keeps px-valued
|
|
165
|
+
// spacing checkable there, and cannot manufacture a failure: a font size
|
|
166
|
+
// larger than assumed only makes a px ratio smaller.
|
|
167
|
+
const ABSOLUTE_FONT_SIZES = {
|
|
168
|
+
'xx-small': 9,
|
|
169
|
+
'x-small': 10,
|
|
170
|
+
small: 13,
|
|
171
|
+
medium: 16,
|
|
172
|
+
large: 18,
|
|
173
|
+
'x-large': 24,
|
|
174
|
+
'xx-large': 32
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function fontSizeOf(cs) {
|
|
178
|
+
if (!cs) return null;
|
|
179
|
+
const direct = px(cs.fontSize);
|
|
180
|
+
if (direct !== null) return direct;
|
|
181
|
+
const keyword = String(cs.fontSize || '')
|
|
182
|
+
.trim()
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
return ABSOLUTE_FONT_SIZES[keyword] || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The spacing as a multiple of the font size, or null when it cannot be
|
|
189
|
+
* resolved. Computed style is preferred because it already applies the
|
|
190
|
+
* cascade and unit resolution; the declared value is only a fallback for
|
|
191
|
+
* environments that do not lay the document out.
|
|
192
|
+
*/
|
|
193
|
+
function spacingRatio(el, prop, declared) {
|
|
194
|
+
const cs = computedStyleOf(el);
|
|
195
|
+
const fontSize = fontSizeOf(cs);
|
|
196
|
+
if (cs && fontSize) {
|
|
197
|
+
const used = px(cs[CAMEL[prop]]);
|
|
198
|
+
if (used !== null) return used / fontSize;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const v = String(declared || '')
|
|
202
|
+
.trim()
|
|
203
|
+
.toLowerCase();
|
|
204
|
+
// `normal`, and the keywords that resolve to it, add nothing for the two
|
|
205
|
+
// spacing properties and stay under the metric for line height.
|
|
206
|
+
if (v === 'normal' || v === 'initial' || v === 'revert' || v === 'revert-layer') {
|
|
207
|
+
return prop === 'line-height' ? NORMAL_LINE_HEIGHT_RATIO : 0;
|
|
208
|
+
}
|
|
209
|
+
if (/^[0-9.]+$/.test(v)) return parseFloat(v);
|
|
210
|
+
if (/em$/.test(v)) return parseFloat(v);
|
|
211
|
+
if (/%$/.test(v)) return parseFloat(v) / 100;
|
|
212
|
+
const asPx = px(v);
|
|
213
|
+
if (asPx !== null && fontSize) return asPx / fontSize;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
68
217
|
for (const el of nodes) {
|
|
69
218
|
if (!el || !el.getAttribute) continue;
|
|
70
219
|
const raw = String(el.getAttribute('style') || '');
|
|
@@ -74,14 +223,25 @@ function runInPage(ctx) {
|
|
|
74
223
|
const hasAnySpacingProp = SPACING_PROPS.some((p) => lower.includes(p));
|
|
75
224
|
if (!hasAnySpacingProp) continue;
|
|
76
225
|
|
|
77
|
-
|
|
226
|
+
// The rule is about text the user needs to re-space, so an element with no
|
|
227
|
+
// text of its own, or none that renders, is not a target.
|
|
228
|
+
if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
|
|
78
229
|
|
|
79
230
|
const flagged = [];
|
|
231
|
+
let inScope = false;
|
|
80
232
|
for (const prop of SPACING_PROPS) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
233
|
+
const decl = effectiveDeclaration(raw, prop);
|
|
234
|
+
if (!decl || !decl.important) continue;
|
|
235
|
+
if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
|
|
236
|
+
inScope = true;
|
|
237
|
+
const ratio = spacingRatio(el, prop, decl.value);
|
|
238
|
+
// Unresolvable spacing is left alone: this engine reserves fail for
|
|
239
|
+
// high-confidence violations.
|
|
240
|
+
if (ratio === null) continue;
|
|
241
|
+
if (ratio < MIN_RATIO[prop]) flagged.push(prop);
|
|
83
242
|
}
|
|
84
243
|
|
|
244
|
+
if (inScope) applicableCount += 1;
|
|
85
245
|
if (!flagged.length) continue;
|
|
86
246
|
|
|
87
247
|
const tag = el.tagName.toLowerCase();
|
|
@@ -91,8 +251,8 @@ function runInPage(ctx) {
|
|
|
91
251
|
occurrences.push({
|
|
92
252
|
selector: stableSelector,
|
|
93
253
|
html,
|
|
94
|
-
summary: `This element's inline style forces ${flagged.join(', ')} with !important
|
|
95
|
-
hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles
|
|
254
|
+
summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
|
|
255
|
+
hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
|
|
96
256
|
i18n: {
|
|
97
257
|
summaryKey: 'avoidInlineSpacing_summary_fail',
|
|
98
258
|
hintKey: 'avoidInlineSpacing_hint_fail',
|
|
@@ -137,9 +137,16 @@ function runInPage(ctx) {
|
|
|
137
137
|
return '';
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
141
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
142
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
140
143
|
function isEligibleAcc(helpers, el, ctx) {
|
|
141
144
|
const fn =
|
|
142
|
-
helpers && typeof helpers.
|
|
145
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
146
|
+
? helpers.isIncludedInAccessibilityTree
|
|
147
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
148
|
+
? helpers.isAccTreeEligible
|
|
149
|
+
: null;
|
|
143
150
|
if (!fn) return true;
|
|
144
151
|
try {
|
|
145
152
|
const r = fn(el, ctx);
|
|
@@ -153,8 +160,9 @@ function runInPage(ctx) {
|
|
|
153
160
|
const occurrences = [];
|
|
154
161
|
let applicableCount = 0;
|
|
155
162
|
|
|
156
|
-
|
|
157
|
-
|
|
163
|
+
// Native checkbox/radio without an explicit role belongs to
|
|
164
|
+
// form-control-programmatic-label-present.
|
|
165
|
+
const selector = '[role="checkbox"], [role="radio"], [role="switch"]';
|
|
158
166
|
const nodes = helpers.queryAllSmart
|
|
159
167
|
? helpers.queryAllSmart(selector)
|
|
160
168
|
: helpers.queryAll(selector);
|
|
@@ -78,7 +78,14 @@ function runInPage(ctx) {
|
|
|
78
78
|
|
|
79
79
|
for (const el of nodes) {
|
|
80
80
|
// isAccTreeEligible returns { eligible, reasons }, not a boolean.
|
|
81
|
-
|
|
81
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
82
|
+
// (ACT c487ae), which excludes focusable aria-hidden content;
|
|
83
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
84
|
+
const eligResult = helpers.isIncludedInAccessibilityTree
|
|
85
|
+
? helpers.isIncludedInAccessibilityTree(el, ctx)
|
|
86
|
+
: helpers.isAccTreeEligible
|
|
87
|
+
? helpers.isAccTreeEligible(el, ctx)
|
|
88
|
+
: true;
|
|
82
89
|
const eligible =
|
|
83
90
|
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
84
91
|
if (!eligible) continue;
|
|
@@ -104,24 +111,50 @@ function runInPage(ctx) {
|
|
|
104
111
|
inputValueName = getInputButtonValueName(el);
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// name-from-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
114
|
+
// ARIA 1.2 "Name From: author, contents". Every other known role is
|
|
115
|
+
// name-from-author-only: <button role="combobox">List</button> exposes a
|
|
116
|
+
// value, not a label. An unknown role falls back to the implicit role.
|
|
117
|
+
// <generated:aria-name-from-content>
|
|
118
|
+
const NAME_FROM_CONTENT_ROLES = [
|
|
119
|
+
'button',
|
|
120
|
+
'cell',
|
|
121
|
+
'checkbox',
|
|
122
|
+
'columnheader',
|
|
123
|
+
'doc-backlink',
|
|
124
|
+
'doc-biblioref',
|
|
125
|
+
'doc-glossref',
|
|
126
|
+
'doc-noteref',
|
|
127
|
+
'graphics-object',
|
|
128
|
+
'gridcell',
|
|
129
|
+
'heading',
|
|
130
|
+
'link',
|
|
131
|
+
'menuitem',
|
|
132
|
+
'menuitemcheckbox',
|
|
133
|
+
'menuitemradio',
|
|
134
|
+
'option',
|
|
135
|
+
'radio',
|
|
136
|
+
'row',
|
|
137
|
+
'rowgroup',
|
|
138
|
+
'rowheader',
|
|
139
|
+
'switch',
|
|
140
|
+
'tab',
|
|
141
|
+
'tooltip',
|
|
142
|
+
'treeitem'
|
|
122
143
|
];
|
|
144
|
+
// </generated:aria-name-from-content>
|
|
145
|
+
const isKnownRoleToken =
|
|
146
|
+
helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
|
|
147
|
+
? (() => {
|
|
148
|
+
try {
|
|
149
|
+
return !!helpers.aria.isKnownRole(roleNorm);
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
})()
|
|
154
|
+
: false;
|
|
123
155
|
const isContentNameCandidate =
|
|
124
|
-
(tag === 'button' || role === 'button') &&
|
|
156
|
+
(tag === 'button' || role === 'button') &&
|
|
157
|
+
(!roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm));
|
|
125
158
|
const contentName =
|
|
126
159
|
!trustedProgrammaticName && !inputValueName && isContentNameCandidate
|
|
127
160
|
? getConservativeSubtreeText(el)
|