@surea11y/core 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -27,6 +27,11 @@
27
27
  * billing), and (c) is the whole of the field's programmatic label, not
28
28
  * the visible fragment of a label whose descriptive part is hidden.
29
29
  * @implementation-notes
30
+ * - Phrase lists exist for en, de, es, fr and ja. English is always
31
+ * checked; the list for the element's own language (nearest lang
32
+ * attribute, across shadow roots) is added on top. Matching every list
33
+ * everywhere would flag words that are generic in one language and a
34
+ * real name in another ("Suite", "Plus" on an English page).
30
35
  * - Authored as `type: 'manual'` (cantTell-capped, never fail). Whether a
31
36
  * label describes its field is a reading judgment: ACT cc0f0a fails
32
37
  * `<label>Menu<input type="text" name="fname"></label>` on the meaning
@@ -66,7 +71,7 @@ const id = 'form-control-label-quality';
66
71
  const meta = {
67
72
  title: 'Form field labels should be descriptive and distinguishable',
68
73
  description:
69
- 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart.',
74
+ 'Flags a visible form-field label that is a placeholder ("Label", "Field"), or that repeats another field\'s label with no visible context (heading, legend, or row) telling the two apart. English placeholders are always recognized, and German, Spanish, French or Japanese ones when the field is in that language.',
70
75
  i18n: {
71
76
  titleKey: 'formControlLabelQuality_title',
72
77
  descriptionKey: 'formControlLabelQuality_description'
@@ -95,7 +100,7 @@ function runInPage(ctx) {
95
100
 
96
101
  // Declared inside runInPage; see scripts/build-core.js header
97
102
  // ("runInPage MUST be self-contained").
98
- const PLACEHOLDER_LABEL_TEXT = new Set([
103
+ const PLACEHOLDER_LABEL_TEXT_EN = new Set([
99
104
  'label',
100
105
  'field',
101
106
  'form field',
@@ -117,6 +122,79 @@ function runInPage(ctx) {
117
122
  'default'
118
123
  ]);
119
124
 
125
+ const PLACEHOLDER_LABEL_TEXT = {
126
+ en: PLACEHOLDER_LABEL_TEXT_EN,
127
+ de: new Set([
128
+ 'beschriftung',
129
+ 'label',
130
+ 'feld',
131
+ 'formularfeld',
132
+ 'eingabe',
133
+ 'eingabefeld',
134
+ 'text',
135
+ 'textfeld',
136
+ 'text eingeben',
137
+ 'hier eingeben',
138
+ 'wert',
139
+ 'platzhalter',
140
+ 'unbenannt',
141
+ 'test',
142
+ 'beispiel',
143
+ 'standard'
144
+ ]),
145
+ es: new Set([
146
+ 'etiqueta',
147
+ 'campo',
148
+ 'campo de formulario',
149
+ 'entrada',
150
+ 'campo de entrada',
151
+ 'texto',
152
+ 'campo de texto',
153
+ 'introduzca texto',
154
+ 'escriba aquí',
155
+ 'valor',
156
+ 'marcador de posición',
157
+ 'sin título',
158
+ 'prueba',
159
+ 'ejemplo',
160
+ 'predeterminado'
161
+ ]),
162
+ fr: new Set([
163
+ 'libellé',
164
+ 'étiquette',
165
+ 'champ',
166
+ 'champ de formulaire',
167
+ 'saisie',
168
+ 'champ de saisie',
169
+ 'texte',
170
+ 'champ de texte',
171
+ 'saisir du texte',
172
+ 'saisissez ici',
173
+ 'valeur',
174
+ 'espace réservé',
175
+ 'sans titre',
176
+ 'test',
177
+ 'exemple',
178
+ 'par défaut'
179
+ ]),
180
+ ja: new Set([
181
+ 'ラベル',
182
+ 'フィールド',
183
+ '入力',
184
+ '入力欄',
185
+ '入力フィールド',
186
+ 'テキスト',
187
+ 'テキストフィールド',
188
+ 'ここに入力',
189
+ '値',
190
+ 'プレースホルダー',
191
+ '無題',
192
+ '未定',
193
+ 'テスト',
194
+ 'サンプル'
195
+ ])
196
+ };
197
+
120
198
  const FIELD_SELECTOR = [
121
199
  'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"])',
122
200
  'select',
@@ -143,13 +221,35 @@ function runInPage(ctx) {
143
221
  .trim();
144
222
  }
145
223
 
224
+ // NFKC folds full-width forms (「入力:」 ends in a full-width colon).
146
225
  function normalize(s) {
147
- return normalizeWs(s)
226
+ return normalizeWs(String(s || '').normalize('NFKC'))
148
227
  .toLowerCase()
149
- .replace(/[.,;:!?*]+$/g, '')
228
+ .replace(/[.,;:!?*。、]+$/g, '')
150
229
  .trim();
151
230
  }
152
231
 
232
+ // Primary language subtag of the nearest lang attribute, crossing shadow
233
+ // roots; '' when none is declared. Phrase lists are matched in English
234
+ // plus this language, so a word that is generic in one language ("plus"
235
+ // in French) is not flagged when it is a real name in another.
236
+ function primaryLangOf(node) {
237
+ let n = node;
238
+ while (n) {
239
+ if (n.nodeType === 1 && n.getAttribute) {
240
+ const v = n.getAttribute('lang');
241
+ if (v != null) return v.trim().split('-')[0].toLowerCase();
242
+ }
243
+ n = n.parentNode || n.host || null;
244
+ }
245
+ return '';
246
+ }
247
+
248
+ function inPhraseList(byLang, normalized, lang) {
249
+ if (byLang.en.has(normalized)) return true;
250
+ return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
251
+ }
252
+
153
253
  const isDomVisibleEligible =
154
254
  helpers && typeof helpers.isDomVisibleEligible === 'function'
155
255
  ? helpers.isDomVisibleEligible
@@ -475,7 +575,11 @@ function runInPage(ctx) {
475
575
  const occurrences = [];
476
576
 
477
577
  for (const field of fields) {
478
- const isPlaceholder = PLACEHOLDER_LABEL_TEXT.has(field.normalized);
578
+ const isPlaceholder = inPhraseList(
579
+ PLACEHOLDER_LABEL_TEXT,
580
+ field.normalized,
581
+ primaryLangOf(field.el)
582
+ );
479
583
  const shared = byKey.get(field.key) || [];
480
584
  const isDuplicate = shared.length > 1;
481
585
  const isPartiallyHidden = field.hiddenParts > 0;
@@ -11,6 +11,8 @@
11
11
  * Applies whenever the page contains two or more heading elements
12
12
  * (native <h1>-<h6>, or explicit role="heading" with aria-level;
13
13
  * default level 2 per the ARIA spec when aria-level is absent/invalid).
14
+ * A native <hx> with a valid aria-level (a positive integer) takes that
15
+ * level, as browsers expose it; otherwise it takes its tag level.
14
16
  * @expectation
15
17
  * In document order, each heading's level is no more than one greater
16
18
  * than the highest heading level seen so far. Jumping deeper by more
@@ -73,7 +75,13 @@ function runInPage(ctx) {
73
75
  }
74
76
  const tag = el.tagName ? el.tagName.toLowerCase() : '';
75
77
  const m = /^h([1-6])$/.exec(tag);
76
- return m ? parseInt(m[1], 10) : 0;
78
+ if (!m) return 0;
79
+ // Browsers expose a valid aria-level on <hx> in place of the tag level.
80
+ const ariaLevel = normalizeWs(el.getAttribute && el.getAttribute('aria-level'));
81
+ if (/^[0-9]+$/.test(ariaLevel) && parseInt(ariaLevel, 10) >= 1) {
82
+ return parseInt(ariaLevel, 10);
83
+ }
84
+ return parseInt(m[1], 10);
77
85
  }
78
86
 
79
87
  const nodes = helpers.queryAllSmart
@@ -21,6 +21,11 @@
21
21
  * 2", "Section 3"), a filename, or a URL. None of these describe the
22
22
  * topic or purpose of the content they introduce.
23
23
  * @implementation-notes
24
+ * - Phrase lists exist for en, de, es, fr and ja. English is always
25
+ * checked; the list for the element's own language (nearest lang
26
+ * attribute, across shadow roots) is added on top. Matching every list
27
+ * everywhere would flag words that are generic in one language and a
28
+ * real name in another ("Suite", "Plus" on an English page).
24
29
  * - Authored as `type: 'manual'` (cantTell-capped, never fail), for the
25
30
  * same reason `link-name-quality` is: whether a heading describes the
26
31
  * content that follows it is a reading-comprehension judgment. What is
@@ -52,7 +57,7 @@ const id = 'heading-quality';
52
57
  const meta = {
53
58
  title: 'Heading text should be descriptive, not a placeholder',
54
59
  description:
55
- 'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL.',
60
+ 'Flags headings whose accessible name is a placeholder rather than a description of the content that follows: a generic word ("Heading", "Untitled"), a numbered template slot ("Section 2"), a filename, or a URL. English phrases are always recognized, and German, Spanish, French or Japanese ones when the heading is in that language.',
56
61
  i18n: {
57
62
  titleKey: 'headingQuality_title',
58
63
  descriptionKey: 'headingQuality_description'
@@ -81,7 +86,7 @@ function runInPage(ctx) {
81
86
 
82
87
  // Declared inside runInPage; see scripts/build-core.js header
83
88
  // ("runInPage MUST be self-contained").
84
- const PLACEHOLDER_HEADING_TEXT = new Set([
89
+ const PLACEHOLDER_HEADING_TEXT_EN = new Set([
85
90
  'heading',
86
91
  'header',
87
92
  'headline',
@@ -118,8 +123,101 @@ function runInPage(ctx) {
118
123
  // A numbered template slot left as authored: "Heading 2", "Section 3",
119
124
  // "Chapter #1". The word alone is already in the set above; this catches
120
125
  // the same words carrying an index.
121
- const NUMBERED_PLACEHOLDER =
122
- /^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/;
126
+ // "Inhalt" and "Contenido" are left out: they usually head a table of
127
+ // contents, which is a real heading.
128
+ const PLACEHOLDER_HEADING_TEXT = {
129
+ en: PLACEHOLDER_HEADING_TEXT_EN,
130
+ de: new Set([
131
+ 'überschrift',
132
+ 'zwischenüberschrift',
133
+ 'titel',
134
+ 'untertitel',
135
+ 'unbenannt',
136
+ 'ohne titel',
137
+ 'abschnitt',
138
+ 'neuer abschnitt',
139
+ 'kapitel',
140
+ 'hauptinhalt',
141
+ 'text',
142
+ 'beispieltext',
143
+ 'blindtext',
144
+ 'platzhalter',
145
+ 'platzhaltertext',
146
+ 'titel hier eingeben',
147
+ 'test',
148
+ 'beispiel',
149
+ 'standard'
150
+ ]),
151
+ es: new Set([
152
+ 'encabezado',
153
+ 'título',
154
+ 'subtítulo',
155
+ 'sin título',
156
+ 'sección',
157
+ 'nueva sección',
158
+ 'capítulo',
159
+ 'contenido principal',
160
+ 'texto',
161
+ 'texto de ejemplo',
162
+ 'marcador de posición',
163
+ 'escriba el título aquí',
164
+ 'su título aquí',
165
+ 'por definir',
166
+ 'prueba',
167
+ 'ejemplo',
168
+ 'predeterminado'
169
+ ]),
170
+ fr: new Set([
171
+ 'titre',
172
+ 'sous-titre',
173
+ 'sans titre',
174
+ 'section',
175
+ 'nouvelle section',
176
+ 'chapitre',
177
+ 'contenu',
178
+ 'contenu principal',
179
+ 'texte',
180
+ "texte d'exemple",
181
+ 'espace réservé',
182
+ 'insérer le titre ici',
183
+ 'votre titre ici',
184
+ 'à définir',
185
+ 'test',
186
+ 'exemple',
187
+ 'par défaut'
188
+ ]),
189
+ ja: new Set([
190
+ '見出し',
191
+ '小見出し',
192
+ 'タイトル',
193
+ 'サブタイトル',
194
+ '無題',
195
+ 'セクション',
196
+ '新しいセクション',
197
+ '章',
198
+ 'コンテンツ',
199
+ 'メインコンテンツ',
200
+ 'テキスト',
201
+ 'サンプルテキスト',
202
+ 'ダミーテキスト',
203
+ 'プレースホルダー',
204
+ 'ここにタイトルを入力',
205
+ 'タイトルを入力',
206
+ '未定',
207
+ 'テスト',
208
+ 'サンプル'
209
+ ])
210
+ };
211
+
212
+ // A numbered template slot left as authored: "Heading 2", "Section 3",
213
+ // "Chapter #1", 「見出し 2」, 「第 1 章」.
214
+ const NUMBERED_PLACEHOLDER = {
215
+ en: /^(heading|header|headline|subheading|title|subtitle|section|chapter|part|step)\s*[-#:.]?\s*\d+$/,
216
+ de: /^(überschrift|titel|abschnitt|kapitel|teil|schritt)\s*[-#:.]?\s*\d+$/,
217
+ es: /^(encabezado|título|sección|capítulo|parte|paso)\s*[-#:.]?\s*\d+$/,
218
+ fr: /^(titre|section|chapitre|partie|étape)\s*[-#:.]?\s*\d+$/,
219
+ ja: /^(?:(見出し|タイトル|セクション|章|ステップ|パート)\s*[-#:.]?\s*\d+|第\s*\d+\s*章)$/
220
+ };
123
221
 
124
222
  const FILENAME_LIKE =
125
223
  /^[\w\s\-.,()[\]]+\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|tiff?|pdf|docx?|xlsx?|pptx?|html?|txt|csv|zip)$/;
@@ -132,13 +230,36 @@ function runInPage(ctx) {
132
230
  .trim();
133
231
  }
134
232
 
233
+ // NFKC folds full-width letters and digits (「見出し2」) into ASCII forms.
135
234
  function normalize(s) {
136
- return normalizeWs(s)
235
+ return normalizeWs(String(s || '').normalize('NFKC'))
236
+ .replace(/[\u2018\u2019]/g, "'")
137
237
  .toLowerCase()
138
- .replace(/[.,;:!?]+$/g, '')
238
+ .replace(/[.,;:!?。、]+$/g, '')
139
239
  .trim();
140
240
  }
141
241
 
242
+ // Primary language subtag of the nearest lang attribute, crossing shadow
243
+ // roots; '' when none is declared. Phrase lists are matched in English
244
+ // plus this language, so a word that is generic in one language ("plus"
245
+ // in French) is not flagged when it is a real name in another.
246
+ function primaryLangOf(node) {
247
+ let n = node;
248
+ while (n) {
249
+ if (n.nodeType === 1 && n.getAttribute) {
250
+ const v = n.getAttribute('lang');
251
+ if (v != null) return v.trim().split('-')[0].toLowerCase();
252
+ }
253
+ n = n.parentNode || n.host || null;
254
+ }
255
+ return '';
256
+ }
257
+
258
+ function inPhraseList(byLang, normalized, lang) {
259
+ if (byLang.en.has(normalized)) return true;
260
+ return !!(lang && lang !== 'en' && byLang[lang] && byLang[lang].has(normalized));
261
+ }
262
+
142
263
  function getExplicitRoleToken(el) {
143
264
  const raw = normalizeWs(el.getAttribute && el.getAttribute('role'));
144
265
  if (!raw) return '';
@@ -248,8 +369,21 @@ function runInPage(ctx) {
248
369
  }
249
370
  }
250
371
 
251
- function classify(normalized) {
252
- if (PLACEHOLDER_HEADING_TEXT.has(normalized) || NUMBERED_PLACEHOLDER.test(normalized)) {
372
+ function isNumberedPlaceholder(normalized, lang) {
373
+ if (NUMBERED_PLACEHOLDER.en.test(normalized)) return true;
374
+ return !!(
375
+ lang &&
376
+ lang !== 'en' &&
377
+ NUMBERED_PLACEHOLDER[lang] &&
378
+ NUMBERED_PLACEHOLDER[lang].test(normalized)
379
+ );
380
+ }
381
+
382
+ function classify(normalized, lang) {
383
+ if (
384
+ inPhraseList(PLACEHOLDER_HEADING_TEXT, normalized, lang) ||
385
+ isNumberedPlaceholder(normalized, lang)
386
+ ) {
253
387
  return 'PLACEHOLDER_HEADING_TEXT';
254
388
  }
255
389
  if (URL_LIKE.test(normalized)) return 'URL_LIKE_HEADING';
@@ -282,7 +416,7 @@ function runInPage(ctx) {
282
416
 
283
417
  applicableCount += 1;
284
418
 
285
- const reasonCode = classify(normalized);
419
+ const reasonCode = classify(normalized, primaryLangOf(el));
286
420
  if (!reasonCode) continue;
287
421
 
288
422
  const eligInfo = helpers.getEligibilityInfo
@@ -14,7 +14,8 @@
14
14
  * accessibility tree by any of: an aria-hidden ancestor-or-self, an
15
15
  * explicit role="none"/"presentation" not overridden by focusability, an
16
16
  * <img alt=""> (the native decorative marker, same focusability
17
- * override), an unlabeled <svg> whose implicit role is graphics-document
17
+ * override; only a literally empty alt, so alt=" " is not one), an
18
+ * unlabeled <svg> whose implicit role is graphics-document
18
19
  * (no img/graphics-symbol role restatement, aria-name, <title>/<desc>, or
19
20
  * focusability), or an unlabeled <canvas> with no explicit role at all.
20
21
  * Per ACT e88epe, an element is skipped entirely when any ancestor
@@ -198,12 +199,14 @@ function runInPage(ctx) {
198
199
  // Presentational exclusion: explicit role="none"/"presentation", or (img
199
200
  // only) the native alt="" marker. Both are overridden by focusability, per
200
201
  // ARIA conflict resolution (a focusable element is never presentational).
202
+ // Only a literally empty alt is the marker: HTML-AAM maps an img to
203
+ // presentation only for alt="", so alt=" " keeps the img role with an
204
+ // empty name, which img-alt-present fails.
201
205
  function isPresentationallyExcluded(el, tag) {
202
206
  const role = getExplicitRole(el);
203
207
  let presentational = role === 'presentation' || role === 'none';
204
208
  if (!presentational && tag === 'img') {
205
- const alt = el.getAttribute('alt');
206
- presentational = alt != null && trim(alt) === '';
209
+ presentational = el.getAttribute('alt') === '';
207
210
  }
208
211
  if (!presentational) return false;
209
212
  return !isFocusable(el);
@@ -10,20 +10,25 @@
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
12
  * @applicability
13
- * Applies to <input type="image"> elements whose alt attribute is present
14
- * and non-empty: an image button whose alt is its label. The element must
15
- * be included in the accessibility tree, and role="presentation"/"none"
16
- * takes it out of scope unless it is focusable, which restores its role.
13
+ * Applies to <input type="image"> elements that get a non-empty text
14
+ * alternative from any source: aria-labelledby (resolving to text),
15
+ * aria-label, alt or title. An element whose alt is present but empty is
16
+ * left to input-image-alt-decorative, which asks about that case. The
17
+ * element must be included in the accessibility tree, and
18
+ * role="presentation"/"none" takes it out of scope unless it is
19
+ * focusable, which restores its role.
17
20
  * @expectation
18
- * Human review is required to confirm that the provided text alternative is accurate and appropriate.
21
+ * Human review is required to confirm that the provided text alternative is
22
+ * accurate and appropriate. Each occurrence lists every source present
23
+ * (data.details.sources), so the reviewer checks each one.
19
24
  */
20
25
 
21
26
  const id = 'input-image-alt-quality';
22
27
 
23
28
  const meta = {
24
- title: '<input type="image"> alt text must be appropriate (manual review)',
29
+ title: '<input type="image"> text alternative must be appropriate (manual review)',
25
30
  description:
26
- 'Flags <input type="image"> elements with non-empty alt text for human review of appropriateness.',
31
+ 'Flags <input type="image"> elements with a non-empty text alternative (alt, aria-label, aria-labelledby or title) for human review of appropriateness.',
27
32
  i18n: {
28
33
  titleKey: 'inputImage_altQuality_title',
29
34
  descriptionKey: 'inputImage_altQuality_description'
@@ -112,6 +117,53 @@ function runInPage(ctx) {
112
117
  return !focusable;
113
118
  }
114
119
 
120
+ const getAriaNameInfo =
121
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
122
+
123
+ // Every non-empty text-alternative source on the element, in accessible-name
124
+ // order: aria-labelledby (when it resolves to text), aria-label, alt, title.
125
+ // aria-labelledby wins over aria-label in the name, but a present aria-label
126
+ // is still listed, so each attribute present is asked about.
127
+ function collectTextAlternativeSources(el) {
128
+ const attr = (name) => {
129
+ try {
130
+ const v = el.getAttribute(name);
131
+ return v == null ? '' : String(v).trim();
132
+ } catch {
133
+ return '';
134
+ }
135
+ };
136
+ const sources = [];
137
+ let name = '';
138
+ let aria = null;
139
+ if (getAriaNameInfo) {
140
+ try {
141
+ aria = getAriaNameInfo(el, ctx);
142
+ } catch {
143
+ aria = null;
144
+ }
145
+ }
146
+ if (aria && aria.present && aria.value) {
147
+ name = String(aria.value).trim();
148
+ sources.push(aria.mechanism);
149
+ if (aria.mechanism === 'aria-labelledby' && attr('aria-label')) sources.push('aria-label');
150
+ } else if (!getAriaNameInfo && attr('aria-label')) {
151
+ name = attr('aria-label');
152
+ sources.push('aria-label');
153
+ }
154
+ const altText = attr('alt');
155
+ if (altText) {
156
+ sources.push('alt');
157
+ if (!name) name = altText;
158
+ }
159
+ const titleText = attr('title');
160
+ if (titleText) {
161
+ sources.push('title');
162
+ if (!name) name = titleText;
163
+ }
164
+ return { sources, name, alt: altText };
165
+ }
166
+
115
167
  const els = (() => {
116
168
  try {
117
169
  return Array.from(
@@ -146,24 +198,40 @@ function runInPage(ctx) {
146
198
 
147
199
  if (isRolePresentationExcluded(el)) continue;
148
200
 
149
- // Rule-specific applicability (only elements that already have a text alternative mechanism)
150
- if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() !== '')) continue;
201
+ // alt="" with another name is input-image-alt-decorative's question, so
202
+ // it is left there rather than asked twice.
203
+ let altRaw;
204
+ try {
205
+ altRaw = el.getAttribute('alt');
206
+ } catch {
207
+ altRaw = null;
208
+ }
209
+ if (altRaw != null && String(altRaw).trim() === '') continue;
210
+
211
+ // Applies when any text-alternative source gives the control a non-empty
212
+ // name; each present source is listed so the reviewer checks all of them.
213
+ const alt = collectTextAlternativeSources(el);
214
+ if (!alt.sources.length) continue;
151
215
 
152
216
  applicableCount += 1;
153
217
 
154
218
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
219
+ const sourcesText = alt.sources.join(', ');
220
+
221
+ const details = { name: alt.name, sources: alt.sources.slice() };
222
+ if (alt.alt) details.alt = alt.alt;
155
223
 
156
224
  const baseOccurrence = {
157
- summary: 'Review alt text on <input type="image"> for accuracy and appropriateness.',
158
- hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
225
+ summary: `Review the text alternative of this <input type="image"> (${sourcesText}) for accuracy and appropriateness.`,
226
+ hint: 'Ensure each listed text alternative describes the control’s action (e.g., “Search”, “Submit order”) in context.',
159
227
  i18n: {
160
228
  summaryKey: 'inputImage_altQuality_summary_cantTell',
161
229
  hintKey: 'inputImage_altQuality_hint_cantTell',
162
- params: { element: 'input[type=image]' }
230
+ params: { element: 'input[type=image]', sources: sourcesText }
163
231
  },
164
232
  data: {
165
233
  visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
166
- details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
234
+ details
167
235
  }
168
236
  };
169
237