@surea11y/core 1.6.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 +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- 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 +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- 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-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -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 +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- 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-alt-present.js → 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 +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- 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 +243 -29
- 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/landmark-complementary-is-top-level-manual.js +231 -0
- 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/password-paste-enabled-manual.js +255 -0
- 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 +18061 -46194
- 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/earl.js +144 -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 +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- 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
|
|
@@ -206,29 +306,63 @@ function runInPage(ctx) {
|
|
|
206
306
|
return out;
|
|
207
307
|
}
|
|
208
308
|
|
|
309
|
+
// Index of `<label for="...">` elements by their `for` value, built once
|
|
310
|
+
// (not the native `el.labels`, deliberately -- see getNativeLabels).
|
|
311
|
+
const labelsByForId = new Map();
|
|
312
|
+
try {
|
|
313
|
+
for (const label of document.querySelectorAll('label[for]')) {
|
|
314
|
+
const forVal = normalizeWs(label.getAttribute('for'));
|
|
315
|
+
if (!forVal) continue;
|
|
316
|
+
const bucket = labelsByForId.get(forVal);
|
|
317
|
+
if (bucket) bucket.push(label);
|
|
318
|
+
else labelsByForId.set(forVal, [label]);
|
|
319
|
+
}
|
|
320
|
+
} catch {
|
|
321
|
+
// labelsByForId stays empty; getNativeLabels still has the wrapping-label check
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Per HTML's label-control algorithm, a wrapping <label> with no `for`
|
|
325
|
+
// attribute is associated with its own FIRST labelable descendant only --
|
|
326
|
+
// these are exactly the tags that count (input excluding type=hidden,
|
|
327
|
+
// which FIELD_SELECTOR already excludes from `el` itself, but a wrapping
|
|
328
|
+
// label could still wrap a hidden input ahead of the real field).
|
|
329
|
+
const LABELABLE_SELECTOR =
|
|
330
|
+
'input:not([type="hidden"]), select, textarea, button, meter, output, progress';
|
|
331
|
+
|
|
332
|
+
// The native `el.labels` accessor is spec-correct but, in this engine's
|
|
333
|
+
// supported Node/jsdom runtime (see tests/node-runtime-parity.test.js),
|
|
334
|
+
// jsdom implements it as a live query that walks the WHOLE document on
|
|
335
|
+
// every access, and for every `<label for>` it passes, calls `.control`
|
|
336
|
+
// -- itself another whole-document walk to resolve that id (jsdom's
|
|
337
|
+
// form-controls.js getLabelsForLabelable / HTMLLabelElement-impl.js
|
|
338
|
+
// `get control`). Called once per field, that's the O(fields * document
|
|
339
|
+
// size) cost that used to dominate this rule under jsdom (a real browser
|
|
340
|
+
// maintains an internal id index, so this cost is jsdom-specific, but
|
|
341
|
+
// jsdom is a real, tested runtime for this engine, not just a benchmark
|
|
342
|
+
// artifact). A `for`-attribute index built once above, plus a bounded
|
|
343
|
+
// `closest('label')` walk, answers the same question in O(1) amortized
|
|
344
|
+
// per field instead.
|
|
209
345
|
function getNativeLabels(el) {
|
|
210
346
|
const labels = [];
|
|
211
|
-
try {
|
|
212
|
-
if (el.labels && el.labels.length) {
|
|
213
|
-
for (const label of el.labels) labels.push(label);
|
|
214
|
-
return labels;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
// fall through to the manual lookup
|
|
218
|
-
}
|
|
219
347
|
const idVal = normalizeWs(el.getAttribute && el.getAttribute('id'));
|
|
220
348
|
if (idVal) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
} catch {
|
|
226
|
-
// ignore
|
|
349
|
+
const forLabels = labelsByForId.get(idVal);
|
|
350
|
+
if (forLabels) {
|
|
351
|
+
for (const label of forLabels) labels.push(label);
|
|
227
352
|
}
|
|
228
353
|
}
|
|
229
354
|
try {
|
|
230
355
|
const wrapping = el.closest ? el.closest('label') : null;
|
|
231
|
-
|
|
356
|
+
const hasForAttr = !!(wrapping && wrapping.hasAttribute && wrapping.hasAttribute('for'));
|
|
357
|
+
if (wrapping && !hasForAttr && labels.indexOf(wrapping) === -1) {
|
|
358
|
+
let firstControl = null;
|
|
359
|
+
try {
|
|
360
|
+
firstControl = wrapping.querySelector ? wrapping.querySelector(LABELABLE_SELECTOR) : null;
|
|
361
|
+
} catch {
|
|
362
|
+
firstControl = null;
|
|
363
|
+
}
|
|
364
|
+
if (firstControl === el) labels.push(wrapping);
|
|
365
|
+
}
|
|
232
366
|
} catch {
|
|
233
367
|
// ignore
|
|
234
368
|
}
|
|
@@ -269,15 +403,17 @@ function runInPage(ctx) {
|
|
|
269
403
|
}
|
|
270
404
|
}
|
|
271
405
|
|
|
406
|
+
// Nearest preceding *visible* heading text, per field. Precomputed below
|
|
407
|
+
// (once `fields` is built) via a single document-order sweep rather than
|
|
408
|
+
// scanning the full `headings` array backward for every field: `headings`
|
|
409
|
+
// and `fields` are each already in document order (both come from
|
|
410
|
+
// querySelectorAll/queryAllSmart), so a two-pointer merge answers every
|
|
411
|
+
// field in one O(fields + headings) pass instead of the O(fields *
|
|
412
|
+
// headings) pairwise compareDocumentPosition/isVisible calls a per-field
|
|
413
|
+
// backward scan requires -- the dominant cost on heading/form-heavy pages.
|
|
414
|
+
const nearestVisibleHeadingByField = new Map();
|
|
272
415
|
function nearestVisibleHeadingText(el) {
|
|
273
|
-
|
|
274
|
-
const heading = headings[i];
|
|
275
|
-
if (!precedes(heading, el)) continue;
|
|
276
|
-
if (!isVisible(heading)) continue;
|
|
277
|
-
const text = normalizeWs(heading.textContent);
|
|
278
|
-
if (text) return text;
|
|
279
|
-
}
|
|
280
|
-
return '';
|
|
416
|
+
return nearestVisibleHeadingByField.get(el) || '';
|
|
281
417
|
}
|
|
282
418
|
|
|
283
419
|
function fieldsetLegendText(el) {
|
|
@@ -309,6 +445,9 @@ function runInPage(ctx) {
|
|
|
309
445
|
|
|
310
446
|
// A table row or list item carries its own context (the product name a
|
|
311
447
|
// repeated "Quantity" field belongs to), so it takes part in the key.
|
|
448
|
+
// `row.textContent` serializes the whole row subtree, so it's memoized per
|
|
449
|
+
// row element -- several fields (one per column) commonly share a row.
|
|
450
|
+
const rowTextCache = new Map();
|
|
312
451
|
function rowContextText(el, labelText) {
|
|
313
452
|
let row;
|
|
314
453
|
try {
|
|
@@ -317,7 +456,11 @@ function runInPage(ctx) {
|
|
|
317
456
|
row = null;
|
|
318
457
|
}
|
|
319
458
|
if (!row || !isVisible(row)) return '';
|
|
320
|
-
|
|
459
|
+
let text = rowTextCache.get(row);
|
|
460
|
+
if (text === undefined) {
|
|
461
|
+
text = normalizeWs(row.textContent);
|
|
462
|
+
rowTextCache.set(row, text);
|
|
463
|
+
}
|
|
321
464
|
if (!text) return '';
|
|
322
465
|
return normalizeWs(text.split(labelText).join(' '));
|
|
323
466
|
}
|
|
@@ -348,6 +491,73 @@ function runInPage(ctx) {
|
|
|
348
491
|
});
|
|
349
492
|
}
|
|
350
493
|
|
|
494
|
+
// Populate nearestVisibleHeadingByField (declared above nearestVisibleHeadingText)
|
|
495
|
+
// via a single two-pointer sweep instead of, per field, scanning the full
|
|
496
|
+
// `headings` array backward and calling compareDocumentPosition/isVisible
|
|
497
|
+
// against every one of them -- the O(fields * headings) cost that used to
|
|
498
|
+
// dominate this rule (and the whole engine) on heading/form-heavy pages.
|
|
499
|
+
//
|
|
500
|
+
// Two correctness precautions this needs, since a two-pointer merge only
|
|
501
|
+
// works over sequences that are BOTH already in real document order:
|
|
502
|
+
//
|
|
503
|
+
// 1. A field inside a shadow root has no document-order relationship to
|
|
504
|
+
// any heading at all: per the DOM spec, compareDocumentPosition
|
|
505
|
+
// between nodes in different trees returns an implementation-specific
|
|
506
|
+
// (not document-order-derived) PRECEDING/FOLLOWING bit. Merging it in
|
|
507
|
+
// would be meaningless and could even desync the sweep for later
|
|
508
|
+
// fields, so it's filtered out up front and always answered '' --
|
|
509
|
+
// matching the "no light-DOM heading can be this field's visible
|
|
510
|
+
// context" reading rather than trusting that arbitrary bit.
|
|
511
|
+
// 2. `fields` (from queryAllSmart) is NOT guaranteed to be globally
|
|
512
|
+
// document-order: it's built root by root (see resolveContextRoots),
|
|
513
|
+
// and a multi-region `engineOptions.contextSelector` array is resolved
|
|
514
|
+
// in the CALLER's array order, not sorted by document position. A
|
|
515
|
+
// fresh copy is sorted by real position before the sweep so the merge
|
|
516
|
+
// is correct regardless of contextSelector's region order (this is a
|
|
517
|
+
// cheap O(k log k) sort, not the O(n*m) cost being fixed).
|
|
518
|
+
{
|
|
519
|
+
const orderedFields = [];
|
|
520
|
+
for (const field of fields) {
|
|
521
|
+
let sameRoot;
|
|
522
|
+
try {
|
|
523
|
+
sameRoot =
|
|
524
|
+
typeof field.el.getRootNode !== 'function' || field.el.getRootNode() === document;
|
|
525
|
+
} catch {
|
|
526
|
+
sameRoot = true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!sameRoot) {
|
|
530
|
+
nearestVisibleHeadingByField.set(field.el, '');
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
orderedFields.push(field);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
orderedFields.sort((a, b) => {
|
|
537
|
+
try {
|
|
538
|
+
const bits = a.el.compareDocumentPosition(b.el);
|
|
539
|
+
if (bits & 4) return -1; // b follows a
|
|
540
|
+
if (bits & 2) return 1; // b precedes a
|
|
541
|
+
} catch {
|
|
542
|
+
/* ignore -- treat as equal/unordered */
|
|
543
|
+
}
|
|
544
|
+
return 0;
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
let hIdx = 0;
|
|
548
|
+
let current = '';
|
|
549
|
+
for (const field of orderedFields) {
|
|
550
|
+
while (hIdx < headings.length && precedes(headings[hIdx], field.el)) {
|
|
551
|
+
const heading = headings[hIdx];
|
|
552
|
+
hIdx += 1;
|
|
553
|
+
if (!isVisible(heading)) continue;
|
|
554
|
+
const text = normalizeWs(heading.textContent);
|
|
555
|
+
if (text) current = text;
|
|
556
|
+
}
|
|
557
|
+
nearestVisibleHeadingByField.set(field.el, current);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
351
561
|
if (!fields.length) {
|
|
352
562
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
353
563
|
}
|
|
@@ -365,7 +575,11 @@ function runInPage(ctx) {
|
|
|
365
575
|
const occurrences = [];
|
|
366
576
|
|
|
367
577
|
for (const field of fields) {
|
|
368
|
-
const isPlaceholder =
|
|
578
|
+
const isPlaceholder = inPhraseList(
|
|
579
|
+
PLACEHOLDER_LABEL_TEXT,
|
|
580
|
+
field.normalized,
|
|
581
|
+
primaryLangOf(field.el)
|
|
582
|
+
);
|
|
369
583
|
const shared = byKey.get(field.key) || [];
|
|
370
584
|
const isDuplicate = shared.length > 1;
|
|
371
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);
|