@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.
- package/CHANGELOG.md +94 -1
- package/README.md +157 -54
- package/docs/ACT_RULE_MAPPING.md +2 -2
- package/docs/API_STABILITY.md +18 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +97 -3
- package/docs/EARL.md +1 -1
- package/docs/ENGINE_OPTIONS.md +81 -3
- package/docs/I18N.md +62 -20
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +1 -0
- package/docs/OUTPUT_SCHEMA.md +19 -6
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +73 -6
- package/docs/RULE_CATALOG.md +139 -116
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +62 -5
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/SARIF.md +2 -1
- package/docs/WCAG_CONFORMANCE.md +56 -3
- package/package.json +34 -11
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-braille-equivalent.js +25 -7
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +17 -4
- package/src/checks/automatic/aria-required-attr.js +29 -0
- package/src/checks/automatic/aria-role-name-present.js +19 -2
- package/src/checks/automatic/aria-valid-attr-value.js +28 -16
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +105 -40
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +35 -4
- package/src/checks/automatic/contrast-enhanced.js +4 -4
- package/src/checks/automatic/contrast-minimum.js +45 -11
- package/src/checks/automatic/css-orientation-lock.js +152 -30
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id.js +6 -2
- package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
- package/src/checks/automatic/iframe-focusable-content.js +7 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +40 -13
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +272 -60
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +0 -11
- package/src/checks/automatic/td-has-header.js +41 -5
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +109 -5
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +14419 -2231
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +34 -3
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +5 -4
- package/surea11y.i18n.de.js +1 -1
- package/surea11y.i18n.es.js +1 -1
- package/surea11y.i18n.fr.js +1 -1
- package/surea11y.i18n.ja.js +3 -0
- package/src/checks/manual/area-alt-decorative-manual.js +0 -255
|
@@ -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
|
|
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(/[
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
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(/[
|
|
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
|
|
252
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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">
|
|
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
|
|
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
|
-
//
|
|
150
|
-
|
|
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:
|
|
158
|
-
hint: 'Ensure
|
|
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
|
|
234
|
+
details
|
|
167
235
|
}
|
|
168
236
|
};
|
|
169
237
|
|