@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/surea11y-core.js +0 -0
  3. package/docs/ARIA_DEPRECATION.md +95 -0
  4. package/docs/RULE_CATALOG.md +6 -6
  5. package/package.json +3 -1
  6. package/src/checks/automatic/aria-allowed-attr.js +648 -83
  7. package/src/checks/automatic/aria-deprecated-role.js +105 -37
  8. package/src/checks/automatic/aria-roles-valid.js +31 -6
  9. package/src/checks/automatic/autocomplete-valid.js +37 -1
  10. package/src/checks/automatic/avoid-inline-spacing.js +179 -19
  11. package/src/checks/automatic/binary-control-name-present.js +11 -3
  12. package/src/checks/automatic/button-name-present.js +50 -17
  13. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  14. package/src/checks/automatic/combobox-name-present.js +8 -1
  15. package/src/checks/automatic/dialog-name-present.js +8 -1
  16. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  17. package/src/checks/automatic/form-control-single-label.js +107 -39
  18. package/src/checks/automatic/img-alt-present.js +16 -9
  19. package/src/checks/automatic/input-image-alt-present.js +99 -48
  20. package/src/checks/automatic/label-in-name.js +82 -8
  21. package/src/checks/automatic/language-page-present.js +5 -1
  22. package/src/checks/automatic/link-name-present.js +50 -17
  23. package/src/checks/automatic/listbox-name-present.js +8 -1
  24. package/src/checks/automatic/menuitem-name-present.js +8 -1
  25. package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
  26. package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
  27. package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
  28. package/src/checks/automatic/meter-name-present.js +8 -1
  29. package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
  30. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  31. package/src/checks/automatic/option-name-present.js +8 -1
  32. package/src/checks/automatic/progressbar-name-present.js +8 -1
  33. package/src/checks/automatic/searchbox-name-present.js +8 -1
  34. package/src/checks/automatic/slider-name-present.js +11 -2
  35. package/src/checks/automatic/spinbutton-name-present.js +8 -1
  36. package/src/checks/automatic/summary-name-present.js +8 -1
  37. package/src/checks/automatic/tab-name-present.js +8 -1
  38. package/src/checks/automatic/table-th-has-data-cells.js +67 -6
  39. package/src/checks/automatic/textbox-name-present.js +8 -1
  40. package/src/checks/automatic/tooltip-name-present.js +8 -1
  41. package/src/checks/automatic/treeitem-name-present.js +8 -1
  42. package/src/checks/automatic/valid-lang.js +16 -3
  43. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
  44. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  45. package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
  46. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
  47. package/src/core.js +4853 -964
  48. package/src/sarif.js +2 -2
  49. 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 that are exposed to assistive technologies.
13
- * Elements otherwise hidden from the accessibility tree remain applicable
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
- const isAccTreeEligible =
75
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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: eligible in the accessibility tree (with focusable/IDREF exceptions handled by helper).
101
- if (isAccTreeEligible) {
106
+ // Applicability: included in the accessibility tree.
107
+ if (isEligibleHelper) {
102
108
  const elig = (() => {
103
109
  try {
104
- return isAccTreeEligible(el, ctx);
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.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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
- const isAccTreeEligible =
77
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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 (!isAccTreeEligible) return true;
153
+ if (!isEligibleHelper) return true;
112
154
  try {
113
- const r = isAccTreeEligible(el, ctx);
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> is associated with the control — either by
16
- * wrapping it, or by a <label for="..."> pointing to its id
17
- * (deduplicated: a label that both wraps the control and
18
- * self-references it via for counts once). Multiple associated labels
19
- * are ambiguous: many screen readers only announce one of them, and it
20
- * is not deterministic which.
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 there is at
24
- * most one).
25
- * - A label that isn't accessibility-tree-eligible (display:none,
26
- * aria-hidden, etc.) is excluded before counting: it can't contribute to
27
- * the control's accessible name, so it can't create the ambiguity this
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 occurrences = [];
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
- occurrences.push({
119
- selector: stableSelector,
120
- html,
121
- summary: 'This form control is associated with more than one <label>.',
122
- hint: 'Keep only one <label> per form control (either wrapping it or referencing it via for/id).',
123
- i18n: {
124
- summaryKey: 'formControlSingleLabel_summary_fail',
125
- hintKey: 'formControlSingleLabel_hint_fail',
126
- params: { element: tag, labelCount: String(eligibleLabels.size) }
127
- },
128
- data: {
129
- details: {
130
- reasonCode: 'FORM_FIELD_MULTIPLE_LABELS',
131
- element: tag,
132
- labelCount: eligibleLabels.size
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
- if (occurrences.length) {
142
- return {
143
- ruleId: rule.ruleId,
144
- outcome: 'fail',
145
- severity: rule.defaultSeverity || 'moderate',
146
- occurrences
147
- };
148
- }
149
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
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 that are exposed to assistive technologies.
13
- * Images with role="presentation" or role="none" are excluded only when they are not focusable.
14
- * Elements otherwise hidden from the accessibility tree remain applicable
15
- * if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
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
- const isAccTreeEligible =
64
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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 (with focusable/IDREF exceptions handled by helper)
117
- if (isAccTreeEligible) {
123
+ // Eligibility: only imgs exposed to assistive tech.
124
+ if (isEligibleHelper) {
118
125
  let elig;
119
126
  try {
120
- elig = isAccTreeEligible(el, ctx);
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 that are exposed to assistive technologies.
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
- const isAccTreeEligible =
76
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
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
- // Applicability: eligible in the acc tree (with helper exceptions).
103
- if (isAccTreeEligible) {
107
+ if (isEligibleHelper) {
104
108
  const elig = (() => {
105
109
  try {
106
- return isAccTreeEligible(el, ctx);
110
+ return isEligibleHelper(el, ctx);
107
111
  } catch {
108
- return { eligible: true, reasons: [] };
112
+ return true;
109
113
  }
110
114
  })();
111
- if (elig && elig.eligible === false) continue;
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
- const hasAlt = el.getAttribute('alt') !== null;
117
- if (hasAlt) continue;
118
-
119
- // aria-label / aria-labelledby is also a valid, standards-recognized
120
- // text-alternative mechanism for <input type="image"> (HTML-AAM
121
- // accessible name computation includes ARIA naming before falling
122
- // back to alt).
123
- if (getAriaNameInfo) {
124
- let ariaName;
125
- try {
126
- ariaName = getAriaNameInfo(el, ctx);
127
- } catch {
128
- ariaName = null;
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 (ariaName && ariaName.present) continue;
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
- if (titleRaw !== null && String(titleRaw).trim()) continue;
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
- summary: 'Missing alt attribute on <input type="image">.',
149
- hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
150
- i18n: {
151
- summaryKey: 'inputImage_altPresent_summary_fail',
152
- hintKey: 'inputImage_altPresent_hint_fail',
153
- params: { element: 'input[type=image]' }
154
- },
155
- data: {
156
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
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));