@surea11y/core 1.3.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -2
- package/README.md +109 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +26 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/CI_INTEGRATIONS.md +7 -7
- package/docs/ENGINE_OPTIONS.md +1 -1
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +1 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +52 -16
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +4 -6
- package/src/checks/automatic/aria-allowed-attr.js +663 -134
- package/src/checks/automatic/aria-allowed-role.js +2 -0
- package/src/checks/automatic/aria-braille-equivalent.js +2 -0
- package/src/checks/automatic/aria-conditional-attr.js +8 -7
- package/src/checks/automatic/aria-deprecated-role.js +107 -38
- package/src/checks/automatic/aria-hidden-body.js +6 -4
- package/src/checks/automatic/aria-hidden-focus.js +12 -13
- package/src/checks/automatic/aria-prohibited-attr.js +98 -105
- package/src/checks/automatic/aria-prohibited-children.js +56 -87
- package/src/checks/automatic/aria-required-attr.js +6 -7
- package/src/checks/automatic/aria-required-children.js +7 -10
- package/src/checks/automatic/aria-required-parent.js +20 -25
- package/src/checks/automatic/aria-role-name-present.js +2 -0
- package/src/checks/automatic/aria-roles-valid.js +33 -6
- package/src/checks/automatic/aria-valid-attr-value.js +18 -15
- package/src/checks/automatic/aria-valid-attr.js +2 -0
- package/src/checks/automatic/autocomplete-valid.js +39 -1
- package/src/checks/automatic/avoid-inline-spacing.js +181 -20
- package/src/checks/automatic/binary-control-name-present.js +13 -3
- package/src/checks/automatic/button-name-present.js +54 -21
- package/src/checks/automatic/canvas-text-alternative-present.js +17 -8
- package/src/checks/automatic/combobox-name-present.js +10 -1
- package/src/checks/automatic/contrast-computable.js +2 -0
- package/src/checks/automatic/contrast-enhanced.js +2 -0
- package/src/checks/automatic/contrast-minimum.js +2 -0
- package/src/checks/automatic/css-orientation-lock.js +21 -27
- package/src/checks/automatic/definition-list-children-valid.js +6 -6
- package/src/checks/automatic/deprecated-elements-not-used.js +4 -2
- package/src/checks/automatic/dialog-name-present.js +18 -11
- package/src/checks/automatic/dlitem-parent-valid.js +2 -0
- package/src/checks/automatic/duplicate-id-aria.js +4 -3
- package/src/checks/automatic/embed-text-alternative-present.js +2 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +50 -4
- package/src/checks/automatic/form-control-single-label.js +110 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -0
- package/src/checks/automatic/iframe-focusable-content.js +246 -18
- package/src/checks/automatic/iframe-name-present.js +2 -0
- package/src/checks/automatic/iframe-title-unique.js +3 -1
- package/src/checks/automatic/img-alt-present.js +23 -18
- package/src/checks/automatic/input-image-alt-present.js +101 -52
- package/src/checks/automatic/label-in-name.js +97 -27
- package/src/checks/automatic/language-page-present.js +7 -1
- package/src/checks/automatic/link-in-text-block.js +2 -0
- package/src/checks/automatic/link-name-present.js +52 -17
- package/src/checks/automatic/list-children-valid.js +14 -24
- package/src/checks/automatic/listbox-name-present.js +10 -1
- package/src/checks/automatic/listitem-parent-valid.js +30 -7
- package/src/checks/automatic/menuitem-name-present.js +10 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +33 -4
- package/src/checks/automatic/meta-refresh-timing-absent.js +32 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +39 -15
- package/src/checks/automatic/meter-name-present.js +12 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +177 -25
- package/src/checks/automatic/object-text-alternative-present.js +16 -7
- package/src/checks/automatic/option-name-present.js +10 -1
- package/src/checks/automatic/page-title-present.js +2 -0
- package/src/checks/automatic/progressbar-name-present.js +16 -11
- package/src/checks/automatic/role-img-alt-present.js +4 -4
- package/src/checks/automatic/searchbox-name-present.js +10 -1
- package/src/checks/automatic/server-side-image-map-absent.js +4 -3
- package/src/checks/automatic/slider-name-present.js +13 -2
- package/src/checks/automatic/spinbutton-name-present.js +10 -1
- package/src/checks/automatic/summary-name-present.js +10 -1
- package/src/checks/automatic/svg-image-text-alternative-present.js +2 -0
- package/src/checks/automatic/svg-text-alternative-present.js +17 -5
- package/src/checks/automatic/tab-name-present.js +10 -1
- package/src/checks/automatic/table-headers-attr-valid.js +3 -2
- package/src/checks/automatic/table-th-has-data-cells.js +69 -6
- package/src/checks/automatic/target-size-minimum.js +5 -0
- package/src/checks/automatic/td-has-header.js +24 -1
- package/src/checks/automatic/textbox-name-present.js +10 -1
- package/src/checks/automatic/tooltip-name-present.js +10 -1
- package/src/checks/automatic/treeitem-name-present.js +10 -1
- package/src/checks/automatic/valid-lang.js +18 -3
- package/src/checks/automatic/video-poster-text-alternative-present.js +2 -0
- package/src/checks/manual/accesskeys-manual.js +3 -1
- package/src/checks/manual/area-alt-decorative-manual.js +2 -0
- package/src/checks/manual/area-alt-quality-manual.js +2 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +14 -23
- package/src/checks/manual/aria-text-manual.js +6 -5
- package/src/checks/manual/bypass-blocks-present-manual.js +279 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/css-hidden-focus.js +184 -9
- package/src/checks/manual/embed-text-alternative-quality-manual.js +18 -13
- package/src/checks/manual/empty-heading-manual.js +17 -17
- package/src/checks/manual/empty-table-header-manual.js +52 -25
- package/src/checks/manual/focus-order-semantics-manual.js +16 -4
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -0
- package/src/checks/manual/heading-order-manual.js +28 -1
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -0
- package/src/checks/manual/image-redundant-alt-manual.js +21 -1
- package/src/checks/manual/img-alt-decorative-manual.js +2 -0
- package/src/checks/manual/img-alt-quality-manual.js +2 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +26 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +2 -0
- package/src/checks/manual/label-title-only-manual.js +29 -22
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +51 -55
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +44 -31
- package/src/checks/manual/landmark-main-is-top-level-manual.js +41 -24
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +16 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +14 -21
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +10 -13
- package/src/checks/manual/landmark-one-main-manual.js +12 -23
- package/src/checks/manual/landmark-unique-manual.js +37 -52
- package/src/checks/manual/link-name-quality-manual.js +2 -0
- package/src/checks/manual/media-transcript-present-manual.js +2 -0
- package/src/checks/manual/meta-viewport-large-manual.js +3 -1
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/p-as-heading-manual.js +2 -0
- package/src/checks/manual/page-has-heading-one-manual.js +12 -11
- package/src/checks/manual/page-title-patterns-manual.js +2 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +51 -37
- package/src/checks/manual/region-manual.js +27 -36
- package/src/checks/manual/scope-attr-valid-manual.js +3 -1
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -0
- package/src/checks/manual/skip-link-manual.js +7 -6
- package/src/checks/manual/svg-text-alternative-quality-manual.js +2 -0
- package/src/checks/manual/tabindex-manual.js +3 -1
- package/src/checks/manual/table-duplicate-name-manual.js +5 -4
- package/src/checks/manual/table-fake-caption-manual.js +24 -3
- package/src/checks/manual/video-caption-manual.js +2 -0
- package/src/checks/manual-review.js +2 -0
- package/src/core.js +11820 -3317
- package/src/index.js +2 -0
- package/src/report.js +51 -9
- package/src/sarif.js +20 -5
- package/surea11y.browser.js +4943 -1388
- package/bin/core.js +0 -473
- package/docs/CLI.md +0 -128
- package/src/catalogs/composites.wcag.js +0 -454
- package/src/checks/automatic/bypass-blocks-present.js +0 -215
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -1211
- package/src/core/contrast-helpers.js +0 -1302
- package/src/core/dom-helpers.js +0 -4493
- package/src/core/dom-runner.js +0 -787
- package/src/core/frame-messaging.js +0 -261
- package/src/core/frame-scan.js +0 -190
- package/src/core/rollup-composites.js +0 -127
- package/src/core/rule-meta.js +0 -176
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -1228
- package/src/i18n/fr.js +0 -1185
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -59
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'label-in-name';
|
|
@@ -50,6 +52,56 @@ function runInPage(ctx) {
|
|
|
50
52
|
return v.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
// WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
|
|
56
|
+
// parenthesised text, case-fold and NFKD-normalise, then reduce every
|
|
57
|
+
// non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
|
|
58
|
+
// splitting on them, which distinguishes a real mismatch from one that is
|
|
59
|
+
// only a hyphenation difference.
|
|
60
|
+
function tokenize(s, hyphensJoin) {
|
|
61
|
+
let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
|
|
62
|
+
try {
|
|
63
|
+
v = v.normalize('NFKD');
|
|
64
|
+
} catch {
|
|
65
|
+
// Realm without String#normalize: the word comparison below still holds.
|
|
66
|
+
}
|
|
67
|
+
if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
|
|
68
|
+
return v
|
|
69
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
70
|
+
.split(' ')
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The label's words must appear adjacent and in order inside the name, so a
|
|
75
|
+
// scattered subsequence does not count. Words in `prefixable` may match a
|
|
76
|
+
// longer name word they start.
|
|
77
|
+
function containsWordRun(needle, hay, prefixable) {
|
|
78
|
+
if (!needle.length) return true;
|
|
79
|
+
for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (let j = 0; j < needle.length; j++) {
|
|
82
|
+
const want = needle[j];
|
|
83
|
+
const got = hay[i + j];
|
|
84
|
+
if (got === want) continue;
|
|
85
|
+
if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
|
|
86
|
+
ok = false;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (ok) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A trailing period marks a word the author may have shortened ("Ave." for
|
|
95
|
+
// "Avenue"). Tokenizing removes the period, so collect these beforehand.
|
|
96
|
+
function abbreviatedWords(s) {
|
|
97
|
+
const out = new Set();
|
|
98
|
+
for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
|
|
99
|
+
const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
|
|
100
|
+
if (m) out.add(m[1].toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
53
105
|
function getElementDescriptor(el) {
|
|
54
106
|
const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
|
|
55
107
|
let role;
|
|
@@ -90,20 +142,13 @@ function runInPage(ctx) {
|
|
|
90
142
|
}
|
|
91
143
|
|
|
92
144
|
// Unlike isDomVisible above, this also excludes aria-hidden subtrees.
|
|
93
|
-
// Needed
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// (measuring rendered glyph shapes), not replicable here since this rule
|
|
101
|
-
// runs against static markup with no real canvas/font rendering
|
|
102
|
-
// available; excluding aria-hidden content is a cheaper, static-markup
|
|
103
|
-
// signal that gets the common case (decorative icon fonts) right without
|
|
104
|
-
// needing one. Confirmed via a live-DOM cross-engine run 2026-07-21
|
|
105
|
-
// (Angular Material's theme-picker button, aria-label="Select a theme",
|
|
106
|
-
// aria-hidden icon rendering literally as "format_color_fill").
|
|
145
|
+
// Needed for collectVisibleTextUnder's per-text-node check below: an
|
|
146
|
+
// aria-hidden icon-font glyph (e.g. an <i aria-hidden="true"> ligature
|
|
147
|
+
// rendering as "format_color_fill") is DOM-visible pixels but is never
|
|
148
|
+
// perceived as literal readable words the way real visible text is.
|
|
149
|
+
// Excluding aria-hidden content is a cheap static-markup signal that gets
|
|
150
|
+
// the common case (decorative icon fonts) right — an icon-only button
|
|
151
|
+
// named via aria-label shouldn't have its glyph name counted as text.
|
|
107
152
|
function isAccEligible(el) {
|
|
108
153
|
if (!el) return false;
|
|
109
154
|
const fn =
|
|
@@ -122,11 +167,12 @@ function runInPage(ctx) {
|
|
|
122
167
|
// than referencing the global NodeFilter object directly: runInPage must
|
|
123
168
|
// have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
|
|
124
169
|
// NodeFilter is not itself present in the execution realm this function
|
|
125
|
-
// actually runs in, unlike window/document.
|
|
126
|
-
// silently
|
|
127
|
-
// raw container.textContent (which respects none of
|
|
128
|
-
// isDomVisible/isAccEligible below, since that whole
|
|
129
|
-
// skipped in the fallback path). Same pattern already
|
|
170
|
+
// actually runs in, unlike window/document. Referencing the global
|
|
171
|
+
// directly would silently make createTreeWalker throw on every call,
|
|
172
|
+
// falling back to raw container.textContent (which respects none of
|
|
173
|
+
// isNonRenderedTag/isDomVisible/isAccEligible below, since that whole
|
|
174
|
+
// per-node loop is skipped in the fallback path). Same pattern already
|
|
175
|
+
// used correctly in
|
|
130
176
|
// region-manual.js's own createTreeWalker call.
|
|
131
177
|
const SHOW_TEXT = 4;
|
|
132
178
|
|
|
@@ -301,7 +347,23 @@ function runInPage(ctx) {
|
|
|
301
347
|
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
302
348
|
const accNorm = norm(accName);
|
|
303
349
|
|
|
304
|
-
const
|
|
350
|
+
const labelTokens = tokenize(visibleLabel, false);
|
|
351
|
+
const nameTokens = tokenize(accName, false);
|
|
352
|
+
const contains = containsWordRun(labelTokens, nameTokens, null);
|
|
353
|
+
|
|
354
|
+
// An abbreviation, or a word hyphenated differently in the two places, is
|
|
355
|
+
// not something markup settles: the author may have meant either. Report
|
|
356
|
+
// without asserting a defect instead of failing or staying silent.
|
|
357
|
+
let uncertainty = '';
|
|
358
|
+
if (!contains) {
|
|
359
|
+
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
360
|
+
uncertainty = 'HYPHENATION_DIFFERS';
|
|
361
|
+
} else {
|
|
362
|
+
const abbreviated = abbreviatedWords(visibleLabel);
|
|
363
|
+
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
|
|
364
|
+
uncertainty = 'POSSIBLE_ABBREVIATION';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
305
367
|
|
|
306
368
|
if (!contains) {
|
|
307
369
|
const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
@@ -312,11 +374,16 @@ function runInPage(ctx) {
|
|
|
312
374
|
occurrences.push({
|
|
313
375
|
selector: selectorOut,
|
|
314
376
|
html,
|
|
315
|
-
|
|
316
|
-
|
|
377
|
+
...(uncertainty ? { outcome: 'cantTell' } : null),
|
|
378
|
+
summary: uncertainty
|
|
379
|
+
? 'Accessible name may not contain the visible label text.'
|
|
380
|
+
: 'Accessible name does not contain the visible label text.',
|
|
381
|
+
hint: uncertainty
|
|
382
|
+
? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
|
|
383
|
+
: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
|
|
317
384
|
i18n: {
|
|
318
|
-
summaryKey: 'labelInName_summary_fail',
|
|
319
|
-
hintKey: 'labelInName_hint_fail',
|
|
385
|
+
summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
|
|
386
|
+
hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
|
|
320
387
|
params: {
|
|
321
388
|
element: getElementDescriptor(el),
|
|
322
389
|
visibleLabel: clipForSummary(visibleLabel),
|
|
@@ -326,10 +393,11 @@ function runInPage(ctx) {
|
|
|
326
393
|
},
|
|
327
394
|
data: {
|
|
328
395
|
details: {
|
|
329
|
-
reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
396
|
+
reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
330
397
|
visibleLabel,
|
|
331
398
|
accessibleName: accName,
|
|
332
399
|
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
400
|
+
tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
|
|
333
401
|
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
334
402
|
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
335
403
|
}
|
|
@@ -340,13 +408,15 @@ function runInPage(ctx) {
|
|
|
340
408
|
|
|
341
409
|
if (applicableCount === 0)
|
|
342
410
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
343
|
-
if (occurrences.length)
|
|
411
|
+
if (occurrences.length) {
|
|
412
|
+
const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
|
|
344
413
|
return {
|
|
345
414
|
ruleId: rule.ruleId,
|
|
346
|
-
outcome: 'fail',
|
|
415
|
+
outcome: anyFail ? 'fail' : 'cantTell',
|
|
347
416
|
severity: rule.defaultSeverity || 'minor',
|
|
348
417
|
occurrences
|
|
349
418
|
};
|
|
419
|
+
}
|
|
350
420
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
351
421
|
}
|
|
352
422
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -142,7 +144,11 @@ function runInPage(ctx) {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
// Minimal BCP47 primary subtag check
|
|
145
|
-
|
|
147
|
+
const isValidTag =
|
|
148
|
+
helpers && typeof helpers.isValidLanguageTag === 'function'
|
|
149
|
+
? helpers.isValidLanguageTag
|
|
150
|
+
: (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
|
|
151
|
+
if (!isValidTag(lang)) {
|
|
146
152
|
return {
|
|
147
153
|
ruleId: rule.ruleId,
|
|
148
154
|
outcome: 'fail',
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'link-name-present';
|
|
@@ -57,7 +59,14 @@ function runInPage(ctx) {
|
|
|
57
59
|
|
|
58
60
|
for (const el of nodes) {
|
|
59
61
|
// isAccTreeEligible returns { eligible, reasons }, not a boolean.
|
|
60
|
-
|
|
62
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
63
|
+
// (ACT c487ae), which excludes focusable aria-hidden content;
|
|
64
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
65
|
+
const eligResult = helpers.isIncludedInAccessibilityTree
|
|
66
|
+
? helpers.isIncludedInAccessibilityTree(el, ctx)
|
|
67
|
+
: helpers.isAccTreeEligible
|
|
68
|
+
? helpers.isAccTreeEligible(el, ctx)
|
|
69
|
+
: true;
|
|
61
70
|
const eligible =
|
|
62
71
|
typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
63
72
|
if (!eligible) continue;
|
|
@@ -67,33 +76,59 @@ function runInPage(ctx) {
|
|
|
67
76
|
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
68
77
|
const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
|
|
69
78
|
|
|
70
|
-
// A native <a href>/<area href> (or [role="link"]) whose role has been
|
|
71
|
-
// overridden to one of these roles is no longer semantically a link —
|
|
72
|
-
// per the WAI-ARIA Accessible Name and Description Computation spec
|
|
73
|
-
// these roles are name-from-author-only, and their rendered content
|
|
74
|
-
// represents a VALUE, not a NAME (see button-name-present.js's identical
|
|
75
|
-
// fix for a real-world example — <button role="combobox">List</button> —
|
|
76
|
-
// of the same class of bug on a different host tag).
|
|
77
79
|
const role = el.getAttribute ? el.getAttribute('role') : null;
|
|
78
80
|
const roleNorm = String(role || '')
|
|
79
81
|
.replace(/\s+/g, ' ')
|
|
80
82
|
.trim()
|
|
81
83
|
.toLowerCase();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'
|
|
84
|
+
// ARIA 1.2 "Name From: author, contents". Every other known role is
|
|
85
|
+
// name-from-author-only. An unknown role falls back to the implicit role.
|
|
86
|
+
// <generated:aria-name-from-content>
|
|
87
|
+
const NAME_FROM_CONTENT_ROLES = [
|
|
88
|
+
'button',
|
|
89
|
+
'cell',
|
|
90
|
+
'checkbox',
|
|
91
|
+
'columnheader',
|
|
92
|
+
'doc-backlink',
|
|
93
|
+
'doc-biblioref',
|
|
94
|
+
'doc-glossref',
|
|
95
|
+
'doc-noteref',
|
|
96
|
+
'graphics-object',
|
|
97
|
+
'gridcell',
|
|
98
|
+
'heading',
|
|
99
|
+
'link',
|
|
100
|
+
'menuitem',
|
|
101
|
+
'menuitemcheckbox',
|
|
102
|
+
'menuitemradio',
|
|
103
|
+
'option',
|
|
104
|
+
'radio',
|
|
105
|
+
'row',
|
|
106
|
+
'rowgroup',
|
|
107
|
+
'rowheader',
|
|
108
|
+
'switch',
|
|
109
|
+
'tab',
|
|
110
|
+
'tooltip',
|
|
111
|
+
'treeitem'
|
|
90
112
|
];
|
|
91
|
-
|
|
113
|
+
// </generated:aria-name-from-content>
|
|
114
|
+
const isKnownRoleToken =
|
|
115
|
+
helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
|
|
116
|
+
? (() => {
|
|
117
|
+
try {
|
|
118
|
+
return !!helpers.aria.isKnownRole(roleNorm);
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
})()
|
|
123
|
+
: false;
|
|
124
|
+
const isContentNameCandidate =
|
|
125
|
+
!roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm);
|
|
92
126
|
|
|
93
127
|
const contentName =
|
|
94
128
|
programmaticName.trim().length === 0 && isContentNameCandidate
|
|
95
129
|
? getConservativeSubtreeText(el)
|
|
96
130
|
: '';
|
|
131
|
+
|
|
97
132
|
const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
|
|
98
133
|
|
|
99
134
|
if (finalName.length === 0) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -24,28 +26,17 @@
|
|
|
24
26
|
* inverse relationship: does a given <li> have a valid parent).
|
|
25
27
|
* - Direct children that are not exposed to the accessibility tree (e.g.
|
|
26
28
|
* display:none, [hidden], aria-hidden="true") are excluded from
|
|
27
|
-
* consideration entirely —
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* DIFFERENT function from the similarly-named, unused `onlyListitemsEvaluate`
|
|
39
|
-
* elsewhere in that engine's source; don't confuse the two when re-verifying). Its
|
|
40
|
-
* exact rule: if a child has an explicit role, only `validRoles` (here,
|
|
41
|
-
* `['listitem']`) is consulted — the tag name is never checked. Only
|
|
42
|
-
* without an explicit role does the tag name (`validNodeNames`) matter.
|
|
43
|
-
* Found via two real sites: Notion's footer nav lists include a
|
|
44
|
-
* `<li role="none">` used purely to host the list's own visually-hidden
|
|
45
|
-
* label (referenced by the `<ul>`'s `aria-labelledby`), and LinkedIn has
|
|
46
|
-
* both an all-`<li role="menuitem">` menu list and a footer list mixing
|
|
47
|
-
* real `<li>`s with one `<li role="presentation">` — that reference engine
|
|
48
|
-
* correctly flags all of these, surea11y's tag-only check missed them entirely.
|
|
29
|
+
* consideration entirely — an element not reachable by assistive
|
|
30
|
+
* technology can't break the list semantics a screen reader announces.
|
|
31
|
+
* Common cases: a stray `<input type="hidden">` as a direct <ul> child
|
|
32
|
+
* (UA-stylesheet display:none by spec), or `<span style="display:none">`
|
|
33
|
+
* hydration markers interleaved with real `<li>`s.
|
|
34
|
+
* - Explicit-role-overrides-tag: if a child has an explicit role, only
|
|
35
|
+
* `['listitem']` is consulted — the tag name is never checked. Only
|
|
36
|
+
* without an explicit role does the tag name matter. Catches cases a
|
|
37
|
+
* tag-only check misses: `<li role="none">` hosting a list's own
|
|
38
|
+
* visually-hidden label, `<li role="menuitem">` menu items, or a real
|
|
39
|
+
* `<li>` mixed with an `<li role="presentation">`.
|
|
49
40
|
*/
|
|
50
41
|
|
|
51
42
|
const id = 'list-children-valid';
|
|
@@ -120,8 +111,7 @@ function runInPage(ctx) {
|
|
|
120
111
|
const roleAttr = child.getAttribute ? String(child.getAttribute('role') || '').trim() : '';
|
|
121
112
|
const explicitRole = roleAttr ? (roleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
|
|
122
113
|
|
|
123
|
-
// An explicit role always wins over the tag — see
|
|
124
|
-
// comment's Notion/LinkedIn examples.
|
|
114
|
+
// An explicit role always wins over the tag — see header comment.
|
|
125
115
|
const valid = explicitRole ? explicitRole === 'listitem' : ALLOWED_CHILD_TAGS.has(tag);
|
|
126
116
|
|
|
127
117
|
if (!valid) invalidTags.push(tag);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'listbox-name-present';
|
|
@@ -107,9 +109,16 @@ function runInPage(ctx) {
|
|
|
107
109
|
return '';
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
113
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
114
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
110
115
|
function isEligibleAcc(helpers, el, ctx) {
|
|
111
116
|
const fn =
|
|
112
|
-
helpers && typeof helpers.
|
|
117
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
118
|
+
? helpers.isIncludedInAccessibilityTree
|
|
119
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
120
|
+
? helpers.isAccTreeEligible
|
|
121
|
+
: null;
|
|
113
122
|
if (!fn) return true;
|
|
114
123
|
try {
|
|
115
124
|
const r = fn(el, ctx);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -25,12 +27,19 @@
|
|
|
25
27
|
* native role is fully replaced by the explicit one — the same "any
|
|
26
28
|
* explicit role overrides the element's native role" ARIA principle
|
|
27
29
|
* applied elsewhere in this engine), so an <li> inside it is invalid
|
|
28
|
-
* despite the <ul> tag
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* despite the <ul> tag (e.g. `<ul role="menu"><li>...`). Conversely
|
|
31
|
+
* role="presentation"/"none" on the parent is still a valid
|
|
32
|
+
* (list-semantics-suppressing) parent — the accepted parent roles are
|
|
33
|
+
* presentation, none, and list.
|
|
34
|
+
* - The SAME "explicit role wins" principle applies to the <li> ELEMENT
|
|
35
|
+
* ITSELF: an <li role="tab">/role="menuitem">/role="presentation"> etc.
|
|
36
|
+
* is exposed to AT with that role, never "listitem" — the whole point
|
|
37
|
+
* of this check (list items need a valid list-container parent) doesn't
|
|
38
|
+
* apply when the element isn't claiming listitem semantics in the first
|
|
39
|
+
* place. Any `<li>` with an explicit `role` attribute is excluded from
|
|
40
|
+
* candidacy. `role="listitem"` itself is a no-op restatement (not an
|
|
41
|
+
* override), so it still falls through to the normal parent-validity
|
|
42
|
+
* check below.
|
|
34
43
|
*/
|
|
35
44
|
|
|
36
45
|
const id = 'listitem-parent-valid';
|
|
@@ -75,6 +84,20 @@ function runInPage(ctx) {
|
|
|
75
84
|
const parent = el.parentElement;
|
|
76
85
|
if (!parent) continue;
|
|
77
86
|
|
|
87
|
+
// An explicit role on the <li> ITSELF overrides its native "listitem"
|
|
88
|
+
// role entirely, the same "any explicit role wins over the tag's
|
|
89
|
+
// native role" principle this check already applies to the PARENT
|
|
90
|
+
// (see the header comment). An <li role="tab">/
|
|
91
|
+
// role="menuitem">/role="presentation"> etc. is exposed to AT with
|
|
92
|
+
// THAT role, never "listitem" -- so the "list item needs a valid list
|
|
93
|
+
// container parent" concern this rule exists for doesn't apply to it
|
|
94
|
+
// at all; there's no listitem semantics being claimed to validate.
|
|
95
|
+
// role="listitem" itself is a no-op restatement, not an override, so
|
|
96
|
+
// it still falls through to the normal parent check below.
|
|
97
|
+
const ownRoleAttr = el.getAttribute ? String(el.getAttribute('role') || '').trim() : '';
|
|
98
|
+
const ownExplicitRole = ownRoleAttr ? (ownRoleAttr.split(/\s+/)[0] || '').toLowerCase() : '';
|
|
99
|
+
if (ownExplicitRole && ownExplicitRole !== 'listitem') continue;
|
|
100
|
+
|
|
78
101
|
applicableCount += 1;
|
|
79
102
|
|
|
80
103
|
const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
|
|
@@ -85,7 +108,7 @@ function runInPage(ctx) {
|
|
|
85
108
|
let valid;
|
|
86
109
|
if (explicitRole) {
|
|
87
110
|
// An explicit role always wins over the tag's native role, in either
|
|
88
|
-
// direction — see the header comment
|
|
111
|
+
// direction — see the header comment.
|
|
89
112
|
valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
|
|
90
113
|
} else {
|
|
91
114
|
valid = parentTag === 'ul' || parentTag === 'ol';
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
const id = 'menuitem-name-present';
|
|
@@ -82,9 +84,16 @@ function runInPage(ctx) {
|
|
|
82
84
|
return '';
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Naming rules apply only to elements included in the accessibility tree
|
|
88
|
+
// (ACT c487ae and siblings), which excludes focusable aria-hidden content.
|
|
89
|
+
// aria-hidden-focus (ACT 6cfa84) covers that markup instead.
|
|
85
90
|
function isEligibleAcc(helpers, el, ctx) {
|
|
86
91
|
const fn =
|
|
87
|
-
helpers && typeof helpers.
|
|
92
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
93
|
+
? helpers.isIncludedInAccessibilityTree
|
|
94
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
95
|
+
? helpers.isAccTreeEligible
|
|
96
|
+
: null;
|
|
88
97
|
if (!fn) return true;
|
|
89
98
|
try {
|
|
90
99
|
const r = fn(el, ctx);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -24,10 +26,8 @@
|
|
|
24
26
|
* - A <meta> nested inside <noscript> is excluded: it only ever takes
|
|
25
27
|
* effect when scripting is disabled, which is never the case for any
|
|
26
28
|
* context capable of running accessibility tooling in the first place
|
|
27
|
-
* (
|
|
28
|
-
*
|
|
29
|
-
* URL=/?nojsmode=1"></noscript> JS-disabled fallback — a widely-used
|
|
30
|
-
* reference engine's own same-named check correctly doesn't flag it either).
|
|
29
|
+
* (e.g. a <noscript><meta http-equiv="refresh" content="0; URL=/?nojs=1">
|
|
30
|
+
* </noscript> JS-disabled fallback).
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const id = 'meta-refresh-no-exceptions';
|
|
@@ -87,6 +87,34 @@ function runInPage(ctx) {
|
|
|
87
87
|
: [];
|
|
88
88
|
|
|
89
89
|
const occurrences = [];
|
|
90
|
+
// HTML's shared declarative refresh steps. Returns the delay in seconds, or
|
|
91
|
+
// null when the value is not a valid refresh directive, in which case the
|
|
92
|
+
// browser never refreshes and there is nothing to report. Rejects a leading
|
|
93
|
+
// sign ("+72001"), a non-numeric time ("foo"), and a separator other than
|
|
94
|
+
// "," or ";" ("0:1").
|
|
95
|
+
function parseRefreshDelay(input) {
|
|
96
|
+
const s = String(input == null ? '' : input);
|
|
97
|
+
const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
98
|
+
let i = 0;
|
|
99
|
+
while (i < s.length && isSpace(s[i])) i++;
|
|
100
|
+
let digits = '';
|
|
101
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
|
|
102
|
+
if (!digits) return null;
|
|
103
|
+
if (s[i] === '.') {
|
|
104
|
+
i++;
|
|
105
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
|
|
106
|
+
}
|
|
107
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
108
|
+
let sawSpace = false;
|
|
109
|
+
while (i < s.length && isSpace(s[i])) {
|
|
110
|
+
sawSpace = true;
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
114
|
+
if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
let applicableCount = 0;
|
|
91
119
|
|
|
92
120
|
for (const el of nodes) {
|
|
@@ -94,6 +122,7 @@ function runInPage(ctx) {
|
|
|
94
122
|
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
|
|
95
123
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
96
124
|
if (!raw) continue;
|
|
125
|
+
if (parseRefreshDelay(raw) === null) continue;
|
|
97
126
|
|
|
98
127
|
applicableCount += 1;
|
|
99
128
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -77,16 +79,42 @@ function runInPage(ctx) {
|
|
|
77
79
|
const occurrences = [];
|
|
78
80
|
let applicableCount = 0;
|
|
79
81
|
|
|
82
|
+
// HTML's shared declarative refresh steps. Returns the delay in seconds, or
|
|
83
|
+
// null when the value is not a valid refresh directive, in which case the
|
|
84
|
+
// browser never refreshes and there is nothing to report. Rejects a leading
|
|
85
|
+
// sign ("+72001"), a non-numeric time ("foo"), and a separator other than
|
|
86
|
+
// "," or ";" ("0:1").
|
|
87
|
+
function parseRefreshDelay(input) {
|
|
88
|
+
const s = String(input == null ? '' : input);
|
|
89
|
+
const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
90
|
+
let i = 0;
|
|
91
|
+
while (i < s.length && isSpace(s[i])) i++;
|
|
92
|
+
let digits = '';
|
|
93
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
|
|
94
|
+
if (!digits) return null;
|
|
95
|
+
if (s[i] === '.') {
|
|
96
|
+
i++;
|
|
97
|
+
while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
|
|
98
|
+
}
|
|
99
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
100
|
+
let sawSpace = false;
|
|
101
|
+
while (i < s.length && isSpace(s[i])) {
|
|
102
|
+
sawSpace = true;
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
if (i >= s.length) return parseInt(digits, 10);
|
|
106
|
+
if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
80
110
|
for (const el of nodes) {
|
|
81
111
|
if (!el || !el.getAttribute) continue;
|
|
82
112
|
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
|
|
83
113
|
const raw = String(el.getAttribute('content') || '').trim();
|
|
84
114
|
if (!raw) continue;
|
|
85
115
|
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
const delay = parseFloat(match[1]);
|
|
89
|
-
if (Number.isNaN(delay)) continue;
|
|
116
|
+
const delay = parseRefreshDelay(raw);
|
|
117
|
+
if (delay === null) continue;
|
|
90
118
|
|
|
91
119
|
applicableCount += 1;
|
|
92
120
|
|