@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,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'treeitem-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Accessible name is present',
|
|
7
|
+
description: 'Checks that elements expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'treeitemNamePresent_title',
|
|
10
|
+
descriptionKey: 'treeitemNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ["wcag2a", "wcag412", "navigation", "atomic", "automatic", "name", "treeitem"],
|
|
14
|
+
wcagSc: ['4.1.2'],
|
|
15
|
+
normativeMappings: [
|
|
16
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
17
|
+
],
|
|
18
|
+
defaultSeverity: 'serious',
|
|
19
|
+
category: 'robust',
|
|
20
|
+
type: 'automatic',
|
|
21
|
+
defaultConfidence: 'high',
|
|
22
|
+
coverage: { facetsBySc: { '4.1.2': ['treeitem-name-present'] } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runInPage(ctx) {
|
|
26
|
+
const { document, root, helpers, rule } = ctx;
|
|
27
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
|
+
? helpers.getEligibilityInfo
|
|
29
|
+
: null;
|
|
30
|
+
const safeRoot = root || document;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
function normalizeWs(s) {
|
|
34
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAttr(el, name) {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !el.getAttribute) return '';
|
|
40
|
+
return normalizeWs(el.getAttribute(name));
|
|
41
|
+
} catch { return ''; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getConservativeSubtreeText(document, container) {
|
|
45
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
46
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
47
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
48
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
49
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
50
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
51
|
+
// icon-button pattern).
|
|
52
|
+
if (helpers.getContentNameInfo) {
|
|
53
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
54
|
+
return info && info.present ? info.value : '';
|
|
55
|
+
}
|
|
56
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
57
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
61
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
62
|
+
if (!raw) return '';
|
|
63
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
64
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
65
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
66
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
67
|
+
// which name-from-content alone can never see).
|
|
68
|
+
if (helpers.getTextFromIdRefs) {
|
|
69
|
+
try {
|
|
70
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
71
|
+
return normalizeWs(r && r.text);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
78
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
79
|
+
if (!fn) return true;
|
|
80
|
+
try {
|
|
81
|
+
const r = fn(el, ctx);
|
|
82
|
+
if (typeof r === 'boolean') return r;
|
|
83
|
+
return !!(r && r.eligible);
|
|
84
|
+
} catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
const occurrences = [];
|
|
91
|
+
let applicableCount = 0;
|
|
92
|
+
|
|
93
|
+
const selector = "[role=\"treeitem\"]";
|
|
94
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
function hasName(el) {
|
|
98
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
99
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
100
|
+
|
|
101
|
+
const labelled = resolveAriaLabelledbyText(document, el, 8);
|
|
102
|
+
if (labelled) return { ok: true, method: 'aria-labelledby' };
|
|
103
|
+
|
|
104
|
+
const title = getAttr(el, 'title');
|
|
105
|
+
if (title) return { ok: true, method: 'title' };
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
const t = getConservativeSubtreeText(document, el);
|
|
109
|
+
if (t) return { ok: true, method: 'content' };
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
return { ok: false, method: 'none' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const el of nodes) {
|
|
116
|
+
if (!el) continue;
|
|
117
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
118
|
+
|
|
119
|
+
applicableCount += 1;
|
|
120
|
+
|
|
121
|
+
const res = hasName(el);
|
|
122
|
+
if (res.ok) continue;
|
|
123
|
+
|
|
124
|
+
const eligInfo = getEligibilityInfo
|
|
125
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
126
|
+
: null;
|
|
127
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
128
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
129
|
+
|
|
130
|
+
occurrences.push({
|
|
131
|
+
selector: stableSelector,
|
|
132
|
+
html,
|
|
133
|
+
summary: 'This element has no accessible name.',
|
|
134
|
+
hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
|
|
135
|
+
i18n: {
|
|
136
|
+
summaryKey: 'treeitemNamePresent_summary_fail',
|
|
137
|
+
hintKey: 'treeitemNamePresent_hint_fail',
|
|
138
|
+
params: { controlType: 'treeitem' }
|
|
139
|
+
},
|
|
140
|
+
data: {
|
|
141
|
+
details: { reasonCode: 'name_missing', controlType: 'treeitem', methodTried: res.method },
|
|
142
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if (applicableCount === 0) {
|
|
149
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
150
|
+
}
|
|
151
|
+
if (occurrences.length) {
|
|
152
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
153
|
+
}
|
|
154
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check valid-lang
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Any element's lang attribute must be a syntactically valid language tag
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 3.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to any element other than the root <html> with a non-empty
|
|
11
|
+
* lang attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* The lang value matches a valid BCP47 language-tag syntax. WCAG 3.1.2
|
|
14
|
+
* (Language of Parts) requires that when a passage's language differs
|
|
15
|
+
* from the page's default, it is identified programmatically — an
|
|
16
|
+
* invalid tag fails to identify a real language at all.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Distinct, atomic decision from html-lang-attr-present (that
|
|
19
|
+
* rule covers the root <html> element only, for SC 3.1.1); this rule
|
|
20
|
+
* covers every other element, for SC 3.1.2.
|
|
21
|
+
* - Same minimal BCP47 *syntax* check as html-lang-attr-present (primary
|
|
22
|
+
* subtag + optional subtags), not IANA Language Subtag Registry
|
|
23
|
+
* validation — same documented scope limitation (syntactically
|
|
24
|
+
* well-formed but unregistered tags like "xx-ZZ" are not flagged).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const id = 'valid-lang';
|
|
28
|
+
|
|
29
|
+
const meta = {
|
|
30
|
+
title: 'Element lang attribute must be syntactically valid',
|
|
31
|
+
description: 'Checks that any element (other than the root <html>) with a non-empty lang attribute uses a syntactically valid language tag.',
|
|
32
|
+
i18n: {
|
|
33
|
+
titleKey: 'validLang_title',
|
|
34
|
+
descriptionKey: 'validLang_description'
|
|
35
|
+
},
|
|
36
|
+
helpUrl: null,
|
|
37
|
+
tags: ['wcag2aa', 'wcag312', 'structure', 'language', 'atomic', 'automatic'],
|
|
38
|
+
wcagSc: ['3.1.2'],
|
|
39
|
+
normativeMappings: [
|
|
40
|
+
{ standard: 'WCAG', version: '2.2', requirement: '3.1.2', title: 'Language of Parts', conformanceLevel: 'AA' }
|
|
41
|
+
],
|
|
42
|
+
defaultSeverity: 'moderate',
|
|
43
|
+
category: 'understandable',
|
|
44
|
+
type: 'automatic',
|
|
45
|
+
defaultConfidence: 'high',
|
|
46
|
+
coverage: { facetsBySc: { '3.1.2': ['element-lang-valid'] } }
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function runInPage(ctx) {
|
|
50
|
+
const { document, root, helpers, rule } = ctx;
|
|
51
|
+
const safeRoot = root || document;
|
|
52
|
+
|
|
53
|
+
const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
54
|
+
|
|
55
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]', safeRoot) : helpers.queryAll('[lang]', safeRoot);
|
|
56
|
+
|
|
57
|
+
const occurrences = [];
|
|
58
|
+
let applicableCount = 0;
|
|
59
|
+
|
|
60
|
+
for (const el of nodes) {
|
|
61
|
+
if (!el || !el.getAttribute) continue;
|
|
62
|
+
if (el.tagName && el.tagName.toLowerCase() === 'html') continue;
|
|
63
|
+
|
|
64
|
+
const raw = String(el.getAttribute('lang') || '').trim();
|
|
65
|
+
if (!raw) continue;
|
|
66
|
+
|
|
67
|
+
applicableCount += 1;
|
|
68
|
+
|
|
69
|
+
if (BCP47_RE.test(raw)) continue;
|
|
70
|
+
|
|
71
|
+
const tag = el.tagName.toLowerCase();
|
|
72
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
73
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
74
|
+
|
|
75
|
+
occurrences.push({
|
|
76
|
+
selector: stableSelector,
|
|
77
|
+
html,
|
|
78
|
+
summary: `This lang attribute value ("${raw}") is not a syntactically valid language tag.`,
|
|
79
|
+
hint: 'Use a valid BCP47 language tag (e.g. "fr", "es-MX").',
|
|
80
|
+
i18n: {
|
|
81
|
+
summaryKey: 'validLang_summary_fail',
|
|
82
|
+
hintKey: 'validLang_hint_fail',
|
|
83
|
+
params: { element: tag, value: raw }
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
details: { reasonCode: 'ELEMENT_LANG_INVALID', element: tag, value: raw }
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (applicableCount === 0) {
|
|
92
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
93
|
+
}
|
|
94
|
+
if (occurrences.length) {
|
|
95
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
96
|
+
}
|
|
97
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check video-poster-text-alternative-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <video> elements with a poster must provide a text alternative
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <video> elements that:
|
|
11
|
+
* 1) have a non-empty poster attribute, AND
|
|
12
|
+
* 2) are exposed to assistive technologies (per engine eligibility checks).
|
|
13
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
14
|
+
* if they are tabbable or referenced by IDREF relationships (per eligibility checks).
|
|
15
|
+
* Videos with role="presentation" or role="none" are excluded only when they are not focusable.
|
|
16
|
+
* @expectation
|
|
17
|
+
* Each applicable <video> element provides a text alternative for the poster image, via:
|
|
18
|
+
* - an accessible name (aria-label / aria-labelledby / title).
|
|
19
|
+
* Between-tag fallback content inside <video> is NOT accepted: it is only
|
|
20
|
+
* rendered by browsers that don't support <video>, so it is not reliably
|
|
21
|
+
* exposed to assistive technologies in practice. <video> is also not a
|
|
22
|
+
* labelable element, so native <label for="..."> associations are not
|
|
23
|
+
* accepted either.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'video-poster-text-alternative-present';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: '<video> poster must have a text alternative',
|
|
30
|
+
description:
|
|
31
|
+
'Checks that <video> elements with a poster image provide a text alternative (accessible name).',
|
|
32
|
+
i18n: {
|
|
33
|
+
titleKey: 'videoPoster_textAltPresent_title',
|
|
34
|
+
descriptionKey: 'videoPoster_textAltPresent_description'
|
|
35
|
+
},
|
|
36
|
+
helpUrl: null,
|
|
37
|
+
tags: ['wcag2a', 'wcag111', 'media', 'video', 'atomic', 'automatic'],
|
|
38
|
+
wcagSc: ['1.1.1'],
|
|
39
|
+
normativeMappings: [
|
|
40
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
41
|
+
],
|
|
42
|
+
defaultSeverity: 'serious',
|
|
43
|
+
category: 'perceivable',
|
|
44
|
+
type: 'automatic',
|
|
45
|
+
defaultConfidence: 'medium',
|
|
46
|
+
coverage: {
|
|
47
|
+
facetsBySc: {
|
|
48
|
+
'1.1.1': ['video-poster-text-alt-present']
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function runInPage(ctx) {
|
|
54
|
+
const { document, root, helpers, rule } = ctx;
|
|
55
|
+
const safeRoot = root || document;
|
|
56
|
+
|
|
57
|
+
const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function'
|
|
58
|
+
? helpers.reportOccurrence
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
62
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
63
|
+
? helpers.queryAll
|
|
64
|
+
: null;
|
|
65
|
+
|
|
66
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
67
|
+
? helpers.getEligibilityInfo
|
|
68
|
+
: null;
|
|
69
|
+
|
|
70
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
71
|
+
? helpers.isAccTreeEligible
|
|
72
|
+
: null;
|
|
73
|
+
|
|
74
|
+
const isFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
75
|
+
? helpers.getFocusableInfo
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
|
|
79
|
+
? helpers.getAriaNameInfo
|
|
80
|
+
: null;
|
|
81
|
+
|
|
82
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
83
|
+
|
|
84
|
+
function computeNameInfo(el) {
|
|
85
|
+
// <video> is not a labelable element (no browser computes an accessible
|
|
86
|
+
// name from <label for="...">), so only ARIA naming and title count —
|
|
87
|
+
// do not accept native <label> associations.
|
|
88
|
+
const flags = [];
|
|
89
|
+
let aria = null;
|
|
90
|
+
|
|
91
|
+
if (getAriaNameInfo) {
|
|
92
|
+
aria = (() => { try { return getAriaNameInfo(el, ctx); } catch { return null; } })();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (aria && aria.present && trim(aria.value)) {
|
|
96
|
+
return { present: true, value: trim(aria.value), mechanism: aria.mechanism || 'aria', flags: (aria.flags || []).slice(0) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const title = trim(el.getAttribute && el.getAttribute('title'));
|
|
100
|
+
if (title) {
|
|
101
|
+
flags.push('title-used');
|
|
102
|
+
return { present: true, value: title, mechanism: 'title', flags };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (aria && aria.flags && aria.flags.length) {
|
|
106
|
+
for (const f of aria.flags) flags.push(f);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { present: false, value: '', mechanism: 'none', flags };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const videos = (() => {
|
|
113
|
+
try {
|
|
114
|
+
if (queryAllSmart) return Array.from(queryAllSmart('video') || []);
|
|
115
|
+
if (queryAll) return Array.from(queryAll('video') || []);
|
|
116
|
+
return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : [];
|
|
117
|
+
} catch {
|
|
118
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll('video')) : []; }
|
|
119
|
+
catch { return []; }
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
|
|
123
|
+
if (!videos.length) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
124
|
+
|
|
125
|
+
const occurrences = [];
|
|
126
|
+
let applicableCount = 0;
|
|
127
|
+
|
|
128
|
+
for (const el of videos) {
|
|
129
|
+
if (!el || !el.getAttribute) continue;
|
|
130
|
+
|
|
131
|
+
const poster = trim(el.getAttribute('poster'));
|
|
132
|
+
if (!poster) continue; // not applicable: no poster image
|
|
133
|
+
|
|
134
|
+
// Eligibility: only elements exposed to AT (with helper exceptions)
|
|
135
|
+
if (isAccTreeEligible) {
|
|
136
|
+
const elig = (() => {
|
|
137
|
+
try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; }
|
|
138
|
+
})();
|
|
139
|
+
if (elig && elig.eligible === false) continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Role presentation/none excluded ONLY if not focusable (mirrors img behavior)
|
|
143
|
+
const role = trim(el.getAttribute('role')).toLowerCase();
|
|
144
|
+
if (role === 'presentation' || role === 'none') {
|
|
145
|
+
let focusable = false;
|
|
146
|
+
if (isFocusableInfo) {
|
|
147
|
+
const fi = (() => { try { return isFocusableInfo(el, ctx); } catch { return null; } })();
|
|
148
|
+
focusable = !!(fi && fi.focusable);
|
|
149
|
+
} else {
|
|
150
|
+
const tabindex = el.getAttribute('tabindex');
|
|
151
|
+
focusable = tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
|
|
152
|
+
}
|
|
153
|
+
if (!focusable) continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
applicableCount += 1;
|
|
157
|
+
|
|
158
|
+
const nameInfo = computeNameInfo(el);
|
|
159
|
+
if (nameInfo.present) continue;
|
|
160
|
+
|
|
161
|
+
let eligInfo = null;
|
|
162
|
+
if (getEligibilityInfo) {
|
|
163
|
+
try { eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { eligInfo = null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const baseOccurrence = {
|
|
167
|
+
summary: 'Missing text alternative for <video> poster.',
|
|
168
|
+
hint: 'Provide an accessible name (e.g., aria-label/aria-labelledby) for the poster image.',
|
|
169
|
+
i18n: {
|
|
170
|
+
summaryKey: 'videoPoster_textAltPresent_summary_fail',
|
|
171
|
+
hintKey: 'videoPoster_textAltPresent_hint_fail',
|
|
172
|
+
params: { element: 'video' }
|
|
173
|
+
},
|
|
174
|
+
data: {
|
|
175
|
+
poster,
|
|
176
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
177
|
+
nameInfo: nameInfo || null
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (reportOccurrence) {
|
|
182
|
+
occurrences.push(reportOccurrence(el, baseOccurrence));
|
|
183
|
+
} else {
|
|
184
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (applicableCount === 0) return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
189
|
+
if (!occurrences.length) return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
190
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check accesskeys
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary accesskey values must be unique on the page
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies whenever two or more elements share the same non-empty
|
|
10
|
+
* accesskey attribute value (case-insensitive).
|
|
11
|
+
* @expectation
|
|
12
|
+
* Every accesskey value on the page is unique. Duplicate accesskeys
|
|
13
|
+
* make keyboard-shortcut activation ambiguous — only one of the
|
|
14
|
+
* elements sharing the key can actually be reached by it, and which
|
|
15
|
+
* one is browser/platform-dependent.
|
|
16
|
+
* @implementation-notes
|
|
17
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
18
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
19
|
+
* header comment for the shared rationale/precedent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const id = 'accesskeys';
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'accesskey values must be unique',
|
|
26
|
+
description: 'Checks that no two elements on the page share the same accesskey attribute value.',
|
|
27
|
+
i18n: {
|
|
28
|
+
titleKey: 'accesskeys_title',
|
|
29
|
+
descriptionKey: 'accesskeys_description'
|
|
30
|
+
},
|
|
31
|
+
helpUrl: null,
|
|
32
|
+
tags: ['best-practice', 'keyboard', 'structure', 'atomic', 'manual'],
|
|
33
|
+
wcagSc: [],
|
|
34
|
+
normativeMappings: [],
|
|
35
|
+
defaultSeverity: 'minor',
|
|
36
|
+
category: 'operable',
|
|
37
|
+
type: 'manual',
|
|
38
|
+
defaultConfidence: 'medium',
|
|
39
|
+
coverage: {}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function runInPage(ctx) {
|
|
43
|
+
const { document, helpers, rule } = ctx;
|
|
44
|
+
|
|
45
|
+
let nodes = [];
|
|
46
|
+
try {
|
|
47
|
+
nodes = document.querySelectorAll('[accesskey]');
|
|
48
|
+
} catch {
|
|
49
|
+
nodes = [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const groups = new Map(); // normalized key -> elements[]
|
|
53
|
+
for (const el of nodes) {
|
|
54
|
+
if (!el || !el.getAttribute) continue;
|
|
55
|
+
const raw = String(el.getAttribute('accesskey') || '').trim();
|
|
56
|
+
if (!raw) continue;
|
|
57
|
+
const key = raw.toLowerCase();
|
|
58
|
+
const list = groups.get(key) || [];
|
|
59
|
+
list.push(el);
|
|
60
|
+
groups.set(key, list);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const occurrences = [];
|
|
64
|
+
for (const [key, els] of groups) {
|
|
65
|
+
if (els.length <= 1) continue;
|
|
66
|
+
for (const el of els) {
|
|
67
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
68
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
69
|
+
|
|
70
|
+
occurrences.push({
|
|
71
|
+
selector: stableSelector,
|
|
72
|
+
html,
|
|
73
|
+
summary: 'This element\'s accesskey is shared with another element on the page.',
|
|
74
|
+
hint: 'Make each accesskey value unique across the page.',
|
|
75
|
+
i18n: {
|
|
76
|
+
summaryKey: 'accesskeys_summary_cantTell',
|
|
77
|
+
hintKey: 'accesskeys_hint_cantTell',
|
|
78
|
+
params: { accesskey: key, duplicateCount: String(els.length) }
|
|
79
|
+
},
|
|
80
|
+
data: {
|
|
81
|
+
details: { reasonCode: 'ACCESSKEY_DUPLICATE', accesskey: key, duplicateCount: els.length }
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!occurrences.length) {
|
|
88
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
89
|
+
}
|
|
90
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { id, meta, runInPage };
|