@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,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check aria-text
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary role="text" elements should have no focusable descendants
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Elements with an explicit `role="text"`.
|
|
10
|
+
* @expectation
|
|
11
|
+
* `role="text"` tells assistive technology to treat an element's whole
|
|
12
|
+
* subtree as a single unit of plain text (e.g. text visually split
|
|
13
|
+
* across multiple `<span>`s by styling). Per the WAI-ARIA Authoring
|
|
14
|
+
* Practices, this only makes sense when that subtree contains no
|
|
15
|
+
* focusable content — a focusable descendant inside a "this is just
|
|
16
|
+
* text" region is unreachable or confusing for keyboard/AT users.
|
|
17
|
+
* @implementation-notes
|
|
18
|
+
* - Not WCAG-normative by a widely-used reference engine's own classification — authored as an
|
|
19
|
+
* advisory, cantTell-capped `type: 'manual'` rule, matching the Tier 1b
|
|
20
|
+
* precedent (see `landmark-unique`'s header comment for the shared
|
|
21
|
+
* rationale).
|
|
22
|
+
* - "Focusable descendant" is a presence check (link/button/form
|
|
23
|
+
* control/`[tabindex]`/`iframe`/`[contenteditable]`), the same
|
|
24
|
+
* conservative selector `scrollable-region-focusable` uses, not a full
|
|
25
|
+
* focusability computation (disabled state, visibility, etc.).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const id = 'aria-text';
|
|
29
|
+
|
|
30
|
+
const meta = {
|
|
31
|
+
title: 'role="text" elements should have no focusable descendants',
|
|
32
|
+
description: 'Checks that elements with role="text" contain no focusable descendant (link, button, form control, tabindex, iframe, or contenteditable).',
|
|
33
|
+
i18n: {
|
|
34
|
+
titleKey: 'ariaText_title',
|
|
35
|
+
descriptionKey: 'ariaText_description'
|
|
36
|
+
},
|
|
37
|
+
helpUrl: null,
|
|
38
|
+
tags: ['best-practice', 'aria', 'structure', 'atomic', 'manual'],
|
|
39
|
+
wcagSc: [],
|
|
40
|
+
normativeMappings: [],
|
|
41
|
+
defaultSeverity: 'minor',
|
|
42
|
+
category: 'robust',
|
|
43
|
+
type: 'manual',
|
|
44
|
+
defaultConfidence: 'medium',
|
|
45
|
+
coverage: {}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const FOCUSABLE_DESCENDANT_SELECTOR =
|
|
53
|
+
'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
|
|
54
|
+
|
|
55
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]', safeRoot) : helpers.queryAll('[role="text"]', safeRoot);
|
|
56
|
+
|
|
57
|
+
const occurrences = [];
|
|
58
|
+
let applicableCount = 0;
|
|
59
|
+
|
|
60
|
+
for (const el of nodes) {
|
|
61
|
+
if (!el || !el.querySelector) continue;
|
|
62
|
+
|
|
63
|
+
applicableCount += 1;
|
|
64
|
+
|
|
65
|
+
let focusableDescendant = null;
|
|
66
|
+
try {
|
|
67
|
+
focusableDescendant = el.querySelector(FOCUSABLE_DESCENDANT_SELECTOR);
|
|
68
|
+
} catch {
|
|
69
|
+
focusableDescendant = null;
|
|
70
|
+
}
|
|
71
|
+
if (!focusableDescendant) continue;
|
|
72
|
+
|
|
73
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
74
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
75
|
+
|
|
76
|
+
const baseOccurrence = {
|
|
77
|
+
selector: stableSelector,
|
|
78
|
+
html,
|
|
79
|
+
summary: 'This role="text" element contains a focusable descendant.',
|
|
80
|
+
hint: 'Remove role="text" (or remove the focusable descendant) — a "plain text" region should not contain focusable content.',
|
|
81
|
+
i18n: {
|
|
82
|
+
summaryKey: 'ariaText_summary_cantTell',
|
|
83
|
+
hintKey: 'ariaText_hint_cantTell',
|
|
84
|
+
params: {}
|
|
85
|
+
},
|
|
86
|
+
data: {
|
|
87
|
+
details: { reasonCode: 'ROLE_TEXT_HAS_FOCUSABLE_DESCENDANT' }
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
92
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
93
|
+
} else {
|
|
94
|
+
occurrences.push(baseOccurrence);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (applicableCount === 0) {
|
|
99
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (occurrences.length) {
|
|
103
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check canvas-text-alternative-quality
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Manual review: text alternative appropriateness (WCAG 1.1.1)
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.1.1
|
|
9
|
+
* @type manual
|
|
10
|
+
* @expectation
|
|
11
|
+
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const id = "canvas-text-alternative-quality";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "<canvas> text alternative must be appropriate (manual review)",
|
|
18
|
+
description: "Flags <canvas> elements with a detected text alternative for human review of equivalence and appropriateness.",
|
|
19
|
+
i18n: {
|
|
20
|
+
titleKey: "canvas_textAltQuality_title",
|
|
21
|
+
descriptionKey: "canvas_textAltQuality_description"
|
|
22
|
+
},
|
|
23
|
+
helpUrl: null,
|
|
24
|
+
tags: ["wcag2a", "wcag111", "nontext", "canvas", "manual", "atomic"],
|
|
25
|
+
wcagSc: ['1.1.1'],
|
|
26
|
+
normativeMappings: [
|
|
27
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.1.1', title: 'Non-text Content', conformanceLevel: 'A' }
|
|
28
|
+
],
|
|
29
|
+
defaultSeverity: 'minor',
|
|
30
|
+
category: 'perceivable',
|
|
31
|
+
type: 'manual',
|
|
32
|
+
defaultConfidence: 'medium',
|
|
33
|
+
coverage: {
|
|
34
|
+
facetsBySc: {
|
|
35
|
+
'1.1.1': ['text-alternative-quality']
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function runInPage(ctx) {
|
|
41
|
+
|
|
42
|
+
const { document, root, helpers, rule } = ctx;
|
|
43
|
+
const safeRoot = root || document;
|
|
44
|
+
|
|
45
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
46
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
47
|
+
? helpers.queryAll
|
|
48
|
+
: (sel) => {
|
|
49
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
50
|
+
catch { return []; }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
54
|
+
? helpers.getEligibilityInfo
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
58
|
+
? helpers.isAccTreeEligible
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
const __accEligCache = new WeakMap();
|
|
62
|
+
function accEligibleCached(node) {
|
|
63
|
+
if (!isAccTreeEligible) return { eligible: true, reasons: [] };
|
|
64
|
+
if (!node || typeof node !== 'object') return { eligible: true, reasons: [] };
|
|
65
|
+
const c = __accEligCache.get(node);
|
|
66
|
+
if (c) return c;
|
|
67
|
+
let r;
|
|
68
|
+
try { r = isAccTreeEligible(node, ctx); } catch { r = { eligible: true, reasons: [] }; }
|
|
69
|
+
r = r && typeof r === 'object' ? r : { eligible: !!r, reasons: [] };
|
|
70
|
+
__accEligCache.set(node, r);
|
|
71
|
+
return r;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
75
|
+
? helpers.getFocusableInfo
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
function isRolePresentationExcluded(el) {
|
|
79
|
+
const role = (() => {
|
|
80
|
+
try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
|
|
81
|
+
catch { return ''; }
|
|
82
|
+
})();
|
|
83
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
84
|
+
|
|
85
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
86
|
+
let focusable = false;
|
|
87
|
+
if (getFocusableInfo) {
|
|
88
|
+
const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
|
|
89
|
+
focusable = !!(fi && fi.focusable);
|
|
90
|
+
} else {
|
|
91
|
+
let tabindex = null;
|
|
92
|
+
try { tabindex = el.getAttribute('tabindex'); } catch { tabindex = null; }
|
|
93
|
+
const t = tabindex == null ? '' : String(tabindex).trim();
|
|
94
|
+
focusable = t !== '' && !Number.isNaN(Number(t));
|
|
95
|
+
}
|
|
96
|
+
return !focusable;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
const els = (() => {
|
|
101
|
+
try { return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []); }
|
|
102
|
+
catch { return queryAll('canvas'); }
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
if (!els.length) {
|
|
106
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const occurrences = [];
|
|
110
|
+
let applicableCount = 0;
|
|
111
|
+
|
|
112
|
+
for (const el of els) {
|
|
113
|
+
if (!el || !el.getAttribute) continue;
|
|
114
|
+
|
|
115
|
+
if (isAccTreeEligible) {
|
|
116
|
+
const elig = accEligibleCached(el);
|
|
117
|
+
if (elig && elig.eligible === false) continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
121
|
+
|
|
122
|
+
const getTextAlternativeInfo =
|
|
123
|
+
helpers && typeof helpers.getTextAlternativeInfo === 'function'
|
|
124
|
+
? helpers.getTextAlternativeInfo
|
|
125
|
+
: null;
|
|
126
|
+
|
|
127
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
128
|
+
|
|
129
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
130
|
+
let textAltInfo = null;
|
|
131
|
+
if (getTextAlternativeInfo) {
|
|
132
|
+
try { textAltInfo = getTextAlternativeInfo(el, ctx); } catch { textAltInfo = null; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const hasTextAlt = !!(textAltInfo && textAltInfo.present);
|
|
136
|
+
if (!hasTextAlt) continue;
|
|
137
|
+
|
|
138
|
+
applicableCount += 1;
|
|
139
|
+
|
|
140
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
141
|
+
|
|
142
|
+
const baseOccurrence = {
|
|
143
|
+
summary: 'Review text alternative for <canvas> for equivalence and appropriateness.',
|
|
144
|
+
hint: 'Confirm the fallback text or accessible name conveys the same information/function as the canvas content.',
|
|
145
|
+
i18n: {
|
|
146
|
+
summaryKey: 'canvas_textAltQuality_summary_cantTell',
|
|
147
|
+
hintKey: 'canvas_textAltQuality_hint_cantTell',
|
|
148
|
+
params: { element: (el.tagName || '').toLowerCase() }
|
|
149
|
+
},
|
|
150
|
+
data: {
|
|
151
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
152
|
+
details: textAltInfo || null
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
157
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
158
|
+
} else {
|
|
159
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (applicableCount === 0) {
|
|
164
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check css-hidden-focus
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary focusable elements must be visible to sighted keyboard users
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.4.7
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements that are tabbable (keyboard-focusable) but are visually hidden
|
|
11
|
+
* via CSS techniques that can leave them in the tab order.
|
|
12
|
+
* @expectation
|
|
13
|
+
* No element should be tabbable while visually hidden (e.g., opacity:0, clipped, off-screen).
|
|
14
|
+
*
|
|
15
|
+
* Notes:
|
|
16
|
+
* - This rule intentionally targets CSS techniques that *can* keep an element focusable.
|
|
17
|
+
* - Elements removed from rendering (display:none, visibility:hidden, [hidden]) are excluded.
|
|
18
|
+
* - The rule uses deterministic heuristics (computed style parsing) and does not rely on layout geometry.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const id = 'css-hidden-focus';
|
|
22
|
+
|
|
23
|
+
const meta = {
|
|
24
|
+
title: 'Focusable elements must not be visually hidden',
|
|
25
|
+
description:
|
|
26
|
+
'Checks that keyboard-focusable elements are not visually hidden by CSS techniques that can leave them in the tab order.',
|
|
27
|
+
i18n: {
|
|
28
|
+
titleKey: 'cssHidden_focus_title',
|
|
29
|
+
descriptionKey: 'cssHidden_focus_description'
|
|
30
|
+
},
|
|
31
|
+
helpUrl: null,
|
|
32
|
+
tags: ['wcag2aa', 'wcag247', 'navigation', 'focus', 'css', 'atomic', 'manual'],
|
|
33
|
+
wcagSc: ['2.4.7'],
|
|
34
|
+
normativeMappings: [
|
|
35
|
+
{standard: 'WCAG', version: '2.2', requirement: '2.4.7', title: 'Focus Visible', conformanceLevel: 'AA'}
|
|
36
|
+
],
|
|
37
|
+
defaultSeverity: 'serious',
|
|
38
|
+
category: 'operable',
|
|
39
|
+
type: 'manual',
|
|
40
|
+
defaultConfidence: 'low',
|
|
41
|
+
coverage: {
|
|
42
|
+
facetsBySc: {
|
|
43
|
+
'2.4.7': ['css-hidden-focusable']
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const {document, root, helpers, rule} = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
53
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
54
|
+
const isDomVisibleEligible = helpers && typeof helpers.isDomVisibleEligible === 'function' ? helpers.isDomVisibleEligible : null;
|
|
55
|
+
const reportOccurrence = helpers && typeof helpers.reportOccurrence === 'function' ? helpers.reportOccurrence : null;
|
|
56
|
+
|
|
57
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
58
|
+
const lower = (v) => trim(v).toLowerCase();
|
|
59
|
+
|
|
60
|
+
function qAll(sel) {
|
|
61
|
+
try {
|
|
62
|
+
if (queryAllSmart) {
|
|
63
|
+
const r = queryAllSmart(sel);
|
|
64
|
+
if (Array.isArray(r)) return r;
|
|
65
|
+
return Array.from(r || []);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// fall through
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (safeRoot && typeof safeRoot.querySelectorAll === 'function') return Array.from(safeRoot.querySelectorAll(sel));
|
|
72
|
+
} catch {
|
|
73
|
+
// fall through
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getComputedStyleSafe(el) {
|
|
79
|
+
try {
|
|
80
|
+
const w = (document && document.defaultView) ? document.defaultView : (typeof window !== 'undefined' ? window : null);
|
|
81
|
+
return w && w.getComputedStyle ? w.getComputedStyle(el) : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Returns deterministic "visually hidden but can remain focusable" hints.
|
|
88
|
+
function getVisibilityHints(el) {
|
|
89
|
+
const out = [];
|
|
90
|
+
if (!el) return out;
|
|
91
|
+
const cs = getComputedStyleSafe(el);
|
|
92
|
+
|
|
93
|
+
// opacity:0
|
|
94
|
+
try {
|
|
95
|
+
const rawOp = cs && cs.opacity != null ? String(cs.opacity).trim() : '';
|
|
96
|
+
const op = rawOp ? Number.parseFloat(rawOp) : 1;
|
|
97
|
+
if (Number.isFinite(op) && op <= 0.0001) out.push('opacityZero');
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// clip / clip-path
|
|
102
|
+
try {
|
|
103
|
+
const clip = cs && cs.clip != null ? String(cs.clip).trim() : '';
|
|
104
|
+
const clipPath = cs && cs.clipPath != null ? String(cs.clipPath).trim() : '';
|
|
105
|
+
const clipLow = (clip || '').toLowerCase();
|
|
106
|
+
const clipPathLow = (clipPath || '').toLowerCase();
|
|
107
|
+
|
|
108
|
+
if (clipLow && clipLow !== 'auto') {
|
|
109
|
+
if (clipLow.indexOf('rect(') !== -1 && clipLow.replace(/\s+/g, '').indexOf('rect(0') !== -1) out.push('clipped');
|
|
110
|
+
}
|
|
111
|
+
if (clipPathLow && clipPathLow !== 'none') {
|
|
112
|
+
if (clipPathLow.indexOf('inset(') !== -1 && (clipPathLow.indexOf('100%') !== -1 || clipPathLow.indexOf('50%') !== -1)) {
|
|
113
|
+
out.push('clipped');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// zero-size + overflow hidden/clip
|
|
120
|
+
try {
|
|
121
|
+
const wv = cs && cs.width != null ? String(cs.width).trim() : '';
|
|
122
|
+
const hv = cs && cs.height != null ? String(cs.height).trim() : '';
|
|
123
|
+
const ov = cs && cs.overflow != null ? String(cs.overflow).trim().toLowerCase() : '';
|
|
124
|
+
const isZeroW = wv === '0px' || wv === '0';
|
|
125
|
+
const isZeroH = hv === '0px' || hv === '0';
|
|
126
|
+
const hidesOverflow = ov === 'hidden' || ov === 'clip';
|
|
127
|
+
if ((isZeroW || isZeroH) && hidesOverflow) out.push('zeroSizeOverflowHidden');
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// off-screen heuristic (absolute/fixed + left/top <= -5000 OR text-indent <= -5000)
|
|
132
|
+
try {
|
|
133
|
+
const pos = cs && cs.position != null ? String(cs.position).trim().toLowerCase() : '';
|
|
134
|
+
const left = cs && cs.left != null ? String(cs.left).trim().toLowerCase() : '';
|
|
135
|
+
const top = cs && cs.top != null ? String(cs.top).trim().toLowerCase() : '';
|
|
136
|
+
const ti = cs && cs.textIndent != null ? String(cs.textIndent).trim().toLowerCase() : '';
|
|
137
|
+
|
|
138
|
+
const parsePx = (s) => {
|
|
139
|
+
if (!s || s === 'auto') return null;
|
|
140
|
+
const m = String(s).match(/-?\d+(\.\d+)?/);
|
|
141
|
+
if (!m) return null;
|
|
142
|
+
const n = Number.parseFloat(m[0]);
|
|
143
|
+
return Number.isFinite(n) ? n : null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const l = parsePx(left);
|
|
147
|
+
const t = parsePx(top);
|
|
148
|
+
const ind = parsePx(ti);
|
|
149
|
+
|
|
150
|
+
if (pos === 'absolute' || pos === 'fixed') {
|
|
151
|
+
if ((l != null && l <= -5000) || (t != null && t <= -5000)) out.push('offscreen');
|
|
152
|
+
}
|
|
153
|
+
if (ind != null && ind <= -5000) out.push('offscreen');
|
|
154
|
+
} catch {
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Dedup
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const uniq = [];
|
|
160
|
+
for (const k of out) {
|
|
161
|
+
const kk = String(k);
|
|
162
|
+
if (!seen.has(kk)) {
|
|
163
|
+
seen.add(kk);
|
|
164
|
+
uniq.push(kk);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return uniq;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function getFocusableInfoSafe(el) {
|
|
171
|
+
if (!getFocusableInfo) return null;
|
|
172
|
+
try {
|
|
173
|
+
return getFocusableInfo(el, ctx);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isTabbable(el, info) {
|
|
180
|
+
const f = info || getFocusableInfoSafe(el);
|
|
181
|
+
return !!(f && f.focusable && f.tabbable);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Exclude elements that are not rendered / not in the visual rendering tree.
|
|
185
|
+
function isRendered(el) {
|
|
186
|
+
if (!isDomVisibleEligible) return true;
|
|
187
|
+
try {
|
|
188
|
+
const vis = isDomVisibleEligible(el, ctx, {visibilityMode: 'styleOnly', disableGeometry: true});
|
|
189
|
+
if (vis && vis.eligible === false) {
|
|
190
|
+
const rs = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
191
|
+
// We *include* opacityZero in this rule. Everything else is treated as not rendered for this purpose.
|
|
192
|
+
const nonOpacity = rs.filter((r) => String(r) !== 'opacityZero');
|
|
193
|
+
if (nonOpacity.length) return false;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// If in doubt, keep deterministic behavior and consider it rendered.
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const candidates = qAll('a[href],area[href],button,input,select,textarea,summary,[tabindex],[contenteditable]');
|
|
201
|
+
if (!candidates.length) {
|
|
202
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const occurrences = [];
|
|
206
|
+
|
|
207
|
+
for (const el of candidates) {
|
|
208
|
+
if (!el || !el.getAttribute) continue;
|
|
209
|
+
|
|
210
|
+
const finfo = getFocusableInfoSafe(el);
|
|
211
|
+
if (!isTabbable(el, finfo)) continue;
|
|
212
|
+
|
|
213
|
+
// Skip elements not rendered (display:none, visibility:hidden, [hidden], etc.)
|
|
214
|
+
if (!isRendered(el)) continue;
|
|
215
|
+
|
|
216
|
+
const hints = getVisibilityHints(el);
|
|
217
|
+
if (!hints.length) continue; // <-- applicability gate
|
|
218
|
+
|
|
219
|
+
const tagName = (() => { try { return lower(el.tagName || ''); } catch { return ''; } })();
|
|
220
|
+
|
|
221
|
+
const hintOrder = ['opacityZero', 'offscreen', 'clipped', 'zeroSizeOverflowHidden'];
|
|
222
|
+
const hintsArr = [];
|
|
223
|
+
for (const k of hintOrder) if (hints.includes(k)) hintsArr.push(k);
|
|
224
|
+
|
|
225
|
+
const baseOccurrence = {
|
|
226
|
+
summary: `Focusable ${tagName} appears visually hidden (${hintsArr.join(',')}). Verify it becomes visible on keyboard focus.`,
|
|
227
|
+
hint: 'Manually tab to the element and confirm a visible focus indicator and that the element is visible when focused. If it remains hidden while focused, fix CSS/JS so it becomes visible or is removed from the tab order until visible.',
|
|
228
|
+
i18n: {
|
|
229
|
+
summaryKey: 'cssHidden_focus_summary_cantTell',
|
|
230
|
+
hintKey: 'cssHidden_focus_hint_cantTell',
|
|
231
|
+
params: { element: tagName, visibilityHints: hintsArr.join(',') }
|
|
232
|
+
},
|
|
233
|
+
data: {
|
|
234
|
+
details: {
|
|
235
|
+
reasonCode: 'cssHiddenTabbable_needsFocusStateVerification',
|
|
236
|
+
metrics: { visibilityHints: hintsArr.slice(0) }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
occurrences.push(
|
|
242
|
+
reportOccurrence ? reportOccurrence(el, baseOccurrence) : { __node: el, selector: '', html: '', ...baseOccurrence }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Manual rule + validator invariant: cantTell must have >= 1 occurrence
|
|
247
|
+
if (!occurrences.length) {
|
|
248
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
ruleId: rule.ruleId,
|
|
253
|
+
outcome: 'cantTell',
|
|
254
|
+
severity: rule.defaultSeverity || 'minor',
|
|
255
|
+
occurrences
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {id, meta, runInPage};
|