@surea11y/core 1.1.2 → 1.3.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 +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +50 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +125 -29
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +50 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +39 -9
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +43 -19
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +117 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +26 -7
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -12
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +25 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +11 -9
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -37,7 +37,13 @@ const meta = {
|
|
|
37
37
|
tags: ['wcag2a', 'wcag111', 'media', 'video', 'atomic', 'automatic'],
|
|
38
38
|
wcagSc: ['1.1.1'],
|
|
39
39
|
normativeMappings: [
|
|
40
|
-
{
|
|
40
|
+
{
|
|
41
|
+
standard: 'WCAG',
|
|
42
|
+
version: '2.2',
|
|
43
|
+
requirement: '1.1.1',
|
|
44
|
+
title: 'Non-text Content',
|
|
45
|
+
conformanceLevel: 'A'
|
|
46
|
+
}
|
|
41
47
|
],
|
|
42
48
|
defaultSeverity: 'serious',
|
|
43
49
|
category: 'perceivable',
|
|
@@ -54,32 +60,28 @@ function runInPage(ctx) {
|
|
|
54
60
|
const { document, root, helpers, rule } = ctx;
|
|
55
61
|
const safeRoot = root || document;
|
|
56
62
|
|
|
57
|
-
const reportOccurrence =
|
|
58
|
-
|
|
59
|
-
: null;
|
|
63
|
+
const reportOccurrence =
|
|
64
|
+
helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
|
|
60
65
|
|
|
61
|
-
const queryAllSmart =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
: null;
|
|
66
|
+
const queryAllSmart =
|
|
67
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
68
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function' ? helpers.queryAll : null;
|
|
65
69
|
|
|
66
|
-
const getEligibilityInfo =
|
|
67
|
-
? helpers.getEligibilityInfo
|
|
68
|
-
: null;
|
|
70
|
+
const getEligibilityInfo =
|
|
71
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
69
72
|
|
|
70
|
-
const isAccTreeEligible =
|
|
71
|
-
? helpers.isAccTreeEligible
|
|
72
|
-
: null;
|
|
73
|
+
const isAccTreeEligible =
|
|
74
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
73
75
|
|
|
74
|
-
const isFocusableInfo =
|
|
75
|
-
? helpers.getFocusableInfo
|
|
76
|
-
: null;
|
|
76
|
+
const isFocusableInfo =
|
|
77
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
77
78
|
|
|
78
|
-
const getAriaNameInfo =
|
|
79
|
-
? helpers.getAriaNameInfo
|
|
80
|
-
: null;
|
|
79
|
+
const getAriaNameInfo =
|
|
80
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
81
81
|
|
|
82
|
-
function trim(v) {
|
|
82
|
+
function trim(v) {
|
|
83
|
+
return (v == null ? '' : String(v)).trim();
|
|
84
|
+
}
|
|
83
85
|
|
|
84
86
|
function computeNameInfo(el) {
|
|
85
87
|
// <video> is not a labelable element (no browser computes an accessible
|
|
@@ -89,11 +91,22 @@ function runInPage(ctx) {
|
|
|
89
91
|
let aria = null;
|
|
90
92
|
|
|
91
93
|
if (getAriaNameInfo) {
|
|
92
|
-
aria = (() => {
|
|
94
|
+
aria = (() => {
|
|
95
|
+
try {
|
|
96
|
+
return getAriaNameInfo(el, ctx);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
})();
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
if (aria && aria.present && trim(aria.value)) {
|
|
96
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
present: true,
|
|
106
|
+
value: trim(aria.value),
|
|
107
|
+
mechanism: aria.mechanism || 'aria',
|
|
108
|
+
flags: (aria.flags || []).slice(0)
|
|
109
|
+
};
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
const title = trim(el.getAttribute && el.getAttribute('title'));
|
|
@@ -113,14 +126,22 @@ function runInPage(ctx) {
|
|
|
113
126
|
try {
|
|
114
127
|
if (queryAllSmart) return Array.from(queryAllSmart('video') || []);
|
|
115
128
|
if (queryAll) return Array.from(queryAll('video') || []);
|
|
116
|
-
return safeRoot && safeRoot.querySelectorAll
|
|
129
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
130
|
+
? Array.from(safeRoot.querySelectorAll('video'))
|
|
131
|
+
: [];
|
|
117
132
|
} catch {
|
|
118
|
-
try {
|
|
119
|
-
|
|
133
|
+
try {
|
|
134
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
135
|
+
? Array.from(safeRoot.querySelectorAll('video'))
|
|
136
|
+
: [];
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
120
140
|
}
|
|
121
141
|
})();
|
|
122
142
|
|
|
123
|
-
if (!videos.length)
|
|
143
|
+
if (!videos.length)
|
|
144
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
124
145
|
|
|
125
146
|
const occurrences = [];
|
|
126
147
|
let applicableCount = 0;
|
|
@@ -134,7 +155,11 @@ function runInPage(ctx) {
|
|
|
134
155
|
// Eligibility: only elements exposed to AT (with helper exceptions)
|
|
135
156
|
if (isAccTreeEligible) {
|
|
136
157
|
const elig = (() => {
|
|
137
|
-
try {
|
|
158
|
+
try {
|
|
159
|
+
return isAccTreeEligible(el, ctx);
|
|
160
|
+
} catch {
|
|
161
|
+
return { eligible: true, reasons: [] };
|
|
162
|
+
}
|
|
138
163
|
})();
|
|
139
164
|
if (elig && elig.eligible === false) continue;
|
|
140
165
|
}
|
|
@@ -142,13 +167,20 @@ function runInPage(ctx) {
|
|
|
142
167
|
// Role presentation/none excluded ONLY if not focusable (mirrors img behavior)
|
|
143
168
|
const role = trim(el.getAttribute('role')).toLowerCase();
|
|
144
169
|
if (role === 'presentation' || role === 'none') {
|
|
145
|
-
let focusable
|
|
170
|
+
let focusable;
|
|
146
171
|
if (isFocusableInfo) {
|
|
147
|
-
const fi = (() => {
|
|
172
|
+
const fi = (() => {
|
|
173
|
+
try {
|
|
174
|
+
return isFocusableInfo(el, ctx);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
148
179
|
focusable = !!(fi && fi.focusable);
|
|
149
180
|
} else {
|
|
150
181
|
const tabindex = el.getAttribute('tabindex');
|
|
151
|
-
focusable =
|
|
182
|
+
focusable =
|
|
183
|
+
tabindex != null && trim(tabindex) !== '' && !Number.isNaN(Number(trim(tabindex)));
|
|
152
184
|
}
|
|
153
185
|
if (!focusable) continue;
|
|
154
186
|
}
|
|
@@ -160,12 +192,16 @@ function runInPage(ctx) {
|
|
|
160
192
|
|
|
161
193
|
let eligInfo = null;
|
|
162
194
|
if (getEligibilityInfo) {
|
|
163
|
-
try {
|
|
195
|
+
try {
|
|
196
|
+
eligInfo = getEligibilityInfo(el, ctx, { targetSet: 'acc' });
|
|
197
|
+
} catch {
|
|
198
|
+
eligInfo = null;
|
|
199
|
+
}
|
|
164
200
|
}
|
|
165
201
|
|
|
166
202
|
const baseOccurrence = {
|
|
167
203
|
summary: 'Missing text alternative for <video> poster.',
|
|
168
|
-
hint: 'Provide an accessible name (
|
|
204
|
+
hint: 'Provide an accessible name for the poster image (aria-label/aria-labelledby preferred, or a title attribute as a fallback).',
|
|
169
205
|
i18n: {
|
|
170
206
|
summaryKey: 'videoPoster_textAltPresent_summary_fail',
|
|
171
207
|
hintKey: 'videoPoster_textAltPresent_hint_fail',
|
|
@@ -185,9 +221,16 @@ function runInPage(ctx) {
|
|
|
185
221
|
}
|
|
186
222
|
}
|
|
187
223
|
|
|
188
|
-
if (applicableCount === 0)
|
|
189
|
-
|
|
190
|
-
|
|
224
|
+
if (applicableCount === 0)
|
|
225
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
226
|
+
if (!occurrences.length)
|
|
227
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
228
|
+
return {
|
|
229
|
+
ruleId: rule.ruleId,
|
|
230
|
+
outcome: 'fail',
|
|
231
|
+
severity: rule.defaultSeverity || 'minor',
|
|
232
|
+
occurrences
|
|
233
|
+
};
|
|
191
234
|
}
|
|
192
235
|
|
|
193
236
|
module.exports = { id, meta, runInPage };
|
|
@@ -40,8 +40,10 @@ const meta = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
function runInPage(ctx) {
|
|
43
|
-
const {
|
|
44
|
-
const nodes = helpers.queryAllSmart
|
|
43
|
+
const { helpers, rule } = ctx;
|
|
44
|
+
const nodes = helpers.queryAllSmart
|
|
45
|
+
? helpers.queryAllSmart('[accesskey]')
|
|
46
|
+
: helpers.queryAll('[accesskey]');
|
|
45
47
|
|
|
46
48
|
const groups = new Map(); // normalized key -> elements[]
|
|
47
49
|
for (const el of nodes) {
|
|
@@ -59,12 +61,14 @@ function runInPage(ctx) {
|
|
|
59
61
|
if (els.length <= 1) continue;
|
|
60
62
|
for (const el of els) {
|
|
61
63
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
62
|
-
const html = helpers.getOuterHtmlSnippet
|
|
64
|
+
const html = helpers.getOuterHtmlSnippet
|
|
65
|
+
? helpers.getOuterHtmlSnippet(el)
|
|
66
|
+
: el.outerHTML || '';
|
|
63
67
|
|
|
64
68
|
occurrences.push({
|
|
65
69
|
selector: stableSelector,
|
|
66
70
|
html,
|
|
67
|
-
summary:
|
|
71
|
+
summary: "This element's accesskey is shared with another element on the page.",
|
|
68
72
|
hint: 'Make each accesskey value unique across the page.',
|
|
69
73
|
i18n: {
|
|
70
74
|
summaryKey: 'accesskeys_summary_cantTell',
|
|
@@ -81,7 +85,12 @@ function runInPage(ctx) {
|
|
|
81
85
|
if (!occurrences.length) {
|
|
82
86
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
83
87
|
}
|
|
84
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
ruleId: rule.ruleId,
|
|
90
|
+
outcome: 'cantTell',
|
|
91
|
+
severity: rule.defaultSeverity || 'minor',
|
|
92
|
+
occurrences
|
|
93
|
+
};
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
module.exports = { id, meta, runInPage };
|
|
@@ -11,237 +11,236 @@
|
|
|
11
11
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const id =
|
|
14
|
+
const id = 'area-alt-decorative';
|
|
15
15
|
|
|
16
16
|
const meta = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
title: '<area> with alt="" must be decorative (manual review)',
|
|
18
|
+
description:
|
|
19
|
+
'Flags <area> elements with empty alt for human review that they are decorative/non-informative.',
|
|
20
|
+
i18n: {
|
|
21
|
+
titleKey: 'area_altDecorative_title',
|
|
22
|
+
descriptionKey: 'area_altDecorative_description'
|
|
23
|
+
},
|
|
24
|
+
helpUrl: null,
|
|
25
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'images', 'imagemap', 'manual', 'atomic'],
|
|
26
|
+
wcagSc: ['1.1.1'],
|
|
27
|
+
normativeMappings: [
|
|
28
|
+
{
|
|
29
|
+
standard: 'WCAG',
|
|
30
|
+
version: '2.2',
|
|
31
|
+
requirement: '1.1.1',
|
|
32
|
+
title: 'Non-text Content',
|
|
33
|
+
conformanceLevel: 'A'
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
defaultSeverity: 'minor',
|
|
37
|
+
category: 'perceivable',
|
|
38
|
+
type: 'manual',
|
|
39
|
+
defaultConfidence: 'medium',
|
|
40
|
+
coverage: {
|
|
41
|
+
facetsBySc: {
|
|
42
|
+
'1.1.1': ['text-alternative-quality']
|
|
37
43
|
}
|
|
44
|
+
}
|
|
38
45
|
};
|
|
39
46
|
|
|
40
47
|
function runInPage(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
const { document, root, helpers, rule } = ctx;
|
|
49
|
+
const safeRoot = root || document;
|
|
50
|
+
|
|
51
|
+
const queryAllSmart =
|
|
52
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
53
|
+
const queryAll =
|
|
54
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
55
|
+
? helpers.queryAll
|
|
56
|
+
: (sel) => {
|
|
57
|
+
try {
|
|
58
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
59
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
60
|
+
: [];
|
|
61
|
+
} catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
53
64
|
};
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return res;
|
|
66
|
+
const __accEligCache = new WeakMap();
|
|
67
|
+
function accEligibleCached(node) {
|
|
68
|
+
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
69
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
70
|
+
const cached = __accEligCache.get(node);
|
|
71
|
+
if (cached) return cached;
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = isAccTreeEligible(node, ctx);
|
|
75
|
+
} catch {
|
|
76
|
+
res = { eligible: true, reasons: [] };
|
|
67
77
|
}
|
|
78
|
+
res = res && typeof res === 'object' ? res : { eligible: !!res, reasons: [] };
|
|
79
|
+
__accEligCache.set(node, res);
|
|
80
|
+
return res;
|
|
81
|
+
}
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: null;
|
|
72
|
-
|
|
73
|
-
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
74
|
-
? helpers.isAccTreeEligible
|
|
75
|
-
: null;
|
|
83
|
+
const getEligibilityInfo =
|
|
84
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
76
85
|
|
|
86
|
+
const isAccTreeEligible =
|
|
87
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
77
88
|
|
|
78
|
-
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
89
|
+
// --- image-map semantics (rule-local; match automatic <area> applicability) ---
|
|
90
|
+
function normUsemap(val) {
|
|
91
|
+
try {
|
|
92
|
+
const t = String(val || '').trim();
|
|
93
|
+
if (!t) return '';
|
|
94
|
+
return t[0] === '#' ? t.slice(1).trim().toLowerCase() : t.toLowerCase();
|
|
95
|
+
} catch {
|
|
96
|
+
return '';
|
|
87
97
|
}
|
|
98
|
+
}
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
100
|
+
function getMapName(mapEl) {
|
|
101
|
+
try {
|
|
102
|
+
if (!mapEl || !mapEl.getAttribute) return '';
|
|
103
|
+
const n = String(mapEl.getAttribute('name') || mapEl.getAttribute('id') || '').trim();
|
|
104
|
+
return n ? n.toLowerCase() : '';
|
|
105
|
+
} catch {
|
|
106
|
+
return '';
|
|
97
107
|
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const getFocusableInfo =
|
|
111
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
112
|
+
|
|
113
|
+
function isRolePresentationExcluded(el) {
|
|
114
|
+
const role = (() => {
|
|
115
|
+
try {
|
|
116
|
+
return String(el.getAttribute('role') || '')
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase();
|
|
119
|
+
} catch {
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
98
124
|
|
|
99
|
-
|
|
125
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
126
|
+
let focusable;
|
|
127
|
+
if (getFocusableInfo) {
|
|
128
|
+
const fi = (() => {
|
|
100
129
|
try {
|
|
101
|
-
|
|
102
|
-
const map = areaEl.closest('map');
|
|
103
|
-
if (!map) return null;
|
|
104
|
-
const mapName = getMapName(map);
|
|
105
|
-
if (!mapName) return null;
|
|
106
|
-
|
|
107
|
-
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
108
|
-
for (const img of imgs) {
|
|
109
|
-
const u = normUsemap(img.getAttribute('usemap'));
|
|
110
|
-
if (u && u === mapName) return img; // deterministic: first match in doc order
|
|
111
|
-
}
|
|
130
|
+
return getFocusableInfo(el, ctx);
|
|
112
131
|
} catch {
|
|
132
|
+
return null;
|
|
113
133
|
}
|
|
114
|
-
|
|
134
|
+
})();
|
|
135
|
+
focusable = !!(fi && fi.focusable);
|
|
136
|
+
} else {
|
|
137
|
+
const tabindex = el.getAttribute('tabindex');
|
|
138
|
+
focusable =
|
|
139
|
+
tabindex != null &&
|
|
140
|
+
String(tabindex).trim() !== '' &&
|
|
141
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
115
142
|
}
|
|
143
|
+
return !focusable;
|
|
144
|
+
}
|
|
116
145
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
let focusable = false;
|
|
133
|
-
if (getFocusableInfo) {
|
|
134
|
-
const fi = (() => {
|
|
135
|
-
try {
|
|
136
|
-
return getFocusableInfo(el, ctx);
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
})();
|
|
141
|
-
focusable = !!(fi && fi.focusable);
|
|
142
|
-
} else {
|
|
143
|
-
const tabindex = el.getAttribute('tabindex');
|
|
144
|
-
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
145
|
-
}
|
|
146
|
-
return !focusable;
|
|
146
|
+
const els = (() => {
|
|
147
|
+
try {
|
|
148
|
+
return Array.from((queryAllSmart ? queryAllSmart('area') : queryAll('area')) || []);
|
|
149
|
+
} catch {
|
|
150
|
+
return queryAll('area');
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
|
|
154
|
+
const __usemapIndex = new Map(); // mapName -> img (first in document order)
|
|
155
|
+
try {
|
|
156
|
+
const imgs = Array.from(document.querySelectorAll('img[usemap]'));
|
|
157
|
+
for (const img of imgs) {
|
|
158
|
+
const u = normUsemap(img.getAttribute('usemap'));
|
|
159
|
+
if (!u) continue;
|
|
160
|
+
if (!__usemapIndex.has(u)) __usemapIndex.set(u, img); // keep first match only
|
|
147
161
|
}
|
|
162
|
+
} catch {}
|
|
148
163
|
|
|
164
|
+
if (!els.length) {
|
|
165
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
166
|
+
}
|
|
149
167
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
})();
|
|
168
|
+
const occurrences = [];
|
|
169
|
+
let applicableCount = 0;
|
|
170
|
+
|
|
171
|
+
for (const el of els) {
|
|
172
|
+
if (!el || !el.getAttribute) continue;
|
|
157
173
|
|
|
158
|
-
|
|
174
|
+
// Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
|
|
175
|
+
let img;
|
|
159
176
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (!u) continue;
|
|
164
|
-
if (!__usemapIndex.has(u)) __usemapIndex.set(u, img); // keep first match only
|
|
165
|
-
}
|
|
177
|
+
const map = el.closest && el.closest('map');
|
|
178
|
+
const mapName = map ? getMapName(map) : '';
|
|
179
|
+
img = mapName ? __usemapIndex.get(mapName) || null : null;
|
|
166
180
|
} catch {
|
|
181
|
+
img = null;
|
|
167
182
|
}
|
|
183
|
+
if (!img) continue;
|
|
168
184
|
|
|
169
|
-
|
|
170
|
-
|
|
185
|
+
// The referencing <img> must be eligible in the accessibility tree.
|
|
186
|
+
if (isAccTreeEligible) {
|
|
187
|
+
const imgElig = accEligibleCached(img);
|
|
188
|
+
if (imgElig && imgElig.eligible === false) continue;
|
|
171
189
|
}
|
|
172
190
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
for (const el of els) {
|
|
177
|
-
if (!el || !el.getAttribute) continue;
|
|
178
|
-
|
|
179
|
-
// Must belong to a *used* image map (referenced by an <img usemap>). If unused, not applicable.
|
|
180
|
-
let img = null;
|
|
191
|
+
if (isAccTreeEligible) {
|
|
192
|
+
const elig = (() => {
|
|
181
193
|
try {
|
|
182
|
-
|
|
183
|
-
const mapName = map ? getMapName(map) : '';
|
|
184
|
-
img = mapName ? (__usemapIndex.get(mapName) || null) : null;
|
|
194
|
+
return isAccTreeEligible(el, ctx);
|
|
185
195
|
} catch {
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
if (!img) continue;
|
|
189
|
-
|
|
190
|
-
// The referencing <img> must be eligible in the accessibility tree.
|
|
191
|
-
if (isAccTreeEligible) {
|
|
192
|
-
const imgElig = accEligibleCached(img);
|
|
193
|
-
if (imgElig && imgElig.eligible === false) continue;
|
|
196
|
+
return { eligible: true, reasons: [] };
|
|
194
197
|
}
|
|
198
|
+
})();
|
|
199
|
+
if (elig && elig.eligible === false) continue;
|
|
200
|
+
}
|
|
195
201
|
|
|
196
|
-
|
|
197
|
-
const elig = (() => {
|
|
198
|
-
try {
|
|
199
|
-
return isAccTreeEligible(el, ctx);
|
|
200
|
-
} catch {
|
|
201
|
-
return {eligible: true, reasons: []};
|
|
202
|
-
}
|
|
203
|
-
})();
|
|
204
|
-
if (elig && elig.eligible === false) continue;
|
|
205
|
-
}
|
|
202
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
206
203
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (String(alt).trim() !== '') continue; // only alt=""
|
|
214
|
-
|
|
215
|
-
applicableCount += 1;
|
|
216
|
-
|
|
217
|
-
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, {targetSet: 'acc'}) : null;
|
|
218
|
-
|
|
219
|
-
const baseOccurrence = {
|
|
220
|
-
summary: 'Review whether <area> is decorative (alt="").',
|
|
221
|
-
hint: 'Confirm the area does not convey information or function. If it is interactive or meaningful, provide meaningful alt text.',
|
|
222
|
-
i18n: {
|
|
223
|
-
summaryKey: 'area_altDecorative_summary_cantTell',
|
|
224
|
-
hintKey: 'area_altDecorative_hint_cantTell',
|
|
225
|
-
params: {element: (el.tagName || '').toLowerCase()}
|
|
226
|
-
},
|
|
227
|
-
data: {
|
|
228
|
-
visibilityFilter: eligInfo || {targetSet: 'acc', accEligible: null, reasons: []},
|
|
229
|
-
details: null
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
234
|
-
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
235
|
-
} else {
|
|
236
|
-
occurrences.push({selector: '', html: '', ...baseOccurrence});
|
|
237
|
-
}
|
|
204
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
205
|
+
let alt;
|
|
206
|
+
try {
|
|
207
|
+
alt = el.getAttribute('alt');
|
|
208
|
+
} catch {
|
|
209
|
+
alt = null;
|
|
238
210
|
}
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
211
|
+
if (alt === null) continue;
|
|
212
|
+
if (String(alt).trim() !== '') continue; // only alt=""
|
|
213
|
+
|
|
214
|
+
applicableCount += 1;
|
|
215
|
+
|
|
216
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
217
|
+
|
|
218
|
+
const baseOccurrence = {
|
|
219
|
+
summary: 'Review whether <area> is decorative (alt="").',
|
|
220
|
+
hint: 'Confirm the area does not convey information or function. If it is interactive or meaningful, provide meaningful alt text.',
|
|
221
|
+
i18n: {
|
|
222
|
+
summaryKey: 'area_altDecorative_summary_cantTell',
|
|
223
|
+
hintKey: 'area_altDecorative_hint_cantTell',
|
|
224
|
+
params: { element: (el.tagName || '').toLowerCase() }
|
|
225
|
+
},
|
|
226
|
+
data: {
|
|
227
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
228
|
+
details: null
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
233
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
234
|
+
} else {
|
|
235
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
242
236
|
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (applicableCount === 0) {
|
|
240
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
241
|
+
}
|
|
243
242
|
|
|
244
|
-
|
|
243
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
245
244
|
}
|
|
246
245
|
|
|
247
|
-
module.exports = {id, meta, runInPage};
|
|
246
|
+
module.exports = { id, meta, runInPage };
|