@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,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check scope-attr-valid
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary The scope attribute must have a valid value
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies to elements with a non-empty scope attribute.
|
|
10
|
+
* @expectation
|
|
11
|
+
* The scope value is one of "row", "col", "rowgroup", or "colgroup"
|
|
12
|
+
* (case-insensitive). An invalid scope value is not recognized by
|
|
13
|
+
* assistive technology, silently losing the row/column header
|
|
14
|
+
* association it was meant to declare.
|
|
15
|
+
* @implementation-notes
|
|
16
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
17
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
18
|
+
* header comment for the shared rationale/precedent.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const id = 'scope-attr-valid';
|
|
22
|
+
|
|
23
|
+
const meta = {
|
|
24
|
+
title: 'scope attribute must have a valid value',
|
|
25
|
+
description: 'Checks that scope="..." is one of row, col, rowgroup, or colgroup.',
|
|
26
|
+
i18n: {
|
|
27
|
+
titleKey: 'scopeAttrValid_title',
|
|
28
|
+
descriptionKey: 'scopeAttrValid_description'
|
|
29
|
+
},
|
|
30
|
+
helpUrl: null,
|
|
31
|
+
tags: ['best-practice', 'tables', 'structure', 'atomic', 'manual'],
|
|
32
|
+
wcagSc: [],
|
|
33
|
+
normativeMappings: [],
|
|
34
|
+
defaultSeverity: 'minor',
|
|
35
|
+
category: 'perceivable',
|
|
36
|
+
type: 'manual',
|
|
37
|
+
defaultConfidence: 'medium',
|
|
38
|
+
coverage: {}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function runInPage(ctx) {
|
|
42
|
+
const { document, helpers, rule } = ctx;
|
|
43
|
+
|
|
44
|
+
const VALID_SCOPES = new Set(['row', 'col', 'rowgroup', 'colgroup']);
|
|
45
|
+
|
|
46
|
+
let nodes = [];
|
|
47
|
+
try {
|
|
48
|
+
nodes = document.querySelectorAll('[scope]');
|
|
49
|
+
} catch {
|
|
50
|
+
nodes = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const occurrences = [];
|
|
54
|
+
let applicableCount = 0;
|
|
55
|
+
|
|
56
|
+
for (const el of nodes) {
|
|
57
|
+
if (!el || !el.getAttribute) continue;
|
|
58
|
+
const raw = String(el.getAttribute('scope') || '').trim();
|
|
59
|
+
if (!raw) continue;
|
|
60
|
+
|
|
61
|
+
applicableCount += 1;
|
|
62
|
+
|
|
63
|
+
if (VALID_SCOPES.has(raw.toLowerCase())) continue;
|
|
64
|
+
|
|
65
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
66
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
67
|
+
|
|
68
|
+
occurrences.push({
|
|
69
|
+
selector: stableSelector,
|
|
70
|
+
html,
|
|
71
|
+
summary: 'This scope attribute value is not recognized.',
|
|
72
|
+
hint: 'Use one of row, col, rowgroup, or colgroup for the scope attribute.',
|
|
73
|
+
i18n: {
|
|
74
|
+
summaryKey: 'scopeAttrValid_summary_cantTell',
|
|
75
|
+
hintKey: 'scopeAttrValid_hint_cantTell',
|
|
76
|
+
params: { value: raw }
|
|
77
|
+
},
|
|
78
|
+
data: {
|
|
79
|
+
details: { reasonCode: 'SCOPE_ATTR_INVALID', value: raw }
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (applicableCount === 0) {
|
|
85
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
86
|
+
}
|
|
87
|
+
if (occurrences.length) {
|
|
88
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
89
|
+
}
|
|
90
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check scrollable-region-focusable
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A scrollable region with no focusable content should itself be keyboard-focusable
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 2.1.1, 2.1.3
|
|
9
|
+
* @applicability
|
|
10
|
+
* Deliberately scoped to a fixed set of likely-to-scroll container tags
|
|
11
|
+
* (div, section, article, aside, main, nav, pre, table, blockquote, ul,
|
|
12
|
+
* ol, textarea) with computed `overflow-x`/`overflow-y` of `auto` or
|
|
13
|
+
* `scroll` — not every element on the page, to keep this deterministic
|
|
14
|
+
* and performant (same style of scope-down as `region`).
|
|
15
|
+
* @expectation
|
|
16
|
+
* A region whose CSS declares it may scroll (`auto`/`scroll`) should be
|
|
17
|
+
* reachable by keyboard: either it already contains a focusable
|
|
18
|
+
* descendant (a link, button, form control, or `tabindex`-bearing
|
|
19
|
+
* element a keyboard user could tab into and then use arrow keys to
|
|
20
|
+
* scroll from), or the region itself carries a non-negative `tabindex`.
|
|
21
|
+
* @implementation-notes
|
|
22
|
+
* - jsdom does not perform layout, so `scrollHeight`/`clientHeight` are
|
|
23
|
+
* not available to confirm the region's content actually overflows —
|
|
24
|
+
* only that the CSS declares it *may*. Many elements declare
|
|
25
|
+
* `overflow: auto` defensively without their content ever actually
|
|
26
|
+
* overflowing, which would be a false positive if treated as a hard
|
|
27
|
+
* `fail`. For that reason this is authored as `type: 'manual'`
|
|
28
|
+
* (cantTell-capped, never fail) rather than `automatic` — same class of
|
|
29
|
+
* layout-dependent gap as `iframe-focusable-content`'s
|
|
30
|
+
* `contentDocument` limitation.
|
|
31
|
+
* - "Has a focusable descendant" is a presence check (link/button/form
|
|
32
|
+
* control/`[tabindex]`/`iframe`/`[contenteditable]`), not a full
|
|
33
|
+
* focusability computation (disabled state, visibility, etc.) — a
|
|
34
|
+
* deliberate simplification to keep this rule self-contained and fast.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const id = 'scrollable-region-focusable';
|
|
38
|
+
|
|
39
|
+
const meta = {
|
|
40
|
+
title: 'Scrollable regions with no focusable content should be keyboard-focusable',
|
|
41
|
+
description:
|
|
42
|
+
'Flags elements whose CSS declares overflow:auto/scroll, contain no focusable descendant, and are not themselves keyboard-focusable, for manual review of whether their content actually overflows and needs keyboard scroll access.',
|
|
43
|
+
i18n: {
|
|
44
|
+
titleKey: 'scrollableRegionFocusable_title',
|
|
45
|
+
descriptionKey: 'scrollableRegionFocusable_description'
|
|
46
|
+
},
|
|
47
|
+
helpUrl: null,
|
|
48
|
+
tags: ['wcag2a', 'wcag2aaa', 'wcag211', 'wcag213', 'structure', 'atomic', 'manual'],
|
|
49
|
+
wcagSc: ['2.1.1', '2.1.3'],
|
|
50
|
+
normativeMappings: [
|
|
51
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' },
|
|
52
|
+
{ standard: 'WCAG', version: '2.2', requirement: '2.1.3', title: 'Keyboard (No Exception)', conformanceLevel: 'AAA' }
|
|
53
|
+
],
|
|
54
|
+
defaultSeverity: 'moderate',
|
|
55
|
+
category: 'operable',
|
|
56
|
+
type: 'manual',
|
|
57
|
+
defaultConfidence: 'low',
|
|
58
|
+
coverage: {
|
|
59
|
+
facetsBySc: {
|
|
60
|
+
'2.1.1': ['scrollable-region-focusable-evidence'],
|
|
61
|
+
'2.1.3': ['scrollable-region-focusable-evidence']
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function runInPage(ctx) {
|
|
67
|
+
const { document, root, helpers, rule } = ctx;
|
|
68
|
+
const safeRoot = root || document;
|
|
69
|
+
|
|
70
|
+
function safeComputedStyle(el) {
|
|
71
|
+
try {
|
|
72
|
+
if (!el || el.nodeType !== 1) return null;
|
|
73
|
+
if (helpers && typeof helpers.computedStyle === 'function') {
|
|
74
|
+
const cs = helpers.computedStyle(el);
|
|
75
|
+
if (cs) return cs;
|
|
76
|
+
}
|
|
77
|
+
const view = (el.ownerDocument && el.ownerDocument.defaultView) ? el.ownerDocument.defaultView : null;
|
|
78
|
+
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
|
|
79
|
+
} catch {}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isScrollableOverflow(cs) {
|
|
84
|
+
if (!cs) return false;
|
|
85
|
+
const x = String(cs.overflowX || '').trim().toLowerCase();
|
|
86
|
+
const y = String(cs.overflowY || '').trim().toLowerCase();
|
|
87
|
+
return x === 'auto' || x === 'scroll' || y === 'auto' || y === 'scroll';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isSelfFocusable(el) {
|
|
91
|
+
try {
|
|
92
|
+
const tabindexAttr = el.getAttribute ? el.getAttribute('tabindex') : null;
|
|
93
|
+
if (tabindexAttr != null) {
|
|
94
|
+
const n = Number.parseInt(tabindexAttr, 10);
|
|
95
|
+
if (Number.isFinite(n) && n >= 0) return true;
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FOCUSABLE_DESCENDANT_SELECTOR =
|
|
102
|
+
'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
|
|
103
|
+
|
|
104
|
+
function hasFocusableDescendant(el) {
|
|
105
|
+
try {
|
|
106
|
+
return !!(el.querySelector && el.querySelector(FOCUSABLE_DESCENDANT_SELECTOR));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
|
|
113
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR, safeRoot) : helpers.queryAll(CANDIDATE_SELECTOR, safeRoot);
|
|
114
|
+
|
|
115
|
+
const occurrences = [];
|
|
116
|
+
let applicableCount = 0;
|
|
117
|
+
|
|
118
|
+
for (const el of nodes) {
|
|
119
|
+
if (!el || !el.getAttribute) continue;
|
|
120
|
+
|
|
121
|
+
const cs = safeComputedStyle(el);
|
|
122
|
+
if (!isScrollableOverflow(cs)) continue;
|
|
123
|
+
|
|
124
|
+
applicableCount += 1;
|
|
125
|
+
|
|
126
|
+
if (isSelfFocusable(el)) continue;
|
|
127
|
+
if (hasFocusableDescendant(el)) continue;
|
|
128
|
+
|
|
129
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
130
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
131
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
132
|
+
|
|
133
|
+
const baseOccurrence = {
|
|
134
|
+
selector: stableSelector,
|
|
135
|
+
html,
|
|
136
|
+
summary: 'This element declares overflow:auto/scroll, has no focusable descendant, and is not itself keyboard-focusable.',
|
|
137
|
+
hint: 'If this region’s content actually overflows, add tabindex="0" (and a suitable label) so keyboard users can focus it and scroll with the arrow keys.',
|
|
138
|
+
i18n: {
|
|
139
|
+
summaryKey: 'scrollableRegionFocusable_summary_cantTell',
|
|
140
|
+
hintKey: 'scrollableRegionFocusable_hint_cantTell',
|
|
141
|
+
params: { element: tag }
|
|
142
|
+
},
|
|
143
|
+
data: {
|
|
144
|
+
details: { reasonCode: 'SCROLLABLE_OVERFLOW_NOT_FOCUSABLE', element: tag }
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
149
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
150
|
+
} else {
|
|
151
|
+
occurrences.push(baseOccurrence);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (applicableCount === 0) {
|
|
156
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (occurrences.length) {
|
|
160
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Manual rules may only emit cantTell/notApplicable (never pass/fail):
|
|
164
|
+
// every scrollable-overflow candidate is already reachable by keyboard.
|
|
165
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check skip-link
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary A "skip" link must resolve to a real, focusable target
|
|
7
|
+
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
|
+
* @applicability
|
|
9
|
+
* Applies to <a href="#fragment"> elements whose accessible name
|
|
10
|
+
* matches a common "skip to ..." / "jump to ..." authoring convention
|
|
11
|
+
* (case-insensitive "skip" or "jump to" in the name) — the recognizable
|
|
12
|
+
* pattern for a skip-navigation link, not every same-page anchor link
|
|
13
|
+
* on the page. "jump to" added 2026-07-23 after a real page (Wish.com)
|
|
14
|
+
* surfaced a skip link reading "Jump to section" with a genuinely
|
|
15
|
+
* missing target — invisible to the original "skip"-only pattern,
|
|
16
|
+
* while a widely-used reference engine's own (purely positional, not text-based) matching
|
|
17
|
+
* caught it. Text-pattern matching itself stays deliberate (see
|
|
18
|
+
* implementation-notes) — this only widens the known-convention list.
|
|
19
|
+
* @expectation
|
|
20
|
+
* The link's fragment resolves to a real element in the document
|
|
21
|
+
* (via a matching id, or a legacy <a name="...">). A skip link whose
|
|
22
|
+
* target does not exist silently does nothing when activated.
|
|
23
|
+
* @implementation-notes
|
|
24
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
25
|
+
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
26
|
+
* header comment for the shared rationale/precedent.
|
|
27
|
+
* - Keyed on the "skip" text-pattern convention rather than positional
|
|
28
|
+
* heuristics (first link in tab order, etc.), matching the same
|
|
29
|
+
* deliberate-leniency reasoning documented in
|
|
30
|
+
* bypass-blocks-present's implementation notes.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const id = 'skip-link';
|
|
34
|
+
|
|
35
|
+
const meta = {
|
|
36
|
+
title: 'Skip link must have a resolvable target',
|
|
37
|
+
description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
|
|
38
|
+
i18n: {
|
|
39
|
+
titleKey: 'skipLink_title',
|
|
40
|
+
descriptionKey: 'skipLink_description'
|
|
41
|
+
},
|
|
42
|
+
helpUrl: null,
|
|
43
|
+
tags: ['best-practice', 'keyboard', 'navigation', 'atomic', 'manual'],
|
|
44
|
+
wcagSc: [],
|
|
45
|
+
normativeMappings: [],
|
|
46
|
+
defaultSeverity: 'minor',
|
|
47
|
+
category: 'operable',
|
|
48
|
+
type: 'manual',
|
|
49
|
+
defaultConfidence: 'medium',
|
|
50
|
+
coverage: {}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function runInPage(ctx) {
|
|
54
|
+
const { document, root, helpers, rule } = ctx;
|
|
55
|
+
const safeRoot = root || document;
|
|
56
|
+
|
|
57
|
+
function normalizeWs(s) {
|
|
58
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getAccessibleNameText(el) {
|
|
62
|
+
const al = normalizeWs(el.getAttribute && el.getAttribute('aria-label'));
|
|
63
|
+
if (al) return al;
|
|
64
|
+
const alb = normalizeWs(el.getAttribute && el.getAttribute('aria-labelledby'));
|
|
65
|
+
if (alb) {
|
|
66
|
+
const parts = [];
|
|
67
|
+
for (const refId of alb.split(/\s+/).filter(Boolean)) {
|
|
68
|
+
try {
|
|
69
|
+
const ref = document.getElementById(refId);
|
|
70
|
+
if (ref) {
|
|
71
|
+
const t = normalizeWs(ref.textContent);
|
|
72
|
+
if (t) parts.push(t);
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
const joined = normalizeWs(parts.join(' '));
|
|
77
|
+
if (joined) return joined;
|
|
78
|
+
}
|
|
79
|
+
return normalizeWs(el.textContent);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]', safeRoot) : helpers.queryAll('a[href]', safeRoot);
|
|
83
|
+
|
|
84
|
+
const occurrences = [];
|
|
85
|
+
let applicableCount = 0;
|
|
86
|
+
|
|
87
|
+
for (const el of nodes) {
|
|
88
|
+
if (!el || !el.getAttribute) continue;
|
|
89
|
+
|
|
90
|
+
const href = String(el.getAttribute('href') || '').trim();
|
|
91
|
+
if (href.length < 2 || href.charAt(0) !== '#') continue;
|
|
92
|
+
|
|
93
|
+
const name = getAccessibleNameText(el);
|
|
94
|
+
if (!/skip/i.test(name) && !/jump\s*to/i.test(name)) continue;
|
|
95
|
+
|
|
96
|
+
applicableCount += 1;
|
|
97
|
+
|
|
98
|
+
let fragment = href.slice(1);
|
|
99
|
+
try {
|
|
100
|
+
fragment = decodeURIComponent(fragment);
|
|
101
|
+
} catch {}
|
|
102
|
+
fragment = fragment.trim();
|
|
103
|
+
|
|
104
|
+
let target = null;
|
|
105
|
+
if (fragment) {
|
|
106
|
+
try {
|
|
107
|
+
target = document.getElementById(fragment);
|
|
108
|
+
} catch {
|
|
109
|
+
target = null;
|
|
110
|
+
}
|
|
111
|
+
if (!target) {
|
|
112
|
+
try {
|
|
113
|
+
target = document.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]');
|
|
114
|
+
} catch {
|
|
115
|
+
target = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (target) continue;
|
|
121
|
+
|
|
122
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
123
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
124
|
+
|
|
125
|
+
occurrences.push({
|
|
126
|
+
selector: stableSelector,
|
|
127
|
+
html,
|
|
128
|
+
summary: 'This skip link\'s target does not exist.',
|
|
129
|
+
hint: 'Point the skip link\'s href at an id that exists in the document, or add the missing target element.',
|
|
130
|
+
i18n: {
|
|
131
|
+
summaryKey: 'skipLink_summary_cantTell',
|
|
132
|
+
hintKey: 'skipLink_hint_cantTell',
|
|
133
|
+
params: { href }
|
|
134
|
+
},
|
|
135
|
+
data: {
|
|
136
|
+
details: { reasonCode: 'SKIP_LINK_TARGET_MISSING', href }
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (applicableCount === 0) {
|
|
142
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
143
|
+
}
|
|
144
|
+
if (occurrences.length) {
|
|
145
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
146
|
+
}
|
|
147
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check svg-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 = "svg-text-alternative-quality";
|
|
15
|
+
|
|
16
|
+
const meta = {
|
|
17
|
+
title: "<svg> text alternative must be appropriate (manual review)",
|
|
18
|
+
description: "Flags applicable <svg> graphics with a detected text alternative for human review of appropriateness.",
|
|
19
|
+
i18n: {
|
|
20
|
+
titleKey: "svg_textAltQuality_title",
|
|
21
|
+
descriptionKey: "svg_textAltQuality_description"
|
|
22
|
+
},
|
|
23
|
+
helpUrl: null,
|
|
24
|
+
tags: ["wcag2a", "wcag111", "nontext", "svg", "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
|
+
const {document, root, helpers, rule} = ctx;
|
|
42
|
+
const safeRoot = root || document;
|
|
43
|
+
|
|
44
|
+
const queryAllSmart = helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
45
|
+
const queryAll = helpers && typeof helpers.queryAll === 'function'
|
|
46
|
+
? helpers.queryAll
|
|
47
|
+
: (sel) => {
|
|
48
|
+
try { return safeRoot && safeRoot.querySelectorAll ? Array.from(safeRoot.querySelectorAll(sel)) : []; }
|
|
49
|
+
catch { return []; }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const buildSelector = helpers && typeof helpers.buildSelector === 'function'
|
|
53
|
+
? helpers.buildSelector
|
|
54
|
+
: (el) => {
|
|
55
|
+
try {
|
|
56
|
+
if (!el || !el.tagName) return 'html';
|
|
57
|
+
const tag = (el.tagName || 'html').toLowerCase();
|
|
58
|
+
return el.id ? `${tag}#${el.id}` : tag;
|
|
59
|
+
} catch {
|
|
60
|
+
return 'html';
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const getOuterHtmlSnippet = helpers && typeof helpers.getOuterHtmlSnippet === 'function'
|
|
65
|
+
? helpers.getOuterHtmlSnippet
|
|
66
|
+
: (el) => { try { return (el && el.outerHTML) ? String(el.outerHTML).slice(0, 2000) : ''; } catch { return ''; } };
|
|
67
|
+
|
|
68
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
69
|
+
? helpers.getEligibilityInfo
|
|
70
|
+
: null;
|
|
71
|
+
|
|
72
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
73
|
+
? helpers.isAccTreeEligible
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
77
|
+
? helpers.getFocusableInfo
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
function isRolePresentationExcluded(el) {
|
|
81
|
+
const role = (() => {
|
|
82
|
+
try {
|
|
83
|
+
return String(el.getAttribute('role') || '').trim().toLowerCase();
|
|
84
|
+
} catch {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
89
|
+
|
|
90
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
91
|
+
let focusable = false;
|
|
92
|
+
if (getFocusableInfo) {
|
|
93
|
+
const fi = (() => {
|
|
94
|
+
try {
|
|
95
|
+
return getFocusableInfo(el, ctx);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
focusable = !!(fi && fi.focusable);
|
|
101
|
+
} else {
|
|
102
|
+
const tabindex = el.getAttribute('tabindex');
|
|
103
|
+
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
104
|
+
}
|
|
105
|
+
return !focusable;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
const els = (() => {
|
|
110
|
+
try {
|
|
111
|
+
return Array.from((queryAllSmart ? queryAllSmart('svg') : queryAll('svg')) || []);
|
|
112
|
+
} catch {
|
|
113
|
+
return queryAll('svg');
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
if (!els.length) {
|
|
118
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const occurrences = [];
|
|
122
|
+
let applicableCount = 0;
|
|
123
|
+
|
|
124
|
+
const trim = (v) => (v == null ? '' : String(v)).trim();
|
|
125
|
+
|
|
126
|
+
for (const el of els) {
|
|
127
|
+
if (!el || !el.getAttribute) continue;
|
|
128
|
+
|
|
129
|
+
// acc eligibility
|
|
130
|
+
if (isAccTreeEligible) {
|
|
131
|
+
const elig = (() => { try { return isAccTreeEligible(el, ctx); } catch { return { eligible: true, reasons: [] }; } })();
|
|
132
|
+
if (elig && elig.eligible === false) continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
136
|
+
|
|
137
|
+
// Detect mechanisms once
|
|
138
|
+
let titleText = '';
|
|
139
|
+
let descText = '';
|
|
140
|
+
let ariaLabel = '';
|
|
141
|
+
let ariaLabelledBy = '';
|
|
142
|
+
let labelledByText = '';
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const titleEl = el.querySelector ? el.querySelector('title') : null;
|
|
146
|
+
const descEl = el.querySelector ? el.querySelector('desc') : null;
|
|
147
|
+
titleText = trim(titleEl && titleEl.textContent);
|
|
148
|
+
descText = trim(descEl && descEl.textContent);
|
|
149
|
+
|
|
150
|
+
ariaLabel = trim(el.getAttribute('aria-label'));
|
|
151
|
+
ariaLabelledBy = trim(el.getAttribute('aria-labelledby'));
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
154
|
+
if (!ariaLabel && ariaLabelledBy && helpers && typeof helpers.getTextFromIdRefs === 'function') {
|
|
155
|
+
try {
|
|
156
|
+
const t = helpers.getTextFromIdRefs(ariaLabelledBy, ctx);
|
|
157
|
+
labelledByText = trim(t && t.text);
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const hasNonEmptyTitle = !!titleText;
|
|
162
|
+
const hasNonEmptyDesc = !!descText;
|
|
163
|
+
const hasAriaLabel = !!ariaLabel;
|
|
164
|
+
const hasResolvedLabelledBy = !!labelledByText;
|
|
165
|
+
|
|
166
|
+
const hasMechanism = hasNonEmptyTitle || hasNonEmptyDesc || hasAriaLabel || hasResolvedLabelledBy;
|
|
167
|
+
if (!hasMechanism) continue;
|
|
168
|
+
|
|
169
|
+
applicableCount += 1;
|
|
170
|
+
|
|
171
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
172
|
+
|
|
173
|
+
const baseOccurrence = {
|
|
174
|
+
summary: 'Review text alternative for <svg> for accuracy and appropriateness.',
|
|
175
|
+
hint: 'Confirm the <title>/<desc> or ARIA name conveys the meaning/purpose of the graphic in context.',
|
|
176
|
+
i18n: {
|
|
177
|
+
summaryKey: 'svg_textAltQuality_summary_cantTell',
|
|
178
|
+
hintKey: 'svg_textAltQuality_hint_cantTell',
|
|
179
|
+
params: { element: 'svg' }
|
|
180
|
+
},
|
|
181
|
+
data: {
|
|
182
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
183
|
+
details: {
|
|
184
|
+
hasNonEmptyTitle,
|
|
185
|
+
hasNonEmptyDesc,
|
|
186
|
+
ariaLabel: ariaLabel || null,
|
|
187
|
+
ariaLabelledBy: ariaLabelledBy || null,
|
|
188
|
+
ariaLabelledByText: labelledByText ? labelledByText.slice(0, 120) : null // deterministic truncation
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
194
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
195
|
+
} else {
|
|
196
|
+
const selectorStr = (() => { try { return buildSelector(el); } catch { return 'html'; } })();
|
|
197
|
+
const html = getOuterHtmlSnippet(el);
|
|
198
|
+
occurrences.push({ selector: selectorStr, html, ...baseOccurrence });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (applicableCount === 0) {
|
|
203
|
+
return {ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: []};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {id, meta, runInPage};
|