@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,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check dlitem-parent-valid
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <dt>/<dd> elements must be contained by a <dl> (directly or via one wrapping <div>)
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <dt>/<dd> elements that have a parent element.
|
|
11
|
+
* @expectation
|
|
12
|
+
* The parent is <dl>, or the parent is a <div> whose own parent is <dl>
|
|
13
|
+
* (a single level of wrapping div is allowed, matching how authors
|
|
14
|
+
* commonly group dt/dd pairs). A <dt>/<dd> used outside a real
|
|
15
|
+
* description-list container is not exposed as a term/definition to
|
|
16
|
+
* assistive technologies.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Distinct, atomic decision from definition-list-children-valid
|
|
19
|
+
* (the inverse relationship: does a given <dl> have valid children).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const id = 'dlitem-parent-valid';
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'Description-list items must be inside a description list',
|
|
26
|
+
description: 'Checks that <dt>/<dd> elements are contained by a <dl>, directly or via one wrapping <div>.',
|
|
27
|
+
i18n: {
|
|
28
|
+
titleKey: 'dlitemParentValid_title',
|
|
29
|
+
descriptionKey: 'dlitemParentValid_description'
|
|
30
|
+
},
|
|
31
|
+
helpUrl: null,
|
|
32
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'list'],
|
|
33
|
+
wcagSc: ['1.3.1'],
|
|
34
|
+
normativeMappings: [
|
|
35
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
36
|
+
],
|
|
37
|
+
defaultSeverity: 'serious',
|
|
38
|
+
category: 'perceivable',
|
|
39
|
+
type: 'automatic',
|
|
40
|
+
defaultConfidence: 'high',
|
|
41
|
+
coverage: { facetsBySc: { '1.3.1': ['dlitem-parent-valid'] } }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function runInPage(ctx) {
|
|
45
|
+
const { document, root, helpers, rule } = ctx;
|
|
46
|
+
const safeRoot = root || document;
|
|
47
|
+
|
|
48
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('dt, dd', safeRoot) : helpers.queryAll('dt, dd', safeRoot);
|
|
49
|
+
|
|
50
|
+
const occurrences = [];
|
|
51
|
+
let applicableCount = 0;
|
|
52
|
+
|
|
53
|
+
for (const el of nodes) {
|
|
54
|
+
if (!el) continue;
|
|
55
|
+
const parent = el.parentElement;
|
|
56
|
+
if (!parent) continue;
|
|
57
|
+
|
|
58
|
+
applicableCount += 1;
|
|
59
|
+
|
|
60
|
+
const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '';
|
|
61
|
+
let valid = parentTag === 'dl';
|
|
62
|
+
|
|
63
|
+
if (!valid && parentTag === 'div') {
|
|
64
|
+
const grandparent = parent.parentElement;
|
|
65
|
+
const grandparentTag = grandparent && grandparent.tagName ? grandparent.tagName.toLowerCase() : '';
|
|
66
|
+
valid = grandparentTag === 'dl';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (valid) 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 description-list item is not contained by a <dl>.',
|
|
79
|
+
hint: 'Place this <dt>/<dd> inside a <dl>, directly or wrapped in a single <div>.',
|
|
80
|
+
i18n: {
|
|
81
|
+
summaryKey: 'dlitemParentValid_summary_fail',
|
|
82
|
+
hintKey: 'dlitemParentValid_hint_fail',
|
|
83
|
+
params: { element: tag, parentElement: parentTag }
|
|
84
|
+
},
|
|
85
|
+
data: {
|
|
86
|
+
details: { reasonCode: 'DLITEM_INVALID_PARENT', element: tag, parentElement: parentTag }
|
|
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 || 'serious', occurrences };
|
|
96
|
+
}
|
|
97
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check duplicate-id-aria
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Any id referenced by an ARIA ID-reference attribute must be unique in the document
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies when the document contains at least one non-empty
|
|
11
|
+
* aria-labelledby, aria-describedby, aria-owns, aria-controls,
|
|
12
|
+
* aria-activedescendant, aria-flowto, aria-errormessage, or aria-details
|
|
13
|
+
* attribute (i.e. at least one ARIA ID reference exists to resolve).
|
|
14
|
+
* @expectation
|
|
15
|
+
* For every id value referenced by one of those attributes, exactly one
|
|
16
|
+
* element in the document carries that id. A duplicated id referenced by
|
|
17
|
+
* ARIA is ambiguous: assistive technologies cannot reliably determine
|
|
18
|
+
* which element the reference resolves to (typically the first, silently
|
|
19
|
+
* dropping the others).
|
|
20
|
+
* @implementation-notes
|
|
21
|
+
* - Scoped deliberately to ids referenced by ARIA (matching a widely-used
|
|
22
|
+
* reference engine's duplicate-id-aria, not the broader/deprecated page-wide duplicate-id
|
|
23
|
+
* check — see ROADMAP.md's "Skip" list).
|
|
24
|
+
* - Document-wide by design: id uniqueness and ARIA id references are a
|
|
25
|
+
* whole-document property, not scoped to a sub-root.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const id = 'duplicate-id-aria';
|
|
29
|
+
|
|
30
|
+
const meta = {
|
|
31
|
+
title: 'IDs referenced by ARIA must be unique',
|
|
32
|
+
description: 'Checks that any id value referenced by an ARIA ID-reference attribute (aria-labelledby, aria-describedby, aria-owns, aria-controls, aria-activedescendant, aria-flowto, aria-errormessage, aria-details) is unique in the document.',
|
|
33
|
+
i18n: {
|
|
34
|
+
titleKey: 'duplicateIdAria_title',
|
|
35
|
+
descriptionKey: 'duplicateIdAria_description'
|
|
36
|
+
},
|
|
37
|
+
helpUrl: null,
|
|
38
|
+
tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
|
|
39
|
+
wcagSc: ['4.1.2'],
|
|
40
|
+
normativeMappings: [
|
|
41
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
42
|
+
],
|
|
43
|
+
defaultSeverity: 'serious',
|
|
44
|
+
category: 'robust',
|
|
45
|
+
type: 'automatic',
|
|
46
|
+
defaultConfidence: 'high',
|
|
47
|
+
coverage: { facetsBySc: { '4.1.2': ['duplicate-id-aria'] } }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function runInPage(ctx) {
|
|
51
|
+
const { document, helpers, rule } = ctx;
|
|
52
|
+
|
|
53
|
+
// Declared inside runInPage — see scripts/build-core.js header
|
|
54
|
+
// ("runInPage MUST be self-contained").
|
|
55
|
+
const IDREF_ATTRS = [
|
|
56
|
+
'aria-labelledby',
|
|
57
|
+
'aria-describedby',
|
|
58
|
+
'aria-owns',
|
|
59
|
+
'aria-controls',
|
|
60
|
+
'aria-activedescendant',
|
|
61
|
+
'aria-flowto',
|
|
62
|
+
'aria-errormessage',
|
|
63
|
+
'aria-details'
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const idMap = new Map(); // id -> element[]
|
|
67
|
+
const idNodes = document.querySelectorAll ? document.querySelectorAll('[id]') : [];
|
|
68
|
+
for (const el of idNodes) {
|
|
69
|
+
if (!el || !el.getAttribute) continue;
|
|
70
|
+
const value = String(el.getAttribute('id') || '').trim();
|
|
71
|
+
if (!value) continue;
|
|
72
|
+
if (!idMap.has(value)) idMap.set(value, []);
|
|
73
|
+
idMap.get(value).push(el);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const referencedIds = new Set();
|
|
77
|
+
for (const attr of IDREF_ATTRS) {
|
|
78
|
+
const selector = `[${attr}]`;
|
|
79
|
+
const nodes = document.querySelectorAll ? document.querySelectorAll(selector) : [];
|
|
80
|
+
for (const el of nodes) {
|
|
81
|
+
if (!el || !el.getAttribute) continue;
|
|
82
|
+
const raw = String(el.getAttribute(attr) || '').trim();
|
|
83
|
+
if (!raw) continue;
|
|
84
|
+
for (const token of raw.split(/\s+/)) {
|
|
85
|
+
if (token) referencedIds.add(token);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const occurrences = [];
|
|
91
|
+
|
|
92
|
+
for (const refId of referencedIds) {
|
|
93
|
+
const els = idMap.get(refId) || [];
|
|
94
|
+
if (els.length <= 1) continue;
|
|
95
|
+
|
|
96
|
+
for (const el of els) {
|
|
97
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
98
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
99
|
+
|
|
100
|
+
occurrences.push({
|
|
101
|
+
selector: stableSelector,
|
|
102
|
+
html,
|
|
103
|
+
summary: 'This id is referenced by an ARIA attribute but is used by more than one element.',
|
|
104
|
+
hint: 'Make ids referenced by ARIA attributes unique within the document.',
|
|
105
|
+
i18n: {
|
|
106
|
+
summaryKey: 'duplicateIdAria_summary_fail',
|
|
107
|
+
hintKey: 'duplicateIdAria_hint_fail',
|
|
108
|
+
params: { id: refId, duplicateCount: String(els.length) }
|
|
109
|
+
},
|
|
110
|
+
data: {
|
|
111
|
+
details: { reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED', id: refId, duplicateCount: els.length }
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (referencedIds.size === 0) {
|
|
118
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
119
|
+
}
|
|
120
|
+
if (occurrences.length) {
|
|
121
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
122
|
+
}
|
|
123
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check embed-text-alternative-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <embed> elements must provide a text alternative
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <embed> elements that are exposed to assistive technologies.
|
|
11
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
12
|
+
* if they are tabbable or referenced by IDREF relationships (per engine eligibility checks).
|
|
13
|
+
* role="presentation"/role="none" are excluded only when not focusable.
|
|
14
|
+
* @expectation
|
|
15
|
+
* Each applicable <embed> provides a text alternative via:
|
|
16
|
+
* - an accessible name (aria-labelledby/aria-label), OR
|
|
17
|
+
* - a title attribute (best-effort fallback).
|
|
18
|
+
*
|
|
19
|
+
* Note: <embed> does not support fallback content in HTML, so this rule does not check children.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const id = 'embed-text-alternative-present';
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: '<embed> must provide a text alternative',
|
|
26
|
+
description: 'Checks that <embed> elements provide a text alternative via an accessible name.',
|
|
27
|
+
i18n: {
|
|
28
|
+
titleKey: 'embed_textAltPresent_title',
|
|
29
|
+
descriptionKey: 'embed_textAltPresent_description'
|
|
30
|
+
},
|
|
31
|
+
helpUrl: null,
|
|
32
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'embed', 'atomic', 'automatic'],
|
|
33
|
+
wcagSc: ['1.1.1'],
|
|
34
|
+
normativeMappings: [
|
|
35
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
36
|
+
],
|
|
37
|
+
defaultSeverity: 'serious',
|
|
38
|
+
category: 'perceivable',
|
|
39
|
+
type: 'automatic',
|
|
40
|
+
defaultConfidence: 'high',
|
|
41
|
+
coverage: { facetsBySc: { '1.1.1': ['embed-text-alternative-present'] } }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function runInPage(ctx) {
|
|
45
|
+
const { document, root, helpers, rule } = ctx;
|
|
46
|
+
const safeRoot = root || document;
|
|
47
|
+
|
|
48
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
49
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
50
|
+
? helpers.queryAll
|
|
51
|
+
: (sel) => {
|
|
52
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
53
|
+
catch { return []; }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
57
|
+
? helpers.getEligibilityInfo
|
|
58
|
+
: null;
|
|
59
|
+
|
|
60
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
61
|
+
? helpers.isAccTreeEligible
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
65
|
+
? helpers.getFocusableInfo
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function'
|
|
69
|
+
? helpers.getAriaNameInfo
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
const embeds = (() => {
|
|
73
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('embed') : queryAll('embed')) || []); }
|
|
74
|
+
catch { return queryAll('embed'); }
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
if (!embeds.length) {
|
|
78
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const occurrences = [];
|
|
82
|
+
let applicableCount = 0;
|
|
83
|
+
|
|
84
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
85
|
+
|
|
86
|
+
function computeNameInfo(el) {
|
|
87
|
+
// For <embed>, treat ARIA name as primary. Do not accept HTML <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
|
+
// `title` is a weaker text-alternative mechanism than aria-label/
|
|
113
|
+
// aria-labelledby — it is not reliably exposed to assistive technology
|
|
114
|
+
// in every context (e.g. touch/mobile) — so a pass achieved only via
|
|
115
|
+
// `title` is reported at reduced confidence rather than the rule's
|
|
116
|
+
// default `high`.
|
|
117
|
+
let anyPassedViaWeakMechanism = false;
|
|
118
|
+
|
|
119
|
+
for (const el of embeds) {
|
|
120
|
+
if (!el || !el.getAttribute) continue;
|
|
121
|
+
|
|
122
|
+
if (isAccTreeEligible) {
|
|
123
|
+
const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
|
|
124
|
+
if (elig && elig.eligible === false) continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// role presentation/none exclusion only when not focusable
|
|
128
|
+
const role = (() => { try { return String(el.getAttribute('role') || '').trim().toLowerCase(); } catch { return ''; } })();
|
|
129
|
+
if (role === 'presentation' || role === 'none') {
|
|
130
|
+
let focusable = false;
|
|
131
|
+
if (getFocusableInfo) {
|
|
132
|
+
const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
|
|
133
|
+
focusable = !!(fi && fi.focusable);
|
|
134
|
+
} else {
|
|
135
|
+
const tabindex = el.getAttribute('tabindex');
|
|
136
|
+
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
137
|
+
}
|
|
138
|
+
if (!focusable) continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
applicableCount += 1;
|
|
142
|
+
|
|
143
|
+
const name = computeNameInfo(el);
|
|
144
|
+
if (name.present) {
|
|
145
|
+
if (name.mechanism === 'title') anyPassedViaWeakMechanism = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
150
|
+
|
|
151
|
+
const baseOccurrence = {
|
|
152
|
+
summary: 'Missing text alternative for <embed>.',
|
|
153
|
+
hint: 'Add an accessible name to <embed> (aria-label/aria-labelledby).',
|
|
154
|
+
i18n: {
|
|
155
|
+
summaryKey: 'embed_textAltPresent_summary_fail',
|
|
156
|
+
hintKey: 'embed_textAltPresent_hint_fail',
|
|
157
|
+
params: { element: 'embed' }
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
|
+
name
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
166
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
167
|
+
} else {
|
|
168
|
+
// Never compute selector/snippet in the rule.
|
|
169
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (applicableCount === 0) {
|
|
174
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!occurrences.length) {
|
|
178
|
+
return {
|
|
179
|
+
ruleId: rule.ruleId,
|
|
180
|
+
outcome: 'pass',
|
|
181
|
+
severity: 'minor',
|
|
182
|
+
confidence: anyPassedViaWeakMechanism ? 'medium' : 'high',
|
|
183
|
+
occurrences: []
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { id, meta, runInPage };
|