@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
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <canvas> elements
|
|
13
|
-
*
|
|
14
|
-
* if they are focusable (tabbable) or referenced by IDREF relationships (per engine eligibility checks).
|
|
12
|
+
* Applies to <canvas> elements included in the accessibility tree.
|
|
13
|
+
* Hidden elements are excluded whether or not they are focusable.
|
|
15
14
|
* @expectation
|
|
16
15
|
* Each applicable <canvas> provides a text alternative via fallback content or an accessible name.
|
|
17
16
|
*/
|
|
@@ -71,8 +70,15 @@ function runInPage(ctx) {
|
|
|
71
70
|
const getEligibilityInfo =
|
|
72
71
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
// aria-hidden removes the canvas from the accessibility tree, so a text
|
|
74
|
+
// alternative on it cannot reach anyone; aria-hidden-focus reports a
|
|
75
|
+
// focusable one. Matches the other non-text-content rules.
|
|
76
|
+
const isEligibleHelper =
|
|
77
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
78
|
+
? helpers.isIncludedInAccessibilityTree
|
|
79
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
80
|
+
? helpers.isAccTreeEligible
|
|
81
|
+
: null;
|
|
76
82
|
|
|
77
83
|
const getTextAlternativeInfo =
|
|
78
84
|
helpers && typeof helpers.getTextAlternativeInfo === 'function'
|
|
@@ -97,15 +103,16 @@ function runInPage(ctx) {
|
|
|
97
103
|
for (const el of canvases) {
|
|
98
104
|
if (!el) continue;
|
|
99
105
|
|
|
100
|
-
// Applicability:
|
|
101
|
-
if (
|
|
106
|
+
// Applicability: included in the accessibility tree.
|
|
107
|
+
if (isEligibleHelper) {
|
|
102
108
|
const elig = (() => {
|
|
103
109
|
try {
|
|
104
|
-
return
|
|
110
|
+
return isEligibleHelper(el, ctx);
|
|
105
111
|
} catch {
|
|
106
112
|
return { eligible: true, reasons: [] };
|
|
107
113
|
}
|
|
108
114
|
})();
|
|
115
|
+
if (elig === false) continue;
|
|
109
116
|
if (elig && elig.eligible === false) continue;
|
|
110
117
|
}
|
|
111
118
|
|
|
@@ -124,9 +124,16 @@ function runInPage(ctx) {
|
|
|
124
124
|
return '';
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
128
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
129
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
127
130
|
function isEligibleAcc(helpers, el, ctx) {
|
|
128
131
|
const fn =
|
|
129
|
-
helpers && typeof helpers.
|
|
132
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
133
|
+
? helpers.isIncludedInAccessibilityTree
|
|
134
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
135
|
+
? helpers.isAccTreeEligible
|
|
136
|
+
: null;
|
|
130
137
|
if (!fn) return true;
|
|
131
138
|
try {
|
|
132
139
|
const r = fn(el, ctx);
|
|
@@ -74,9 +74,16 @@ function runInPage(ctx) {
|
|
|
74
74
|
return '';
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
78
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
79
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
77
80
|
function isEligibleAcc(helpers, el, ctx) {
|
|
78
81
|
const fn =
|
|
79
|
-
helpers && typeof helpers.
|
|
82
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
83
|
+
? helpers.isIncludedInAccessibilityTree
|
|
84
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
85
|
+
? helpers.isAccTreeEligible
|
|
86
|
+
: null;
|
|
80
87
|
if (!fn) return true;
|
|
81
88
|
try {
|
|
82
89
|
const r = fn(el, ctx);
|
|
@@ -73,8 +73,15 @@ const meta = {
|
|
|
73
73
|
function runInPage(ctx) {
|
|
74
74
|
const { helpers, rule } = ctx;
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// ACT e086e5 applies only to controls included in the accessibility tree, so
|
|
77
|
+
// a focusable control inside aria-hidden is out of scope; aria-hidden-focus
|
|
78
|
+
// reports that markup instead.
|
|
79
|
+
const isEligibleHelper =
|
|
80
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
81
|
+
? helpers.isIncludedInAccessibilityTree
|
|
82
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
83
|
+
? helpers.isAccTreeEligible
|
|
84
|
+
: null;
|
|
78
85
|
const getEligibilityInfo =
|
|
79
86
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
80
87
|
|
|
@@ -85,6 +92,41 @@ function runInPage(ctx) {
|
|
|
85
92
|
|
|
86
93
|
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
87
94
|
|
|
95
|
+
// Roles with their own *-name-present rule; a control carrying one
|
|
96
|
+
// explicitly is skipped here so it is reported once.
|
|
97
|
+
const ROLE_OWNED_ELSEWHERE = [
|
|
98
|
+
'alertdialog',
|
|
99
|
+
'button',
|
|
100
|
+
'checkbox',
|
|
101
|
+
'combobox',
|
|
102
|
+
'dialog',
|
|
103
|
+
'grid',
|
|
104
|
+
'link',
|
|
105
|
+
'listbox',
|
|
106
|
+
'menu',
|
|
107
|
+
'menubar',
|
|
108
|
+
'menuitem',
|
|
109
|
+
'menuitemcheckbox',
|
|
110
|
+
'menuitemradio',
|
|
111
|
+
'meter',
|
|
112
|
+
'option',
|
|
113
|
+
'progressbar',
|
|
114
|
+
'radio',
|
|
115
|
+
'radiogroup',
|
|
116
|
+
'scrollbar',
|
|
117
|
+
'searchbox',
|
|
118
|
+
'slider',
|
|
119
|
+
'spinbutton',
|
|
120
|
+
'switch',
|
|
121
|
+
'tab',
|
|
122
|
+
'tablist',
|
|
123
|
+
'textbox',
|
|
124
|
+
'toolbar',
|
|
125
|
+
'tooltip',
|
|
126
|
+
'tree',
|
|
127
|
+
'treeitem'
|
|
128
|
+
];
|
|
129
|
+
|
|
88
130
|
const metrics = {
|
|
89
131
|
applicableCount: 0,
|
|
90
132
|
passCount: 0,
|
|
@@ -108,9 +150,9 @@ function runInPage(ctx) {
|
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
function isEligibleAcc(el) {
|
|
111
|
-
if (!
|
|
153
|
+
if (!isEligibleHelper) return true;
|
|
112
154
|
try {
|
|
113
|
-
const r =
|
|
155
|
+
const r = isEligibleHelper(el, ctx);
|
|
114
156
|
if (typeof r === 'boolean') return r;
|
|
115
157
|
return !!(r && r.eligible);
|
|
116
158
|
} catch {
|
|
@@ -193,6 +235,8 @@ function runInPage(ctx) {
|
|
|
193
235
|
role = '';
|
|
194
236
|
}
|
|
195
237
|
|
|
238
|
+
if (role && ROLE_OWNED_ELSEWHERE.indexOf(role) !== -1) continue;
|
|
239
|
+
|
|
196
240
|
if (role === 'presentation' || role === 'none') {
|
|
197
241
|
const fi = getFocusableInfo
|
|
198
242
|
? (() => {
|
|
@@ -12,20 +12,29 @@
|
|
|
12
12
|
* Applies to labelable form controls (input, excluding
|
|
13
13
|
* hidden/submit/reset/button/image; select; textarea).
|
|
14
14
|
* @expectation
|
|
15
|
-
* At most one <label>
|
|
16
|
-
* wrapping it, or by a <label for="...">
|
|
17
|
-
* (
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* At most one <label> that can contribute to the control's accessible name
|
|
16
|
+
* is associated with it — by wrapping it, or by a <label for="..."> on its
|
|
17
|
+
* id (a label that both wraps and self-references via for counts once).
|
|
18
|
+
* Graded by whether the surplus labels actually compete for the name:
|
|
19
|
+
* - PASS when an override (aria-labelledby / aria-label) supersedes every
|
|
20
|
+
* native <label>: the labels then contribute nothing to the name, so
|
|
21
|
+
* they cannot be ambiguous. A visible-label-vs-name mismatch is SC 2.5.3
|
|
22
|
+
* Label in Name's concern, not this rule's.
|
|
23
|
+
* - FAIL when two or more non-empty labels compete and there is no
|
|
24
|
+
* override: screen readers announce a non-deterministic subset.
|
|
25
|
+
* - CANTTELL when one non-empty label is joined by empty label
|
|
26
|
+
* association(s) and there is no override: the name usually resolves to
|
|
27
|
+
* the real label, but handling of the empty association is not
|
|
28
|
+
* guaranteed across user agents.
|
|
29
|
+
* All-empty associations with no override are a missing-name case (the
|
|
30
|
+
* sibling rule below), not an ambiguity, so this rule stays silent.
|
|
21
31
|
* @implementation-notes
|
|
22
32
|
* - Distinct, atomic decision from form-control-programmatic-label-present
|
|
23
|
-
* (that rule checks a label exists at all; this one checks
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* rule exists to catch.
|
|
33
|
+
* (that rule checks a label exists at all; this one checks at most one
|
|
34
|
+
* matters). Whether a label contributes a name is decided by the shared
|
|
35
|
+
* helpers.labelContributesAccessibleName, so the two rules agree.
|
|
36
|
+
* - Accessibility-tree-ineligible labels (display:none, aria-hidden, etc.)
|
|
37
|
+
* are excluded before counting: they can't contribute to the name.
|
|
29
38
|
*/
|
|
30
39
|
|
|
31
40
|
const id = 'form-control-single-label';
|
|
@@ -62,6 +71,12 @@ function runInPage(ctx) {
|
|
|
62
71
|
|
|
63
72
|
const isAccTreeEligible =
|
|
64
73
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
74
|
+
const getAriaNameInfo =
|
|
75
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
76
|
+
const labelContributesName =
|
|
77
|
+
helpers && typeof helpers.labelContributesAccessibleName === 'function'
|
|
78
|
+
? helpers.labelContributesAccessibleName
|
|
79
|
+
: null;
|
|
65
80
|
|
|
66
81
|
const selector =
|
|
67
82
|
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
@@ -82,7 +97,8 @@ function runInPage(ctx) {
|
|
|
82
97
|
labelsByFor.get(forValue).push(lab);
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
const
|
|
100
|
+
const failOccurrences = [];
|
|
101
|
+
const cantTellOccurrences = [];
|
|
86
102
|
let applicableCount = 0;
|
|
87
103
|
|
|
88
104
|
for (const el of nodes) {
|
|
@@ -111,42 +127,94 @@ function runInPage(ctx) {
|
|
|
111
127
|
|
|
112
128
|
if (eligibleLabels.size <= 1) continue;
|
|
113
129
|
|
|
130
|
+
// An override (aria-labelledby / aria-label) supersedes every native
|
|
131
|
+
// <label>, so the labels contribute nothing to the accessible name and
|
|
132
|
+
// cannot compete.
|
|
133
|
+
const override = getAriaNameInfo ? getAriaNameInfo(el, ctx) : null;
|
|
134
|
+
if (override && override.present && override.value) continue;
|
|
135
|
+
|
|
136
|
+
// Without an override the labels feed the name; only labels with their
|
|
137
|
+
// own text compete for it.
|
|
138
|
+
const contributing = labelContributesName
|
|
139
|
+
? [...eligibleLabels].filter((lab) => labelContributesName(lab))
|
|
140
|
+
: [...eligibleLabels];
|
|
141
|
+
|
|
114
142
|
const tag = el.tagName.toLowerCase();
|
|
115
143
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
116
144
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
117
145
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
if (contributing.length >= 2) {
|
|
147
|
+
failOccurrences.push({
|
|
148
|
+
selector: stableSelector,
|
|
149
|
+
html,
|
|
150
|
+
summary: 'This form control is associated with more than one <label>.',
|
|
151
|
+
hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
|
|
152
|
+
occurrenceOutcome: 'fail',
|
|
153
|
+
i18n: {
|
|
154
|
+
summaryKey: 'formControlSingleLabel_summary_fail',
|
|
155
|
+
hintKey: 'formControlSingleLabel_hint_fail',
|
|
156
|
+
params: { element: tag, labelCount: String(contributing.length) }
|
|
157
|
+
},
|
|
158
|
+
data: {
|
|
159
|
+
details: {
|
|
160
|
+
reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
|
|
161
|
+
element: tag,
|
|
162
|
+
labelCount: contributing.length
|
|
163
|
+
}
|
|
133
164
|
}
|
|
134
|
-
}
|
|
135
|
-
})
|
|
165
|
+
});
|
|
166
|
+
} else if (contributing.length === 1) {
|
|
167
|
+
cantTellOccurrences.push({
|
|
168
|
+
selector: stableSelector,
|
|
169
|
+
html,
|
|
170
|
+
summary:
|
|
171
|
+
'This form control has one labelling <label> plus an extra empty <label> association.',
|
|
172
|
+
hint: 'Remove the redundant empty <label> so exactly one <label> is associated with the control.',
|
|
173
|
+
occurrenceOutcome: 'cantTell',
|
|
174
|
+
i18n: {
|
|
175
|
+
summaryKey: 'formControlSingleLabel_summary_cantTell',
|
|
176
|
+
hintKey: 'formControlSingleLabel_hint_cantTell',
|
|
177
|
+
params: { element: tag, labelCount: String(eligibleLabels.size) }
|
|
178
|
+
},
|
|
179
|
+
data: {
|
|
180
|
+
details: {
|
|
181
|
+
reasonCode: 'FORM_FIELD_EXTRA_EMPTY_LABEL',
|
|
182
|
+
element: tag,
|
|
183
|
+
labelCount: eligibleLabels.size,
|
|
184
|
+
contributingLabelCount: contributing.length
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// contributing.length === 0: no label carries text, so nothing competes
|
|
190
|
+
// for the name. A control left unnamed is form-control-programmatic-
|
|
191
|
+
// label-present's concern.
|
|
136
192
|
}
|
|
137
193
|
|
|
138
194
|
if (applicableCount === 0) {
|
|
139
195
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
140
196
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
|
|
198
|
+
const resolved = helpers.resolveTieredOutcome
|
|
199
|
+
? helpers.resolveTieredOutcome(
|
|
200
|
+
failOccurrences,
|
|
201
|
+
cantTellOccurrences,
|
|
202
|
+
rule.defaultSeverity || 'moderate'
|
|
203
|
+
)
|
|
204
|
+
: failOccurrences.length
|
|
205
|
+
? {
|
|
206
|
+
outcome: 'fail',
|
|
207
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
208
|
+
occurrences: failOccurrences
|
|
209
|
+
}
|
|
210
|
+
: cantTellOccurrences.length
|
|
211
|
+
? {
|
|
212
|
+
outcome: 'cantTell',
|
|
213
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
214
|
+
occurrences: cantTellOccurrences
|
|
215
|
+
}
|
|
216
|
+
: { outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
217
|
+
return { ruleId: rule.ruleId, ...resolved };
|
|
150
218
|
}
|
|
151
219
|
|
|
152
220
|
module.exports = { id, meta, runInPage };
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <img> elements
|
|
13
|
-
* Images with role="presentation" or role="none" are excluded only when they are not focusable
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Applies to <img> elements included in the accessibility tree.
|
|
13
|
+
* Images with role="presentation" or role="none" are excluded only when they are not focusable,
|
|
14
|
+
* since a focusable one reverts to the img role under presentational roles conflict resolution.
|
|
15
|
+
* Hidden images are excluded whether or not they are focusable.
|
|
16
16
|
* @expectation
|
|
17
17
|
* Each applicable <img> element has an alt attribute.
|
|
18
18
|
* The alt attribute may be empty (alt="").
|
|
@@ -60,8 +60,15 @@ function runInPage(ctx) {
|
|
|
60
60
|
const getEligibilityInfo =
|
|
61
61
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// ACT 23a2a8 exempts programmatically hidden images, and that glossary term
|
|
64
|
+
// has no focusability carve-out, so an aria-hidden image stays out of scope
|
|
65
|
+
// even when tabbable; aria-hidden-focus reports that markup instead.
|
|
66
|
+
const isEligibleHelper =
|
|
67
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
68
|
+
? helpers.isIncludedInAccessibilityTree
|
|
69
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
70
|
+
? helpers.isAccTreeEligible
|
|
71
|
+
: null;
|
|
65
72
|
const getFocusableInfo =
|
|
66
73
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
67
74
|
const getAriaNameInfo =
|
|
@@ -113,11 +120,11 @@ function runInPage(ctx) {
|
|
|
113
120
|
const el = imgs[i];
|
|
114
121
|
if (!el || !el.getAttribute) continue;
|
|
115
122
|
|
|
116
|
-
// Eligibility: only imgs exposed to assistive tech
|
|
117
|
-
if (
|
|
123
|
+
// Eligibility: only imgs exposed to assistive tech.
|
|
124
|
+
if (isEligibleHelper) {
|
|
118
125
|
let elig;
|
|
119
126
|
try {
|
|
120
|
-
elig =
|
|
127
|
+
elig = isEligibleHelper(el, ctx);
|
|
121
128
|
} catch {
|
|
122
129
|
elig = null;
|
|
123
130
|
}
|
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <input type="image"> elements
|
|
13
|
-
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
14
|
-
* if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
12
|
+
* Applies to <input type="image"> elements included in the accessibility tree.
|
|
15
13
|
* @expectation
|
|
16
|
-
* Each applicable <input type="image"> element has an alt attribute
|
|
14
|
+
* Each applicable <input type="image"> element has an alt attribute, and its
|
|
15
|
+
* accessible name is not the browser default for an image button.
|
|
17
16
|
* The alt attribute may be empty (alt="").
|
|
18
17
|
*/
|
|
19
18
|
|
|
@@ -72,8 +71,14 @@ function runInPage(ctx) {
|
|
|
72
71
|
const getEligibilityInfo =
|
|
73
72
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// ACT 59796f applies to image buttons included in the accessibility tree,
|
|
75
|
+
// which excludes focusable content inside aria-hidden.
|
|
76
|
+
const isEligibleHelper =
|
|
77
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
78
|
+
? helpers.isIncludedInAccessibilityTree
|
|
79
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
80
|
+
? helpers.isAccTreeEligible
|
|
81
|
+
: null;
|
|
77
82
|
|
|
78
83
|
const getAriaNameInfo =
|
|
79
84
|
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
@@ -99,63 +104,109 @@ function runInPage(ctx) {
|
|
|
99
104
|
for (const el of inputs) {
|
|
100
105
|
if (!el || !el.getAttribute) continue;
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
if (isAccTreeEligible) {
|
|
107
|
+
if (isEligibleHelper) {
|
|
104
108
|
const elig = (() => {
|
|
105
109
|
try {
|
|
106
|
-
return
|
|
110
|
+
return isEligibleHelper(el, ctx);
|
|
107
111
|
} catch {
|
|
108
|
-
return
|
|
112
|
+
return true;
|
|
109
113
|
}
|
|
110
114
|
})();
|
|
111
|
-
|
|
115
|
+
const eligible = typeof elig === 'boolean' ? elig : !(elig && elig.eligible === false);
|
|
116
|
+
if (!eligible) continue;
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
applicableCount += 1;
|
|
115
120
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
// The browser's own fallback name for an image button carries no
|
|
122
|
+
// information, so an author-supplied name equal to it is treated as no
|
|
123
|
+
// name at all (ACT 59796f). Only the English defaults are recognised:
|
|
124
|
+
// "Submit Query" from HTML-AAM, "Submit" from Chrome.
|
|
125
|
+
const effectiveName = (() => {
|
|
126
|
+
let v = '';
|
|
127
|
+
if (getAriaNameInfo) {
|
|
128
|
+
try {
|
|
129
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
130
|
+
if (aria && aria.present && aria.value) v = String(aria.value);
|
|
131
|
+
} catch {
|
|
132
|
+
v = '';
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
134
|
-
// source once alt is entirely absent. Same gap img-alt-present handles
|
|
135
|
-
// for <img title="..."> with no alt.
|
|
136
|
-
const titleRaw = (() => {
|
|
137
|
-
try {
|
|
138
|
-
return el.getAttribute('title');
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
135
|
+
if (!v) {
|
|
136
|
+
const alt = el.getAttribute('alt');
|
|
137
|
+
if (alt != null && String(alt).trim()) v = String(alt);
|
|
141
138
|
}
|
|
139
|
+
if (!v) {
|
|
140
|
+
const t = el.getAttribute('title');
|
|
141
|
+
if (t != null && String(t).trim()) v = String(t);
|
|
142
|
+
}
|
|
143
|
+
return v.trim().toLowerCase();
|
|
142
144
|
})();
|
|
143
|
-
|
|
145
|
+
|
|
146
|
+
if (effectiveName === 'submit query' || effectiveName === 'submit') {
|
|
147
|
+
const eligInfoDefault = getEligibilityInfo
|
|
148
|
+
? getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
149
|
+
: null;
|
|
150
|
+
const defaultNameOccurrence = {
|
|
151
|
+
summary:
|
|
152
|
+
'Accessible name is the browser default for an image button, which conveys nothing.',
|
|
153
|
+
hint: 'Replace it with text describing what the button does, for example "Search".',
|
|
154
|
+
i18n: {
|
|
155
|
+
summaryKey: 'inputImage_altPresent_summary_defaultName',
|
|
156
|
+
hintKey: 'inputImage_altPresent_hint_defaultName',
|
|
157
|
+
params: { element: 'input[type=image]' }
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
|
+
details: { reasonCode: 'default_name' }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
occurrences.push(
|
|
165
|
+
helpers && typeof helpers.reportOccurrence === 'function'
|
|
166
|
+
? helpers.reportOccurrence(el, defaultNameOccurrence)
|
|
167
|
+
: { selector: '', html: '', ...defaultNameOccurrence }
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// effectiveName already covers the naming sources HTML-AAM allows here,
|
|
173
|
+
// in order: aria-label/aria-labelledby, then alt, then title.
|
|
174
|
+
if (effectiveName) continue;
|
|
175
|
+
|
|
176
|
+
// An image button is a control, so an empty name fails whether alt is
|
|
177
|
+
// absent or present-but-empty. alt="" marks a decorative image, and an
|
|
178
|
+
// image button is never decorative.
|
|
179
|
+
const emptyAlt = el.getAttribute('alt') !== null;
|
|
144
180
|
|
|
145
181
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
146
182
|
|
|
147
|
-
const baseOccurrence =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
const baseOccurrence = emptyAlt
|
|
184
|
+
? {
|
|
185
|
+
summary: 'Empty alt="" on <input type="image"> leaves the control unnamed.',
|
|
186
|
+
hint: 'Describe the action in alt, or name the control with aria-label or aria-labelledby.',
|
|
187
|
+
i18n: {
|
|
188
|
+
summaryKey: 'inputImage_altPresent_summary_emptyAlt',
|
|
189
|
+
hintKey: 'inputImage_altPresent_hint_emptyAlt',
|
|
190
|
+
params: { element: 'input[type=image]' }
|
|
191
|
+
},
|
|
192
|
+
data: {
|
|
193
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
194
|
+
details: { reasonCode: 'empty_alt' }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
summary: 'Missing alt attribute on <input type="image">.',
|
|
199
|
+
hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
|
|
200
|
+
i18n: {
|
|
201
|
+
summaryKey: 'inputImage_altPresent_summary_fail',
|
|
202
|
+
hintKey: 'inputImage_altPresent_hint_fail',
|
|
203
|
+
params: { element: 'input[type=image]' }
|
|
204
|
+
},
|
|
205
|
+
data: {
|
|
206
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
207
|
+
details: { reasonCode: 'missing_alt' }
|
|
208
|
+
}
|
|
209
|
+
};
|
|
159
210
|
|
|
160
211
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
161
212
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|