@surea11y/core 1.0.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 +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check inputImage-alt-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <input type="image"> elements must have an alt attribute
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <input type="image"> elements that are exposed to assistive technologies.
|
|
11
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
12
|
+
* if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
13
|
+
* @expectation
|
|
14
|
+
* Each applicable <input type="image"> element has an alt attribute.
|
|
15
|
+
* The alt attribute may be empty (alt="").
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const id = 'input-image-alt-present';
|
|
19
|
+
|
|
20
|
+
const meta = {
|
|
21
|
+
title: '<input type="image"> must have an alt attribute',
|
|
22
|
+
description:
|
|
23
|
+
'Checks that <input type="image"> elements provide an alt attribute to support a text alternative mechanism.',
|
|
24
|
+
i18n: {
|
|
25
|
+
titleKey: 'inputImage_altPresent_title',
|
|
26
|
+
descriptionKey: 'inputImage_altPresent_description'
|
|
27
|
+
},
|
|
28
|
+
helpUrl: null,
|
|
29
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'atomic', 'automatic'],
|
|
30
|
+
wcagSc: ['1.1.1'],
|
|
31
|
+
normativeMappings: [
|
|
32
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
33
|
+
],
|
|
34
|
+
defaultSeverity: 'serious',
|
|
35
|
+
category: 'perceivable',
|
|
36
|
+
type: 'automatic',
|
|
37
|
+
defaultConfidence: 'high',
|
|
38
|
+
coverage: {
|
|
39
|
+
facetsBySc: {
|
|
40
|
+
'1.1.1': ['input-image-alt-attr-present']
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function runInPage(ctx) {
|
|
46
|
+
const { document, root, helpers, rule } = ctx;
|
|
47
|
+
const safeRoot = root || document;
|
|
48
|
+
|
|
49
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
50
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
51
|
+
? helpers.queryAll
|
|
52
|
+
: (sel) => {
|
|
53
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
54
|
+
catch { return []; }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
58
|
+
? helpers.getEligibilityInfo
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
62
|
+
? helpers.isAccTreeEligible
|
|
63
|
+
: null;
|
|
64
|
+
|
|
65
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
|
|
66
|
+
? helpers.getAriaNameInfo
|
|
67
|
+
: null;
|
|
68
|
+
|
|
69
|
+
const inputs = (() => {
|
|
70
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
|
|
71
|
+
catch { return queryAll('input[type="image"]'); }
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
if (!inputs.length) {
|
|
75
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const occurrences = [];
|
|
79
|
+
let applicableCount = 0;
|
|
80
|
+
|
|
81
|
+
for (const el of inputs) {
|
|
82
|
+
if (!el || !el.getAttribute) continue;
|
|
83
|
+
|
|
84
|
+
// Applicability: eligible in the acc tree (with helper exceptions).
|
|
85
|
+
if (isAccTreeEligible) {
|
|
86
|
+
const elig = (() => {
|
|
87
|
+
try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
88
|
+
})();
|
|
89
|
+
if (elig && elig.eligible === false) continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
applicableCount += 1;
|
|
93
|
+
|
|
94
|
+
const hasAlt = el.getAttribute('alt') !== null;
|
|
95
|
+
if (hasAlt) continue;
|
|
96
|
+
|
|
97
|
+
// aria-label / aria-labelledby is also a valid, standards-recognized
|
|
98
|
+
// text-alternative mechanism for <input type="image"> (HTML-AAM
|
|
99
|
+
// accessible name computation includes ARIA naming before falling
|
|
100
|
+
// back to alt).
|
|
101
|
+
if (getAriaNameInfo) {
|
|
102
|
+
let ariaName = null;
|
|
103
|
+
try {
|
|
104
|
+
ariaName = getAriaNameInfo(el, ctx);
|
|
105
|
+
} catch {
|
|
106
|
+
ariaName = null;
|
|
107
|
+
}
|
|
108
|
+
if (ariaName && ariaName.present) continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
112
|
+
// source once alt is entirely absent -- also accepted by a widely-used
|
|
113
|
+
// reference engine's equivalent input-image-alt rule (non-empty-title, same "any" list
|
|
114
|
+
// as non-empty-alt/aria-label/aria-labelledby). See img-alt-present's
|
|
115
|
+
// sibling fix (2026-07-23, AliExpress's title-only logo <img>) for
|
|
116
|
+
// the real page this was found via -- same gap, same fix, different
|
|
117
|
+
// element.
|
|
118
|
+
const titleRaw = (() => { try { return el.getAttribute('title'); } catch { return null; } })();
|
|
119
|
+
if (titleRaw !== null && String(titleRaw).trim()) continue;
|
|
120
|
+
|
|
121
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
122
|
+
|
|
123
|
+
const baseOccurrence = {
|
|
124
|
+
summary: 'Missing alt attribute on <input type="image">.',
|
|
125
|
+
hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
|
|
126
|
+
i18n: {
|
|
127
|
+
summaryKey: 'inputImage_altPresent_summary_fail',
|
|
128
|
+
hintKey: 'inputImage_altPresent_hint_fail',
|
|
129
|
+
params: { element: 'input[type=image]' }
|
|
130
|
+
},
|
|
131
|
+
data: {
|
|
132
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
137
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
138
|
+
} else {
|
|
139
|
+
// Never compute selector/snippet in the rule.
|
|
140
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (applicableCount === 0) {
|
|
145
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!occurrences.length) {
|
|
149
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'label-in-name';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Label in Name: accessible name contains visible text',
|
|
7
|
+
description:
|
|
8
|
+
'Checks that when a control has a visible text label, the accessible name (from aria-label/aria-labelledby) contains that visible label text (WCAG 2.5.3).',
|
|
9
|
+
i18n: {
|
|
10
|
+
titleKey: 'labelInName_title',
|
|
11
|
+
descriptionKey: 'labelInName_description'
|
|
12
|
+
},
|
|
13
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
|
|
14
|
+
tags: ['wcag21a', 'wcag253', 'forms', 'atomic', 'automatic'],
|
|
15
|
+
wcagSc: ['2.5.3'],
|
|
16
|
+
normativeMappings: [
|
|
17
|
+
{
|
|
18
|
+
standard: 'WCAG',
|
|
19
|
+
version: '2.2',
|
|
20
|
+
requirement: '2.5.3',
|
|
21
|
+
title: 'Label in Name',
|
|
22
|
+
conformanceLevel: 'A'
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
defaultSeverity: 'serious',
|
|
26
|
+
category: 'operable',
|
|
27
|
+
type: 'automatic',
|
|
28
|
+
defaultConfidence: 'high',
|
|
29
|
+
coverage: { facetsBySc: { '2.5.3': ['label-in-name'] } }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function runInPage(ctx) {
|
|
33
|
+
const { document, root, helpers, rule } = ctx;
|
|
34
|
+
const safeRoot = root || document;
|
|
35
|
+
|
|
36
|
+
const occurrences = [];
|
|
37
|
+
let applicableCount = 0;
|
|
38
|
+
|
|
39
|
+
// Applicability: focus/activation controls with explicit ARIA naming.
|
|
40
|
+
// NOTE: aria-hidden is intentionally NOT excluded here; it does not affect visual rendering.
|
|
41
|
+
const selector = ':is(button, a[href], summary, input:not([type="hidden"]), textarea, select, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="searchbox"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"], [role="gridcell"]):not([hidden]):not([disabled]):not([aria-disabled="true"]):is([aria-label], [aria-labelledby])';
|
|
42
|
+
|
|
43
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
44
|
+
|
|
45
|
+
function norm(s) {
|
|
46
|
+
const v = (s == null ? '' : String(s));
|
|
47
|
+
// deterministic normalization: trim + collapse whitespace + case-fold
|
|
48
|
+
return v.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getElementDescriptor(el) {
|
|
52
|
+
const tag = (el && el.tagName ? String(el.tagName).toLowerCase() : 'element');
|
|
53
|
+
let role = '';
|
|
54
|
+
try { role = el && el.getAttribute ? (el.getAttribute('role') || '') : ''; } catch { role = ''; }
|
|
55
|
+
const r = String(role || '').trim();
|
|
56
|
+
return r ? `${tag}[role="${r}"]` : tag;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clipForSummary(s) {
|
|
60
|
+
const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim();
|
|
61
|
+
if (v.length <= 80) return v;
|
|
62
|
+
return v.slice(0, 77) + '...';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isNonRenderedTag(el) {
|
|
66
|
+
const tn = (el && el.tagName ? String(el.tagName).toLowerCase() : '');
|
|
67
|
+
return tn === 'script' || tn === 'style' || tn === 'template' || tn === 'noscript' || tn === 'meta' || tn === 'link';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isDomVisible(el) {
|
|
71
|
+
if (!el) return false;
|
|
72
|
+
if (helpers.isDomVisibleEligible) return !!helpers.isDomVisibleEligible(el, ctx, { targetSet: 'dom' }).eligible;
|
|
73
|
+
if (helpers.getEligibilityInfo) return !!helpers.getEligibilityInfo(el, ctx, { targetSet: 'dom' }).eligible;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Unlike isDomVisible above, this also excludes aria-hidden subtrees.
|
|
78
|
+
// Needed specifically for collectVisibleTextUnder's per-text-node check
|
|
79
|
+
// below: an aria-hidden icon-font glyph (e.g. Material Icons' ligature
|
|
80
|
+
// pattern, <mat-icon aria-hidden="true">format_color_fill</mat-icon>) is
|
|
81
|
+
// technically DOM-visible pixels, but is never perceived by a sighted or
|
|
82
|
+
// voice-control user as literal readable words the way real visible text
|
|
83
|
+
// is — a widely-used reference engine's own label-content-name-mismatch check reaches the same
|
|
84
|
+
// practical outcome via a canvas-based ligature-detection heuristic
|
|
85
|
+
// (measuring rendered glyph shapes), not replicable here since this rule
|
|
86
|
+
// runs against static markup with no real canvas/font rendering
|
|
87
|
+
// available; excluding aria-hidden content is a cheaper, static-markup
|
|
88
|
+
// signal that gets the common case (decorative icon fonts) right without
|
|
89
|
+
// needing one. Confirmed via a live-DOM cross-engine run 2026-07-21
|
|
90
|
+
// (Angular Material's theme-picker button, aria-label="Select a theme",
|
|
91
|
+
// aria-hidden icon rendering literally as "format_color_fill").
|
|
92
|
+
function isAccEligible(el) {
|
|
93
|
+
if (!el) return false;
|
|
94
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
95
|
+
if (!fn) return isDomVisible(el);
|
|
96
|
+
try {
|
|
97
|
+
const r = fn(el, ctx);
|
|
98
|
+
if (typeof r === 'boolean') return r;
|
|
99
|
+
return !!(r && r.eligible);
|
|
100
|
+
} catch {
|
|
101
|
+
return isDomVisible(el);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// DOM's NodeFilter.SHOW_TEXT constant, inlined as a numeric literal rather
|
|
106
|
+
// than referencing the global NodeFilter object directly: runInPage must
|
|
107
|
+
// have zero free vars (see docs/RULE_AUTHORING.md's free-var footgun) and
|
|
108
|
+
// NodeFilter is not itself present in the execution realm this function
|
|
109
|
+
// actually runs in, unlike window/document. Confirmed 2026-07-21 — this
|
|
110
|
+
// silently made createTreeWalker throw on every call, falling back to
|
|
111
|
+
// raw container.textContent (which respects none of isNonRenderedTag/
|
|
112
|
+
// isDomVisible/isAccEligible below, since that whole per-node loop is
|
|
113
|
+
// skipped in the fallback path). Same pattern already used correctly in
|
|
114
|
+
// region-manual.js's own createTreeWalker call.
|
|
115
|
+
const SHOW_TEXT = 4;
|
|
116
|
+
|
|
117
|
+
function collectVisibleTextUnder(container) {
|
|
118
|
+
if (!container) return '';
|
|
119
|
+
if (!isDomVisible(container)) return '';
|
|
120
|
+
|
|
121
|
+
// TreeWalker is deterministic in document order.
|
|
122
|
+
let walker = null;
|
|
123
|
+
try {
|
|
124
|
+
walker = document.createTreeWalker(container, SHOW_TEXT, null);
|
|
125
|
+
} catch {
|
|
126
|
+
walker = null;
|
|
127
|
+
}
|
|
128
|
+
if (!walker) {
|
|
129
|
+
try {
|
|
130
|
+
const t = container.textContent;
|
|
131
|
+
return t == null ? '' : String(t);
|
|
132
|
+
} catch {
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parts = [];
|
|
138
|
+
let n = null;
|
|
139
|
+
// eslint-disable-next-line no-cond-assign
|
|
140
|
+
while ((n = walker.nextNode())) {
|
|
141
|
+
try {
|
|
142
|
+
const raw = n && n.nodeValue != null ? String(n.nodeValue) : '';
|
|
143
|
+
const t = raw.replace(/\s+/g, ' ').trim();
|
|
144
|
+
if (!t) continue;
|
|
145
|
+
|
|
146
|
+
const p = n.parentElement || null;
|
|
147
|
+
if (!p || !p.tagName) continue;
|
|
148
|
+
if (isNonRenderedTag(p)) continue;
|
|
149
|
+
|
|
150
|
+
// Require the parent element to be visually eligible AND not inside
|
|
151
|
+
// an aria-hidden subtree (see isAccEligible's docblock above).
|
|
152
|
+
if (!isAccEligible(p)) continue;
|
|
153
|
+
|
|
154
|
+
parts.push(t);
|
|
155
|
+
} catch {
|
|
156
|
+
// no-throws
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function getAssociatedLabelElements(control) {
|
|
163
|
+
const labels = [];
|
|
164
|
+
try {
|
|
165
|
+
if (control && control.labels && typeof control.labels.length === 'number') {
|
|
166
|
+
for (const l of control.labels) labels.push(l);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
// ignore
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fallback: wrapped label
|
|
173
|
+
try {
|
|
174
|
+
const w = control && control.closest ? control.closest('label') : null;
|
|
175
|
+
if (w) labels.push(w);
|
|
176
|
+
} catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Fallback: label[for=id]. Uses `document` directly rather than
|
|
181
|
+
// ctx.root/safeRoot -- label[for] association is a document-wide
|
|
182
|
+
// relationship (IDs are document-unique), not bounded by whatever
|
|
183
|
+
// contextSelector region happens to be scanned, and ctx.root is an
|
|
184
|
+
// array (multi-region contextSelector support), not a single element
|
|
185
|
+
// with its own .querySelector to call directly.
|
|
186
|
+
try {
|
|
187
|
+
const idAttribute = control && control.getAttribute ? (control.getAttribute('id') || '') : '';
|
|
188
|
+
const key = String(idAttribute || '').trim();
|
|
189
|
+
if (key && document && document.querySelector) {
|
|
190
|
+
const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
|
|
191
|
+
if (l) labels.push(l);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// ignore
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// De-dupe in document order
|
|
198
|
+
const seen = new Set();
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const l of labels) {
|
|
201
|
+
try {
|
|
202
|
+
if (!l || !l.tagName) continue;
|
|
203
|
+
if (seen.has(l)) continue;
|
|
204
|
+
seen.add(l);
|
|
205
|
+
out.push(l);
|
|
206
|
+
} catch {
|
|
207
|
+
// ignore
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getVisibleTextLabelInfo(el) {
|
|
214
|
+
// Returns { text, source } where source helps reporting.
|
|
215
|
+
// Source policy (deterministic):
|
|
216
|
+
// 1) <label> association for form controls
|
|
217
|
+
// 2) visible text inside the element
|
|
218
|
+
// 3) visible text from aria-labelledby referenced elements
|
|
219
|
+
let text = '';
|
|
220
|
+
let source = 'none';
|
|
221
|
+
|
|
222
|
+
const tn = (el && el.tagName ? String(el.tagName).toLowerCase() : '');
|
|
223
|
+
|
|
224
|
+
// 1) Label association (native form controls)
|
|
225
|
+
const isFormControl = tn === 'input' || tn === 'select' || tn === 'textarea';
|
|
226
|
+
if (isFormControl) {
|
|
227
|
+
const labels = getAssociatedLabelElements(el);
|
|
228
|
+
const labelParts = [];
|
|
229
|
+
for (const l of labels) {
|
|
230
|
+
if (!l || !isDomVisible(l)) continue;
|
|
231
|
+
const t = collectVisibleTextUnder(l);
|
|
232
|
+
if (t) labelParts.push(t);
|
|
233
|
+
}
|
|
234
|
+
const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
|
|
235
|
+
if (joined) return { text: joined, source: 'label' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 2) Text inside the control itself
|
|
239
|
+
text = collectVisibleTextUnder(el);
|
|
240
|
+
if (text) return { text, source: 'self' };
|
|
241
|
+
|
|
242
|
+
// 3) aria-labelledby referenced visible text (only if refs exist and are visible)
|
|
243
|
+
try {
|
|
244
|
+
const idrefs = el && el.getAttribute ? el.getAttribute('aria-labelledby') : null;
|
|
245
|
+
if (idrefs && helpers.resolveIdRefs) {
|
|
246
|
+
const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
|
|
247
|
+
const parts = [];
|
|
248
|
+
for (const ref of (r && Array.isArray(r.refs) ? r.refs : [])) {
|
|
249
|
+
if (!ref || !ref.tagName) continue;
|
|
250
|
+
if (!isDomVisible(ref)) continue;
|
|
251
|
+
const t = collectVisibleTextUnder(ref);
|
|
252
|
+
if (t) parts.push(t);
|
|
253
|
+
}
|
|
254
|
+
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
255
|
+
if (joined) return { text: joined, source: 'aria-labelledby' };
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
// ignore
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { text: '', source };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const el of nodes) {
|
|
265
|
+
// Gate: control must be visually rendered (SC is about visual labels).
|
|
266
|
+
if (!isDomVisible(el)) continue;
|
|
267
|
+
|
|
268
|
+
const labelInfo = getVisibleTextLabelInfo(el);
|
|
269
|
+
const visibleLabel = labelInfo && labelInfo.text ? String(labelInfo.text) : '';
|
|
270
|
+
const visibleNorm = norm(visibleLabel);
|
|
271
|
+
|
|
272
|
+
// Applicability: only when we can deterministically extract visible label text.
|
|
273
|
+
if (!visibleNorm) continue;
|
|
274
|
+
|
|
275
|
+
applicableCount += 1;
|
|
276
|
+
|
|
277
|
+
let acc = { present: false, value: '', mechanism: 'none', flags: [] };
|
|
278
|
+
try {
|
|
279
|
+
acc = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 }) : acc;
|
|
280
|
+
} catch {
|
|
281
|
+
acc = { present: false, value: '', mechanism: 'none', flags: ['exception'] };
|
|
282
|
+
}
|
|
283
|
+
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
284
|
+
const accNorm = norm(accName);
|
|
285
|
+
|
|
286
|
+
const contains = !!(accNorm && visibleNorm && accNorm.indexOf(visibleNorm) !== -1);
|
|
287
|
+
|
|
288
|
+
if (!contains) {
|
|
289
|
+
const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
290
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
291
|
+
|
|
292
|
+
occurrences.push({
|
|
293
|
+
selector: selectorOut,
|
|
294
|
+
html,
|
|
295
|
+
summary: 'Accessible name does not contain the visible label text.',
|
|
296
|
+
hint: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
|
|
297
|
+
i18n: {
|
|
298
|
+
summaryKey: 'labelInName_summary_fail',
|
|
299
|
+
hintKey: 'labelInName_hint_fail',
|
|
300
|
+
params: {
|
|
301
|
+
element: getElementDescriptor(el),
|
|
302
|
+
visibleLabel: clipForSummary(visibleLabel),
|
|
303
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
304
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
data: {
|
|
308
|
+
details: {
|
|
309
|
+
reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
310
|
+
visibleLabel,
|
|
311
|
+
accessibleName: accName,
|
|
312
|
+
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
313
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
314
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
322
|
+
if (occurrences.length) return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
323
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check html-lang-attr-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The default language of the page must be programmatically declared
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 3.1.1
|
|
9
|
+
*
|
|
10
|
+
* @applicability
|
|
11
|
+
* Applies to HTML documents with a root <html> element.
|
|
12
|
+
* The rule evaluates the document element only and does not iterate over child nodes.
|
|
13
|
+
* Non-HTML documents or documents without a document element are not applicable.
|
|
14
|
+
*
|
|
15
|
+
* @expectation
|
|
16
|
+
* The <html> element has a lang attribute.
|
|
17
|
+
* The lang attribute is not empty and contains a syntactically valid language tag.
|
|
18
|
+
*
|
|
19
|
+
* @outcomes
|
|
20
|
+
* Pass:
|
|
21
|
+
* The <html> element has a non-empty lang attribute with a valid language tag.
|
|
22
|
+
*
|
|
23
|
+
* Fail:
|
|
24
|
+
* The <html> element does not have a lang attribute.
|
|
25
|
+
* The <html> element has a lang attribute that is present but empty or whitespace.
|
|
26
|
+
* The <html> element has a lang attribute with an invalid language tag.
|
|
27
|
+
*
|
|
28
|
+
* @notes
|
|
29
|
+
* This rule does not verify that the declared language matches the actual language
|
|
30
|
+
* of the page content. Language correctness is out of scope for SC 3.1.1.
|
|
31
|
+
* Changes of language within the page are covered by SC 3.1.2.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'html-lang-attr-present';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Page language is declared',
|
|
38
|
+
description: 'Checks that the default language of the page is programmatically declared.',
|
|
39
|
+
i18n: {
|
|
40
|
+
titleKey: 'html_lang_attr_title',
|
|
41
|
+
descriptionKey: 'html_lang_attr_description'
|
|
42
|
+
},
|
|
43
|
+
helpUrl: null,
|
|
44
|
+
tags: ['wcag2a', 'wcag311', 'structure', 'language', 'automatic', 'atomic'],
|
|
45
|
+
wcagSc: ['3.1.1'],
|
|
46
|
+
normativeMappings: [
|
|
47
|
+
{
|
|
48
|
+
standard: 'WCAG',
|
|
49
|
+
version: '2.2',
|
|
50
|
+
requirement: '3.1.1',
|
|
51
|
+
title: 'Language of Page',
|
|
52
|
+
conformanceLevel: 'A'
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
defaultSeverity: 'serious',
|
|
56
|
+
category: 'understandable',
|
|
57
|
+
type: 'automatic',
|
|
58
|
+
defaultConfidence: 'high',
|
|
59
|
+
coverage: {
|
|
60
|
+
facetsBySc: {
|
|
61
|
+
'3.1.1': ['html-lang-attr-present']
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function runInPage(ctx) {
|
|
67
|
+
const { document, rule, helpers } = ctx;
|
|
68
|
+
const html = document && document.documentElement;
|
|
69
|
+
|
|
70
|
+
const tag = (html && html.tagName) ? String(html.tagName).toLowerCase() : '';
|
|
71
|
+
if (!html || tag !== 'html') {
|
|
72
|
+
return {
|
|
73
|
+
ruleId: rule.ruleId,
|
|
74
|
+
outcome: 'notApplicable',
|
|
75
|
+
severity: 'minor',
|
|
76
|
+
occurrences: []
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const visibilityFilter = { targetSet: 'acc', accEligible: true, reasons: [] };
|
|
81
|
+
|
|
82
|
+
function pushFail(summaryKey, hintKey, params, details) {
|
|
83
|
+
const baseOccurrence = {
|
|
84
|
+
summary: '',
|
|
85
|
+
hint: '',
|
|
86
|
+
i18n: {
|
|
87
|
+
summaryKey,
|
|
88
|
+
hintKey,
|
|
89
|
+
params: params && typeof params === 'object' ? params : {}
|
|
90
|
+
},
|
|
91
|
+
data: {
|
|
92
|
+
visibilityFilter,
|
|
93
|
+
details: details && typeof details === 'object' ? details : {}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
98
|
+
return [helpers.reportOccurrence(html, baseOccurrence)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Never compute selector/snippet in the rule.
|
|
102
|
+
return [{ selector: '', html: '', ...baseOccurrence }];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rawLang = html.getAttribute('lang'); // null if missing
|
|
106
|
+
const lang = (rawLang || '').trim();
|
|
107
|
+
|
|
108
|
+
if (rawLang === null) {
|
|
109
|
+
return {
|
|
110
|
+
ruleId: rule.ruleId,
|
|
111
|
+
outcome: 'fail',
|
|
112
|
+
severity: rule.defaultSeverity,
|
|
113
|
+
occurrences: pushFail(
|
|
114
|
+
'html_lang_attr_missing_absent',
|
|
115
|
+
'html_lang_attr_hint_missing_absent',
|
|
116
|
+
{},
|
|
117
|
+
{ reasonCode: 'lang-missing', location: 'html' }
|
|
118
|
+
)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (lang.length === 0) {
|
|
123
|
+
return {
|
|
124
|
+
ruleId: rule.ruleId,
|
|
125
|
+
outcome: 'fail',
|
|
126
|
+
severity: rule.defaultSeverity,
|
|
127
|
+
occurrences: pushFail(
|
|
128
|
+
'html_lang_attr_missing_empty',
|
|
129
|
+
'html_lang_attr_hint_missing_empty',
|
|
130
|
+
{},
|
|
131
|
+
{ reasonCode: 'lang-empty', location: 'html' }
|
|
132
|
+
)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Minimal BCP47 primary subtag check
|
|
137
|
+
if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)) {
|
|
138
|
+
return {
|
|
139
|
+
ruleId: rule.ruleId,
|
|
140
|
+
outcome: 'fail',
|
|
141
|
+
severity: rule.defaultSeverity,
|
|
142
|
+
occurrences: pushFail(
|
|
143
|
+
'html_lang_attr_invalid',
|
|
144
|
+
'html_lang_attr_hint_invalid',
|
|
145
|
+
{ lang },
|
|
146
|
+
{ reasonCode: 'lang-invalid-bcp47', lang }
|
|
147
|
+
)
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
ruleId: rule.ruleId,
|
|
153
|
+
outcome: 'pass',
|
|
154
|
+
severity: 'minor',
|
|
155
|
+
occurrences: []
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { id, meta, runInPage };
|