@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,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check iframe-focusable-content
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <iframe>/<frame> elements with tabindex="-1" must not contain focusable content
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <iframe>/<frame> elements with an explicit negative
|
|
11
|
+
* tabindex, whose embedded document is same-origin and reachable via
|
|
12
|
+
* contentDocument (cross-origin/unreachable frames assert nothing — see
|
|
13
|
+
* implementation notes).
|
|
14
|
+
* @expectation
|
|
15
|
+
* The frame's embedded document contains no focusable element. Browsers
|
|
16
|
+
* do not propagate tabindex="-1" on the host <iframe> into its embedded
|
|
17
|
+
* document: Tab can still reach focusable content inside, even though
|
|
18
|
+
* the frame itself is skipped. An author who set tabindex="-1" intending
|
|
19
|
+
* to remove the frame from the tab order has not actually done so if the
|
|
20
|
+
* embedded document contains focusable content.
|
|
21
|
+
* @implementation-notes
|
|
22
|
+
* - Deliberately scoped to same-origin, currently-accessible content only
|
|
23
|
+
* (contentDocument access is wrapped in try/catch and treated as "no
|
|
24
|
+
* constraint asserted" — not counted as applicable — when unreachable),
|
|
25
|
+
* matching this engine's established scope-limiting rationale (see
|
|
26
|
+
* src/core/aria-helpers.js file header) rather than guessing at
|
|
27
|
+
* cross-origin content.
|
|
28
|
+
* - Focusability inside the embedded document is checked with a small,
|
|
29
|
+
* self-contained heuristic (native interactive tags + non-negative
|
|
30
|
+
* tabindex) rather than ctx.helpers.getFocusableInfo, since that helper
|
|
31
|
+
* is built for the outer document's realm/caches, not an embedded
|
|
32
|
+
* document that may be a distinct realm.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const id = 'iframe-focusable-content';
|
|
36
|
+
|
|
37
|
+
const meta = {
|
|
38
|
+
title: 'Frames with tabindex="-1" must not contain focusable content',
|
|
39
|
+
description: 'Checks that same-origin <iframe>/<frame> elements with tabindex="-1" do not contain focusable content, since browsers do not propagate that restriction into the frame’s embedded document.',
|
|
40
|
+
i18n: {
|
|
41
|
+
titleKey: 'iframeFocusableContent_title',
|
|
42
|
+
descriptionKey: 'iframeFocusableContent_description'
|
|
43
|
+
},
|
|
44
|
+
helpUrl: null,
|
|
45
|
+
tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'automatic', 'keyboard', 'iframe'],
|
|
46
|
+
wcagSc: ['2.1.1'],
|
|
47
|
+
normativeMappings: [
|
|
48
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' }
|
|
49
|
+
],
|
|
50
|
+
defaultSeverity: 'moderate',
|
|
51
|
+
category: 'operable',
|
|
52
|
+
type: 'automatic',
|
|
53
|
+
defaultConfidence: 'high',
|
|
54
|
+
coverage: { facetsBySc: { '2.1.1': ['iframe-tabindex-negative-content-not-focusable'] } }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function runInPage(ctx) {
|
|
58
|
+
const { document, root, helpers, rule } = ctx;
|
|
59
|
+
const safeRoot = root || document;
|
|
60
|
+
|
|
61
|
+
function hasFocusableCandidate(doc) {
|
|
62
|
+
if (!doc || !doc.querySelectorAll) return false;
|
|
63
|
+
let els = [];
|
|
64
|
+
try {
|
|
65
|
+
els = doc.querySelectorAll(
|
|
66
|
+
'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), ' +
|
|
67
|
+
'select:not([disabled]), textarea:not([disabled]), iframe, [contenteditable="true"], [tabindex]'
|
|
68
|
+
);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
for (const el of els) {
|
|
73
|
+
if (!el || !el.getAttribute) continue;
|
|
74
|
+
const raw = el.getAttribute('tabindex');
|
|
75
|
+
if (raw != null) {
|
|
76
|
+
const n = Number(String(raw).trim());
|
|
77
|
+
if (!Number.isNaN(n) && n < 0) continue; // explicitly removed from tab order
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getNegativeTabIndex(el) {
|
|
85
|
+
const raw = el.getAttribute('tabindex');
|
|
86
|
+
if (raw == null) return false;
|
|
87
|
+
const n = Number(String(raw).trim());
|
|
88
|
+
return !Number.isNaN(n) && n < 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
|
|
92
|
+
|
|
93
|
+
const occurrences = [];
|
|
94
|
+
let applicableCount = 0;
|
|
95
|
+
|
|
96
|
+
for (const el of nodes) {
|
|
97
|
+
if (!el || !el.getAttribute) continue;
|
|
98
|
+
if (!getNegativeTabIndex(el)) continue;
|
|
99
|
+
|
|
100
|
+
let contentDoc = null;
|
|
101
|
+
try {
|
|
102
|
+
contentDoc = el.contentDocument || null;
|
|
103
|
+
} catch {
|
|
104
|
+
contentDoc = null;
|
|
105
|
+
}
|
|
106
|
+
if (!contentDoc || !contentDoc.querySelectorAll) continue; // cross-origin/unreachable: no constraint asserted
|
|
107
|
+
|
|
108
|
+
applicableCount += 1;
|
|
109
|
+
|
|
110
|
+
if (!hasFocusableCandidate(contentDoc)) continue;
|
|
111
|
+
|
|
112
|
+
const tag = el.tagName.toLowerCase();
|
|
113
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
114
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
115
|
+
|
|
116
|
+
occurrences.push({
|
|
117
|
+
selector: stableSelector,
|
|
118
|
+
html,
|
|
119
|
+
summary: 'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
|
|
120
|
+
hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
|
|
121
|
+
i18n: {
|
|
122
|
+
summaryKey: 'iframeFocusableContent_summary_fail',
|
|
123
|
+
hintKey: 'iframeFocusableContent_hint_fail',
|
|
124
|
+
params: { element: tag }
|
|
125
|
+
},
|
|
126
|
+
data: {
|
|
127
|
+
details: { reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_FOCUSABLE', element: tag }
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (applicableCount === 0) {
|
|
133
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
134
|
+
}
|
|
135
|
+
if (occurrences.length) {
|
|
136
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
137
|
+
}
|
|
138
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check iframe-name-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <iframe>/<frame> elements must have an accessible name
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <iframe>/<frame> elements that are eligible for the
|
|
11
|
+
* accessibility tree (isAccTreeEligible).
|
|
12
|
+
* @expectation
|
|
13
|
+
* The element has a non-empty accessible name via aria-labelledby,
|
|
14
|
+
* aria-label, or the title attribute. Unlike most interactive elements,
|
|
15
|
+
* an iframe's name is never derived from its rendered content (the
|
|
16
|
+
* embedded document is a separate browsing context) — this mirrors
|
|
17
|
+
* dialog-name-present's "name-from-author-only" reasoning.
|
|
18
|
+
* @implementation-notes
|
|
19
|
+
* - Uses helpers.getAccessibleNameInfo, which already stops at
|
|
20
|
+
* aria-label/aria-labelledby/label[for]/title without falling back to
|
|
21
|
+
* subtree text content — the right shape for this element.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const id = 'iframe-name-present';
|
|
25
|
+
|
|
26
|
+
const meta = {
|
|
27
|
+
title: 'Frames have an accessible name',
|
|
28
|
+
description: 'Checks that <iframe>/<frame> elements expose a non-empty accessible name via aria-label, aria-labelledby, or the title attribute.',
|
|
29
|
+
i18n: {
|
|
30
|
+
titleKey: 'iframeNamePresent_title',
|
|
31
|
+
descriptionKey: 'iframeNamePresent_description'
|
|
32
|
+
},
|
|
33
|
+
helpUrl: null,
|
|
34
|
+
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
|
|
35
|
+
wcagSc: ['4.1.2'],
|
|
36
|
+
normativeMappings: [
|
|
37
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
38
|
+
],
|
|
39
|
+
defaultSeverity: 'serious',
|
|
40
|
+
category: 'robust',
|
|
41
|
+
type: 'automatic',
|
|
42
|
+
defaultConfidence: 'high',
|
|
43
|
+
coverage: { facetsBySc: { '4.1.2': ['iframe-name-present'] } }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function runInPage(ctx) {
|
|
47
|
+
const { document, root, helpers, rule } = ctx;
|
|
48
|
+
const safeRoot = root || document;
|
|
49
|
+
|
|
50
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
|
|
51
|
+
|
|
52
|
+
const occurrences = [];
|
|
53
|
+
let applicableCount = 0;
|
|
54
|
+
|
|
55
|
+
for (const el of nodes) {
|
|
56
|
+
if (!el || !el.tagName) continue;
|
|
57
|
+
|
|
58
|
+
if (helpers.isAccTreeEligible) {
|
|
59
|
+
const elig = helpers.isAccTreeEligible(el);
|
|
60
|
+
const isEligible = typeof elig === 'boolean' ? elig : !!(elig && elig.eligible);
|
|
61
|
+
if (!isEligible) continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
applicableCount += 1;
|
|
65
|
+
|
|
66
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx, { maxRefs: 8 }) : null;
|
|
67
|
+
if (nameInfo && nameInfo.present && nameInfo.value) continue;
|
|
68
|
+
|
|
69
|
+
const eligInfo = helpers.getEligibilityInfo
|
|
70
|
+
? (() => { try { return helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
71
|
+
: null;
|
|
72
|
+
const tag = el.tagName.toLowerCase();
|
|
73
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
74
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
75
|
+
|
|
76
|
+
occurrences.push({
|
|
77
|
+
selector: stableSelector,
|
|
78
|
+
html,
|
|
79
|
+
summary: 'This frame has no accessible name.',
|
|
80
|
+
hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
|
|
81
|
+
i18n: {
|
|
82
|
+
summaryKey: 'iframeNamePresent_summary_fail',
|
|
83
|
+
hintKey: 'iframeNamePresent_hint_fail',
|
|
84
|
+
params: { element: tag }
|
|
85
|
+
},
|
|
86
|
+
data: {
|
|
87
|
+
details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
|
|
88
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (applicableCount === 0) {
|
|
94
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
95
|
+
}
|
|
96
|
+
if (occurrences.length) {
|
|
97
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
98
|
+
}
|
|
99
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check iframe-title-unique
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <iframe>/<frame> title attributes must be unique among frames
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <iframe>/<frame> elements that carry a non-empty title
|
|
11
|
+
* attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* No two frames in scope share the same (trimmed, case-sensitive) title
|
|
14
|
+
* attribute value — a duplicate title prevents assistive technology
|
|
15
|
+
* users from telling frames apart when scanning by name.
|
|
16
|
+
* @implementation-notes
|
|
17
|
+
* - Distinct, atomic decision from iframe-name-present (presence):
|
|
18
|
+
* a frame can have a non-empty title while still failing uniqueness.
|
|
19
|
+
* - Compares the title ATTRIBUTE specifically, not the full computed
|
|
20
|
+
* accessible name (aria-label could legitimately differ in wording even
|
|
21
|
+
* when title happens to collide) — matches a widely-used reference engine's frame-title-unique.
|
|
22
|
+
* - Not gated on isAccTreeEligible: a duplicate title is a static markup
|
|
23
|
+
* property independent of the frame's current visibility.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'iframe-title-unique';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Frame titles must be unique',
|
|
30
|
+
description: 'Checks that no two <iframe>/<frame> elements in scope share the same title attribute value.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'iframeTitleUnique_title',
|
|
33
|
+
descriptionKey: 'iframeTitleUnique_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic', 'name', 'iframe'],
|
|
37
|
+
wcagSc: ['4.1.2'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'moderate',
|
|
42
|
+
category: 'robust',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: { facetsBySc: { '4.1.2': ['iframe-title-unique'] } }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('iframe, frame', safeRoot) : helpers.queryAll('iframe, frame', safeRoot);
|
|
53
|
+
|
|
54
|
+
const groups = new Map(); // trimmed title -> elements[]
|
|
55
|
+
let applicableCount = 0;
|
|
56
|
+
|
|
57
|
+
for (const el of nodes) {
|
|
58
|
+
if (!el || !el.getAttribute) continue;
|
|
59
|
+
|
|
60
|
+
const title = String(el.getAttribute('title') || '').trim();
|
|
61
|
+
if (!title) continue;
|
|
62
|
+
|
|
63
|
+
applicableCount += 1;
|
|
64
|
+
|
|
65
|
+
const list = groups.get(title);
|
|
66
|
+
if (list) list.push(el);
|
|
67
|
+
else groups.set(title, [el]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (applicableCount === 0) {
|
|
71
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const occurrences = [];
|
|
75
|
+
|
|
76
|
+
for (const [title, els] of groups) {
|
|
77
|
+
if (els.length < 2) continue;
|
|
78
|
+
|
|
79
|
+
for (const el of els) {
|
|
80
|
+
const tag = el.tagName.toLowerCase();
|
|
81
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
82
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
83
|
+
|
|
84
|
+
occurrences.push({
|
|
85
|
+
selector: stableSelector,
|
|
86
|
+
html,
|
|
87
|
+
summary: 'This frame’s title is not unique among the frames on this page.',
|
|
88
|
+
hint: 'Give each frame a distinct title describing its specific content or purpose.',
|
|
89
|
+
i18n: {
|
|
90
|
+
summaryKey: 'iframeTitleUnique_summary_fail',
|
|
91
|
+
hintKey: 'iframeTitleUnique_hint_fail',
|
|
92
|
+
params: { element: tag, title }
|
|
93
|
+
},
|
|
94
|
+
data: {
|
|
95
|
+
details: { reasonCode: 'IFRAME_TITLE_DUPLICATE', element: tag, title, duplicateCount: els.length }
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (occurrences.length) {
|
|
102
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
103
|
+
}
|
|
104
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check img-alt-present
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Accessible <img> elements must have an alt attribute
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <img> elements that are exposed to assistive technologies.
|
|
11
|
+
* Images with role="presentation" or role="none" are excluded only when they are not focusable.
|
|
12
|
+
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
13
|
+
* if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
14
|
+
* @expectation
|
|
15
|
+
* Each applicable <img> element has an alt attribute.
|
|
16
|
+
* The alt attribute may be empty (alt="").
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const id = 'img-alt-present';
|
|
20
|
+
|
|
21
|
+
const meta = {
|
|
22
|
+
title: '<img> must have an alt attribute',
|
|
23
|
+
description: 'Checks that <img> elements provide an alt attribute to support a text alternative mechanism.',
|
|
24
|
+
i18n: {
|
|
25
|
+
titleKey: 'img_altPresent_title',
|
|
26
|
+
descriptionKey: 'img_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': ['img-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 getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
51
|
+
|
|
52
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
53
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
54
|
+
const getAriaNameInfo = helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
55
|
+
|
|
56
|
+
// Prefer tagName collection when available (cheap), otherwise fall back.
|
|
57
|
+
function getImgsCollection() {
|
|
58
|
+
try {
|
|
59
|
+
if (queryAllSmart) {
|
|
60
|
+
const r = queryAllSmart('img');
|
|
61
|
+
return Array.isArray(r) ? r : Array.from(r || []);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// fall through
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (safeRoot && typeof safeRoot.getElementsByTagName === 'function') {
|
|
69
|
+
return safeRoot.getElementsByTagName('img'); // HTMLCollection (live)
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// fall through
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return safeRoot.querySelectorAll('img');
|
|
77
|
+
} catch {
|
|
78
|
+
// fall through
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const imgs = getImgsCollection();
|
|
85
|
+
const imgLen = imgs && typeof imgs.length === 'number' ? imgs.length : 0;
|
|
86
|
+
|
|
87
|
+
if (!imgLen) {
|
|
88
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const occurrences = [];
|
|
92
|
+
let applicableCount = 0;
|
|
93
|
+
|
|
94
|
+
// Helper-safe trim
|
|
95
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
96
|
+
|
|
97
|
+
// Deterministic tabindex fallback (only used if helper missing)
|
|
98
|
+
function isFocusableFallback(el) {
|
|
99
|
+
try {
|
|
100
|
+
const tabindex = el.getAttribute('tabindex');
|
|
101
|
+
if (tabindex == null) return false;
|
|
102
|
+
const s = String(tabindex).trim();
|
|
103
|
+
if (!s) return false;
|
|
104
|
+
return !Number.isNaN(Number(s));
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < imgLen; i++) {
|
|
111
|
+
const el = imgs[i];
|
|
112
|
+
if (!el || !el.getAttribute) continue;
|
|
113
|
+
|
|
114
|
+
// Eligibility: only imgs exposed to assistive tech (with focusable/IDREF exceptions handled by helper)
|
|
115
|
+
if (isAccTreeEligible) {
|
|
116
|
+
let elig;
|
|
117
|
+
try {
|
|
118
|
+
elig = isAccTreeEligible(el, ctx);
|
|
119
|
+
} catch {
|
|
120
|
+
elig = null;
|
|
121
|
+
}
|
|
122
|
+
if (elig && elig.eligible === false) continue;
|
|
123
|
+
// If helper returns boolean, treat false as ineligible.
|
|
124
|
+
if (typeof elig === 'boolean' && elig === false) continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Role (presentation/none) exclusion only when NOT focusable.
|
|
128
|
+
let role = '';
|
|
129
|
+
try {
|
|
130
|
+
role = trim(el.getAttribute('role')).toLowerCase();
|
|
131
|
+
} catch {
|
|
132
|
+
role = '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (role === 'presentation' || role === 'none') {
|
|
136
|
+
let focusable = false;
|
|
137
|
+
if (getFocusableInfo) {
|
|
138
|
+
try {
|
|
139
|
+
const fi = getFocusableInfo(el, ctx);
|
|
140
|
+
// Preserve existing behavior: prefer fi.focusable; fall back to fi.tabbable if present.
|
|
141
|
+
focusable = !!(fi && (fi.focusable || fi.tabbable));
|
|
142
|
+
} catch {
|
|
143
|
+
focusable = false;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
focusable = isFocusableFallback(el);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!focusable) continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// From here: applicable
|
|
153
|
+
applicableCount += 1;
|
|
154
|
+
|
|
155
|
+
// alt attribute presence check (empty allowed)
|
|
156
|
+
let hasAlt = false;
|
|
157
|
+
try {
|
|
158
|
+
hasAlt = el.getAttribute('alt') !== null;
|
|
159
|
+
} catch {
|
|
160
|
+
hasAlt = false;
|
|
161
|
+
}
|
|
162
|
+
if (hasAlt) continue;
|
|
163
|
+
|
|
164
|
+
// aria-label / aria-labelledby is also a valid, standards-recognized
|
|
165
|
+
// text-alternative mechanism for <img> (HTML-AAM accessible name
|
|
166
|
+
// computation includes ARIA naming before falling back to alt); an
|
|
167
|
+
// image with a non-empty ARIA name is not missing a text alternative
|
|
168
|
+
// just because it lacks an alt attribute.
|
|
169
|
+
if (getAriaNameInfo) {
|
|
170
|
+
let ariaName = null;
|
|
171
|
+
try {
|
|
172
|
+
ariaName = getAriaNameInfo(el, ctx);
|
|
173
|
+
} catch {
|
|
174
|
+
ariaName = null;
|
|
175
|
+
}
|
|
176
|
+
if (ariaName && ariaName.present) continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
180
|
+
// source for <img> once alt is entirely absent (not merely alt="",
|
|
181
|
+
// which explicitly marks decorative and stays excluded from this
|
|
182
|
+
// branch since hasAlt already short-circuited above) -- confirmed
|
|
183
|
+
// against a widely-used reference engine's own image-alt rule, which lists a non-empty
|
|
184
|
+
// title as one of its "any" satisfying conditions alongside has-alt/
|
|
185
|
+
// aria-label/aria-labelledby. Found via a real page (AliExpress's
|
|
186
|
+
// logo, 2026-07-23): <img src="..." title="...">, no alt attribute
|
|
187
|
+
// at all -- a real false positive, not a missing text alternative.
|
|
188
|
+
const title = trim(el.getAttribute('title'));
|
|
189
|
+
if (title) continue;
|
|
190
|
+
|
|
191
|
+
const eligInfo = getEligibilityInfo ? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })() : null;
|
|
192
|
+
|
|
193
|
+
const baseOccurrence = {
|
|
194
|
+
summary: 'Missing alt attribute on <img>.',
|
|
195
|
+
hint: 'Add an alt attribute (use alt="" only for decorative images).',
|
|
196
|
+
i18n: {
|
|
197
|
+
summaryKey: 'img_altPresent_summary_fail',
|
|
198
|
+
hintKey: 'img_altPresent_hint_fail',
|
|
199
|
+
params: {}
|
|
200
|
+
},
|
|
201
|
+
data: { visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] } }
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
205
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
206
|
+
} else {
|
|
207
|
+
// Never compute selector/snippet in the rule.
|
|
208
|
+
occurrences.push({ __node: el, selector: '', html: '', ...baseOccurrence });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (applicableCount === 0) {
|
|
213
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!occurrences.length) {
|
|
217
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { id, meta, runInPage };
|