@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,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check meta-refresh-timing-absent
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <meta http-equiv="refresh"> must not impose a delayed page refresh
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.2.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <meta http-equiv="refresh"> elements that carry a non-empty
|
|
11
|
+
* content attribute with a parseable leading delay value.
|
|
12
|
+
* @expectation
|
|
13
|
+
* The delay is 0 (an immediate redirect, which users cannot be caught by
|
|
14
|
+
* mid-read), or exceeds 20 hours. Any other positive delay refreshes or
|
|
15
|
+
* redirects the page on a timer the user did not initiate and cannot
|
|
16
|
+
* pause, stop, or extend, which WCAG 2.2.1 (Timing Adjustable) requires
|
|
17
|
+
* be possible.
|
|
18
|
+
* @implementation-notes
|
|
19
|
+
* - An unparseable content value (no leading numeric delay) is not
|
|
20
|
+
* flagged — this rule only reports a clearly-detected timed refresh,
|
|
21
|
+
* matching this engine's no-false-positives policy.
|
|
22
|
+
* - WCAG 2.2.1 Exception 3 exempts time limits longer than 20 hours (the
|
|
23
|
+
* rationale being that a delay this long gives users enough real-world
|
|
24
|
+
* time to act, so "adjustable" ceases to be a meaningful requirement).
|
|
25
|
+
* A delay over 72000 seconds is therefore not flagged.
|
|
26
|
+
* - A <meta> nested inside <noscript> is excluded — see
|
|
27
|
+
* meta-refresh-no-exceptions's header comment for why.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const id = 'meta-refresh-timing-absent';
|
|
31
|
+
|
|
32
|
+
const meta = {
|
|
33
|
+
title: 'Page must not use a timed meta refresh',
|
|
34
|
+
description: 'Checks that <meta http-equiv="refresh"> does not impose a positive delay of 20 hours or less.',
|
|
35
|
+
i18n: {
|
|
36
|
+
titleKey: 'metaRefreshTimingAbsent_title',
|
|
37
|
+
descriptionKey: 'metaRefreshTimingAbsent_description'
|
|
38
|
+
},
|
|
39
|
+
helpUrl: null,
|
|
40
|
+
tags: ['wcag2a', 'wcag221', 'structure', 'atomic', 'automatic'],
|
|
41
|
+
wcagSc: ['2.2.1'],
|
|
42
|
+
normativeMappings: [
|
|
43
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.2.1', title: 'Timing Adjustable', conformanceLevel: 'A' }
|
|
44
|
+
],
|
|
45
|
+
defaultSeverity: 'serious',
|
|
46
|
+
category: 'operable',
|
|
47
|
+
type: 'automatic',
|
|
48
|
+
defaultConfidence: 'high',
|
|
49
|
+
coverage: { facetsBySc: { '2.2.1': ['meta-refresh-timing-absent'] } }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function runInPage(ctx) {
|
|
53
|
+
const { document, helpers, rule } = ctx;
|
|
54
|
+
|
|
55
|
+
// WCAG 2.2.1 Exception 3: time limits longer than 20 hours are exempt.
|
|
56
|
+
const EXEMPT_DELAY_SECONDS = 20 * 60 * 60;
|
|
57
|
+
|
|
58
|
+
const nodes = document.querySelectorAll ? document.querySelectorAll('meta[http-equiv="refresh" i]') : [];
|
|
59
|
+
|
|
60
|
+
const occurrences = [];
|
|
61
|
+
let applicableCount = 0;
|
|
62
|
+
|
|
63
|
+
for (const el of nodes) {
|
|
64
|
+
if (!el || !el.getAttribute) continue;
|
|
65
|
+
if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
|
|
66
|
+
const raw = String(el.getAttribute('content') || '').trim();
|
|
67
|
+
if (!raw) continue;
|
|
68
|
+
|
|
69
|
+
const match = raw.match(/^([0-9]*\.?[0-9]+)/);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
const delay = parseFloat(match[1]);
|
|
72
|
+
if (Number.isNaN(delay)) continue;
|
|
73
|
+
|
|
74
|
+
applicableCount += 1;
|
|
75
|
+
|
|
76
|
+
if (!(delay > 0)) continue;
|
|
77
|
+
if (delay > EXEMPT_DELAY_SECONDS) continue; // WCAG 2.2.1 Exception 3 (>20 hours)
|
|
78
|
+
|
|
79
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
80
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
81
|
+
|
|
82
|
+
occurrences.push({
|
|
83
|
+
selector: stableSelector,
|
|
84
|
+
html,
|
|
85
|
+
summary: 'This page refreshes itself automatically after a delay.',
|
|
86
|
+
hint: 'Remove the timed meta refresh, or provide a way for users to turn it off, extend it, or pause it before it triggers.',
|
|
87
|
+
i18n: {
|
|
88
|
+
summaryKey: 'metaRefreshTimingAbsent_summary_fail',
|
|
89
|
+
hintKey: 'metaRefreshTimingAbsent_hint_fail',
|
|
90
|
+
params: { delay: String(delay) }
|
|
91
|
+
},
|
|
92
|
+
data: {
|
|
93
|
+
details: { reasonCode: 'META_REFRESH_DELAYED', delay }
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (applicableCount === 0) {
|
|
99
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
100
|
+
}
|
|
101
|
+
if (occurrences.length) {
|
|
102
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
103
|
+
}
|
|
104
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check meta-viewport-zoom-enabled
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <meta name="viewport"> must not disable or cap pinch-zoom below 200%
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.4.4
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <meta name="viewport"> elements that carry a non-empty
|
|
11
|
+
* content attribute.
|
|
12
|
+
* @expectation
|
|
13
|
+
* The content attribute does not set user-scalable to "no"/"0", and does
|
|
14
|
+
* not set maximum-scale below 2 (200%). Either disables the user's
|
|
15
|
+
* ability to zoom text to at least 200%, which WCAG 1.4.4 (Resize Text)
|
|
16
|
+
* requires be possible.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Malformed/unparseable maximum-scale values are ignored (not flagged) —
|
|
19
|
+
* this rule only reports a clearly-detected zoom restriction, matching
|
|
20
|
+
* this engine's no-false-positives policy.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const id = 'meta-viewport-zoom-enabled';
|
|
24
|
+
|
|
25
|
+
const meta = {
|
|
26
|
+
title: 'Viewport meta tag must not disable zoom',
|
|
27
|
+
description: 'Checks that <meta name="viewport"> does not set user-scalable=no or maximum-scale below 2 (200%).',
|
|
28
|
+
i18n: {
|
|
29
|
+
titleKey: 'metaViewportZoomEnabled_title',
|
|
30
|
+
descriptionKey: 'metaViewportZoomEnabled_description'
|
|
31
|
+
},
|
|
32
|
+
helpUrl: null,
|
|
33
|
+
tags: ['wcag2aa', 'wcag144', 'structure', 'atomic', 'automatic'],
|
|
34
|
+
wcagSc: ['1.4.4'],
|
|
35
|
+
normativeMappings: [
|
|
36
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.4.4', title: 'Resize Text', conformanceLevel: 'AA' }
|
|
37
|
+
],
|
|
38
|
+
defaultSeverity: 'serious',
|
|
39
|
+
category: 'perceivable',
|
|
40
|
+
type: 'automatic',
|
|
41
|
+
defaultConfidence: 'high',
|
|
42
|
+
coverage: { facetsBySc: { '1.4.4': ['meta-viewport-zoom-enabled'] } }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function runInPage(ctx) {
|
|
46
|
+
const { document, helpers, rule } = ctx;
|
|
47
|
+
|
|
48
|
+
function parseContent(raw) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const pair of String(raw || '').split(/[,;]/)) {
|
|
51
|
+
const eq = pair.indexOf('=');
|
|
52
|
+
if (eq === -1) continue;
|
|
53
|
+
const key = pair.slice(0, eq).trim().toLowerCase();
|
|
54
|
+
const value = pair.slice(eq + 1).trim().toLowerCase();
|
|
55
|
+
if (key) out[key] = value;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nodes = document.querySelectorAll ? document.querySelectorAll('meta[name="viewport" i]') : [];
|
|
61
|
+
|
|
62
|
+
const occurrences = [];
|
|
63
|
+
let applicableCount = 0;
|
|
64
|
+
|
|
65
|
+
for (const el of nodes) {
|
|
66
|
+
if (!el || !el.getAttribute) continue;
|
|
67
|
+
const raw = String(el.getAttribute('content') || '').trim();
|
|
68
|
+
if (!raw) continue;
|
|
69
|
+
|
|
70
|
+
applicableCount += 1;
|
|
71
|
+
|
|
72
|
+
const parsed = parseContent(raw);
|
|
73
|
+
const reasons = [];
|
|
74
|
+
|
|
75
|
+
const userScalable = parsed['user-scalable'];
|
|
76
|
+
if (userScalable === 'no' || userScalable === '0') {
|
|
77
|
+
reasons.push('user-scalable=' + userScalable);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const maxScaleRaw = parsed['maximum-scale'];
|
|
81
|
+
if (maxScaleRaw !== undefined) {
|
|
82
|
+
const maxScale = parseFloat(maxScaleRaw);
|
|
83
|
+
if (!Number.isNaN(maxScale) && maxScale < 2) {
|
|
84
|
+
reasons.push('maximum-scale=' + maxScaleRaw);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!reasons.length) continue;
|
|
89
|
+
|
|
90
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
91
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
92
|
+
|
|
93
|
+
occurrences.push({
|
|
94
|
+
selector: stableSelector,
|
|
95
|
+
html,
|
|
96
|
+
summary: 'This viewport meta tag restricts the user’s ability to zoom.',
|
|
97
|
+
hint: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta content.',
|
|
98
|
+
i18n: {
|
|
99
|
+
summaryKey: 'metaViewportZoomEnabled_summary_fail',
|
|
100
|
+
hintKey: 'metaViewportZoomEnabled_hint_fail',
|
|
101
|
+
params: { reasons: reasons.join(', ') }
|
|
102
|
+
},
|
|
103
|
+
data: {
|
|
104
|
+
details: { reasonCode: 'VIEWPORT_ZOOM_RESTRICTED', reasons }
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (applicableCount === 0) {
|
|
110
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
111
|
+
}
|
|
112
|
+
if (occurrences.length) {
|
|
113
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
114
|
+
}
|
|
115
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'meter-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Meters have an accessible name',
|
|
7
|
+
description: 'Checks that elements with role="meter" expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'meterNamePresent_title',
|
|
10
|
+
descriptionKey: 'meterNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a', 'wcag111', 'nontext', 'atomic', 'automatic', 'name'],
|
|
14
|
+
wcagSc: ['1.1.1'],
|
|
15
|
+
normativeMappings: [
|
|
16
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
17
|
+
],
|
|
18
|
+
defaultSeverity: 'serious',
|
|
19
|
+
category: 'perceivable',
|
|
20
|
+
type: 'automatic',
|
|
21
|
+
defaultConfidence: 'high',
|
|
22
|
+
coverage: { facetsBySc: { '1.1.1': ['meter-name-present'] } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runInPage(ctx) {
|
|
26
|
+
const { document, root, helpers, rule } = ctx;
|
|
27
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
|
+
? helpers.getEligibilityInfo
|
|
29
|
+
: null;
|
|
30
|
+
const safeRoot = root || document;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
function normalizeWs(s) {
|
|
34
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAttr(el, name) {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !el.getAttribute) return '';
|
|
40
|
+
return normalizeWs(el.getAttribute(name));
|
|
41
|
+
} catch { return ''; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getConservativeSubtreeText(document, container) {
|
|
45
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
46
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
47
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
48
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
49
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
50
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
51
|
+
// icon-button pattern).
|
|
52
|
+
if (helpers.getContentNameInfo) {
|
|
53
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
54
|
+
return info && info.present ? info.value : '';
|
|
55
|
+
}
|
|
56
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
57
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
61
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
62
|
+
if (!raw) return '';
|
|
63
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
64
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
65
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
66
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
67
|
+
// which name-from-content alone can never see).
|
|
68
|
+
if (helpers.getTextFromIdRefs) {
|
|
69
|
+
try {
|
|
70
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
71
|
+
return normalizeWs(r && r.text);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
78
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
79
|
+
if (!fn) return true;
|
|
80
|
+
try {
|
|
81
|
+
const r = fn(el, ctx);
|
|
82
|
+
if (typeof r === 'boolean') return r;
|
|
83
|
+
return !!(r && r.eligible);
|
|
84
|
+
} catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
const occurrences = [];
|
|
91
|
+
let applicableCount = 0;
|
|
92
|
+
|
|
93
|
+
const selector = '[role="meter"]';
|
|
94
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
function evaluate(el) {
|
|
98
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
99
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
100
|
+
|
|
101
|
+
const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
|
|
102
|
+
if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
|
|
103
|
+
|
|
104
|
+
const title = getAttr(el, 'title');
|
|
105
|
+
if (title) return { ok: true, method: 'title' };
|
|
106
|
+
|
|
107
|
+
// role="meter" is name-from-author-only per WAI-ARIA (verified against
|
|
108
|
+
// a widely-used reference engine's own aria-meter-name check: any: ['aria-label',
|
|
109
|
+
// 'aria-labelledby', title] — no content-based naming method at all).
|
|
110
|
+
// It must NOT fall back to subtree content — visible text near/inside a
|
|
111
|
+
// custom meter widget is not reliably exposed as its accessible name.
|
|
112
|
+
return { ok: false, method: 'none' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const el of nodes) {
|
|
116
|
+
if (!el) continue;
|
|
117
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
118
|
+
|
|
119
|
+
const role = getAttr(el, 'role').toLowerCase();
|
|
120
|
+
if (role !== 'meter') continue;
|
|
121
|
+
|
|
122
|
+
applicableCount += 1;
|
|
123
|
+
|
|
124
|
+
const res = evaluate(el);
|
|
125
|
+
if (res.ok) continue;
|
|
126
|
+
|
|
127
|
+
const eligInfo = getEligibilityInfo
|
|
128
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
129
|
+
: null;
|
|
130
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
131
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
132
|
+
|
|
133
|
+
occurrences.push({
|
|
134
|
+
selector: stableSelector,
|
|
135
|
+
html,
|
|
136
|
+
summary: 'This meter has no accessible name.',
|
|
137
|
+
hint: 'Provide meter text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
|
|
138
|
+
i18n: {
|
|
139
|
+
summaryKey: 'meterNamePresent_summary_fail',
|
|
140
|
+
hintKey: 'meterNamePresent_hint_fail',
|
|
141
|
+
params: {}
|
|
142
|
+
},
|
|
143
|
+
data: {
|
|
144
|
+
details: { reasonCode: 'name_missing', controlType: 'meter', methodTried: res.method },
|
|
145
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if (applicableCount === 0) {
|
|
152
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
153
|
+
}
|
|
154
|
+
if (occurrences.length) {
|
|
155
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
156
|
+
}
|
|
157
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check nested-interactive-controls-absent
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary An interactive control must not contain another interactive control
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 4.1.2
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements matching the interactive-control set (native
|
|
11
|
+
* a[href], button, input (not hidden), select, textarea; or an explicit
|
|
12
|
+
* ARIA widget role: button, link, checkbox, radio, switch, tab, textbox,
|
|
13
|
+
* combobox, listbox, menuitem, menuitemcheckbox, menuitemradio, option,
|
|
14
|
+
* slider, spinbutton, searchbox, treeitem).
|
|
15
|
+
* @expectation
|
|
16
|
+
* The element does not contain, as a descendant, another element from
|
|
17
|
+
* that same interactive-control set (e.g. a <button> wrapping a
|
|
18
|
+
* <select>, or a link containing a checkbox). Nested interactive
|
|
19
|
+
* controls are not reliably announced or operable via assistive
|
|
20
|
+
* technology — activating the outer control and the inner one become
|
|
21
|
+
* ambiguous, and some AT only exposes one of the two.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Matches a widely-used reference engine's nested-interactive. Reports on the outer
|
|
24
|
+
* (containing) control, not the nested descendant — this engine
|
|
25
|
+
* considers the container the fixable unit ("move the nested control
|
|
26
|
+
* outside this element").
|
|
27
|
+
* - A container can be reported once even with multiple nested
|
|
28
|
+
* descendants (listed together); a deeply nested chain (A > B > C, all
|
|
29
|
+
* interactive) reports both A and B as separate occurrences, since each
|
|
30
|
+
* genuinely contains a nested interactive control.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const id = 'nested-interactive-controls-absent';
|
|
34
|
+
|
|
35
|
+
const meta = {
|
|
36
|
+
title: 'Interactive controls must not be nested',
|
|
37
|
+
description: 'Checks that an interactive control (link, button, form control, or ARIA widget role) does not contain another interactive control.',
|
|
38
|
+
i18n: {
|
|
39
|
+
titleKey: 'nestedInteractiveControlsAbsent_title',
|
|
40
|
+
descriptionKey: 'nestedInteractiveControlsAbsent_description'
|
|
41
|
+
},
|
|
42
|
+
helpUrl: null,
|
|
43
|
+
tags: ['wcag2a', 'wcag412', 'structure', 'atomic', 'automatic'],
|
|
44
|
+
wcagSc: ['4.1.2'],
|
|
45
|
+
normativeMappings: [
|
|
46
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
47
|
+
],
|
|
48
|
+
defaultSeverity: 'serious',
|
|
49
|
+
category: 'robust',
|
|
50
|
+
type: 'automatic',
|
|
51
|
+
defaultConfidence: 'high',
|
|
52
|
+
coverage: { facetsBySc: { '4.1.2': ['nested-interactive-controls-absent'] } }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function runInPage(ctx) {
|
|
56
|
+
const { document, root, helpers, rule } = ctx;
|
|
57
|
+
const safeRoot = root || document;
|
|
58
|
+
|
|
59
|
+
// Declared inside runInPage — see scripts/build-core.js header
|
|
60
|
+
// ("runInPage MUST be self-contained").
|
|
61
|
+
const INTERACTIVE_SELECTOR = [
|
|
62
|
+
'a[href]',
|
|
63
|
+
'button',
|
|
64
|
+
'input:not([type="hidden"])',
|
|
65
|
+
'select',
|
|
66
|
+
'textarea',
|
|
67
|
+
'[role="button"]',
|
|
68
|
+
'[role="link"]',
|
|
69
|
+
'[role="checkbox"]',
|
|
70
|
+
'[role="radio"]',
|
|
71
|
+
'[role="switch"]',
|
|
72
|
+
'[role="tab"]',
|
|
73
|
+
'[role="textbox"]',
|
|
74
|
+
'[role="combobox"]',
|
|
75
|
+
'[role="listbox"]',
|
|
76
|
+
'[role="menuitem"]',
|
|
77
|
+
'[role="menuitemcheckbox"]',
|
|
78
|
+
'[role="menuitemradio"]',
|
|
79
|
+
'[role="option"]',
|
|
80
|
+
'[role="slider"]',
|
|
81
|
+
'[role="spinbutton"]',
|
|
82
|
+
'[role="searchbox"]',
|
|
83
|
+
'[role="treeitem"]'
|
|
84
|
+
].join(', ');
|
|
85
|
+
|
|
86
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(INTERACTIVE_SELECTOR, safeRoot) : helpers.queryAll(INTERACTIVE_SELECTOR, safeRoot);
|
|
87
|
+
|
|
88
|
+
const occurrences = [];
|
|
89
|
+
let applicableCount = 0;
|
|
90
|
+
|
|
91
|
+
for (const el of nodes) {
|
|
92
|
+
if (!el || !el.querySelectorAll) continue;
|
|
93
|
+
|
|
94
|
+
applicableCount += 1;
|
|
95
|
+
|
|
96
|
+
let nested = [];
|
|
97
|
+
try {
|
|
98
|
+
nested = Array.from(el.querySelectorAll(INTERACTIVE_SELECTOR));
|
|
99
|
+
} catch {
|
|
100
|
+
nested = [];
|
|
101
|
+
}
|
|
102
|
+
if (!nested.length) continue;
|
|
103
|
+
|
|
104
|
+
const nestedTags = nested.map((n) => (n && n.tagName ? n.tagName.toLowerCase() : 'unknown'));
|
|
105
|
+
|
|
106
|
+
const tag = el.tagName.toLowerCase();
|
|
107
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
108
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
109
|
+
|
|
110
|
+
occurrences.push({
|
|
111
|
+
selector: stableSelector,
|
|
112
|
+
html,
|
|
113
|
+
summary: 'This interactive control contains one or more other interactive controls.',
|
|
114
|
+
hint: 'Move the nested interactive control(s) outside this element; nested interactive controls are not reliably operable via assistive technology.',
|
|
115
|
+
i18n: {
|
|
116
|
+
summaryKey: 'nestedInteractiveControlsAbsent_summary_fail',
|
|
117
|
+
hintKey: 'nestedInteractiveControlsAbsent_hint_fail',
|
|
118
|
+
params: { element: tag, nestedElements: nestedTags.join(', ') }
|
|
119
|
+
},
|
|
120
|
+
data: {
|
|
121
|
+
details: { reasonCode: 'NESTED_INTERACTIVE_CONTROL', element: tag, nestedElements: nestedTags }
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (applicableCount === 0) {
|
|
127
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
128
|
+
}
|
|
129
|
+
if (occurrences.length) {
|
|
130
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
131
|
+
}
|
|
132
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { id, meta, runInPage };
|