@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,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'tab-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Tabs have an accessible name',
|
|
7
|
+
description: 'Checks that elements with role="tab" expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'tabNamePresent_title',
|
|
10
|
+
descriptionKey: 'tabNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a','wcag412','navigation','atomic','automatic','name','tab'],
|
|
14
|
+
wcagSc: ['4.1.2'],
|
|
15
|
+
normativeMappings: [
|
|
16
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
17
|
+
],
|
|
18
|
+
defaultSeverity: 'serious',
|
|
19
|
+
category: 'robust',
|
|
20
|
+
type: 'automatic',
|
|
21
|
+
defaultConfidence: 'high',
|
|
22
|
+
coverage: { facetsBySc: { '4.1.2': ['tab-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 hasAttr(el, name) {
|
|
45
|
+
try { return !!(el && el.hasAttribute && el.hasAttribute(name)); } catch { return false; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isExplicitProgrammatic(el) {
|
|
49
|
+
// Programmatic name mechanisms we treat as authoritative (presence-only):
|
|
50
|
+
// - aria-label (non-empty)
|
|
51
|
+
// - aria-labelledby (non-empty)
|
|
52
|
+
// - title (non-empty) [weak but allowed for presence]
|
|
53
|
+
const al = getAttr(el, 'aria-label');
|
|
54
|
+
if (al) return true;
|
|
55
|
+
const alb = getAttr(el, 'aria-labelledby');
|
|
56
|
+
if (alb) return true;
|
|
57
|
+
const t = getAttr(el, 'title');
|
|
58
|
+
if (t) return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildLabelForMap(doc) {
|
|
63
|
+
const map = new Map(); // id -> label element (first)
|
|
64
|
+
try {
|
|
65
|
+
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
66
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
67
|
+
const lab = labels[i];
|
|
68
|
+
if (!lab || !lab.getAttribute) continue;
|
|
69
|
+
const f = normalizeWs(lab.getAttribute('for'));
|
|
70
|
+
if (!f) continue;
|
|
71
|
+
if (!map.has(f)) map.set(f, lab);
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
return map;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getConservativeSubtreeText(document, container) {
|
|
78
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
79
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
80
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
81
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
82
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
83
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
84
|
+
// icon-button pattern).
|
|
85
|
+
if (helpers.getContentNameInfo) {
|
|
86
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
87
|
+
return info && info.present ? info.value : '';
|
|
88
|
+
}
|
|
89
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
90
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
94
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
95
|
+
if (!raw) return '';
|
|
96
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
97
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
98
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
99
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
100
|
+
// which name-from-content alone can never see).
|
|
101
|
+
if (helpers.getTextFromIdRefs) {
|
|
102
|
+
try {
|
|
103
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
104
|
+
return normalizeWs(r && r.text);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getInputValueName(el) {
|
|
111
|
+
try {
|
|
112
|
+
const type = getAttr(el, 'type').toLowerCase();
|
|
113
|
+
if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
|
|
114
|
+
const v = getAttr(el, 'value');
|
|
115
|
+
return v;
|
|
116
|
+
} catch { return ''; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
120
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
121
|
+
if (!fn) return true;
|
|
122
|
+
try {
|
|
123
|
+
const r = fn(el, ctx);
|
|
124
|
+
if (typeof r === 'boolean') return r;
|
|
125
|
+
return !!(r && r.eligible);
|
|
126
|
+
} catch {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
const occurrences = [];
|
|
133
|
+
let applicableCount = 0;
|
|
134
|
+
|
|
135
|
+
const selector = '[role="tab"]';
|
|
136
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
function evaluate(el) {
|
|
140
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
141
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
142
|
+
|
|
143
|
+
const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
|
|
144
|
+
if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
|
|
145
|
+
|
|
146
|
+
const title = getAttr(el, 'title');
|
|
147
|
+
if (title) return { ok: true, method: 'title' };
|
|
148
|
+
|
|
149
|
+
const t = getConservativeSubtreeText(document, el);
|
|
150
|
+
if (t) return { ok: true, method: 'content' };
|
|
151
|
+
|
|
152
|
+
return { ok: false, method: 'none' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const el of nodes) {
|
|
156
|
+
if (!el) continue;
|
|
157
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
158
|
+
|
|
159
|
+
const role = getAttr(el, 'role').toLowerCase();
|
|
160
|
+
if (role !== 'tab') continue;
|
|
161
|
+
|
|
162
|
+
applicableCount += 1;
|
|
163
|
+
|
|
164
|
+
const res = evaluate(el);
|
|
165
|
+
if (res.ok) continue;
|
|
166
|
+
|
|
167
|
+
const eligInfo = getEligibilityInfo
|
|
168
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
169
|
+
: null;
|
|
170
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
171
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
172
|
+
|
|
173
|
+
occurrences.push({
|
|
174
|
+
selector: stableSelector,
|
|
175
|
+
html,
|
|
176
|
+
summary: 'This tab has no accessible name.',
|
|
177
|
+
hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
|
|
178
|
+
i18n: {
|
|
179
|
+
summaryKey: 'tabNamePresent_summary_fail',
|
|
180
|
+
hintKey: 'tabNamePresent_hint_fail',
|
|
181
|
+
params: {}
|
|
182
|
+
},
|
|
183
|
+
data: {
|
|
184
|
+
details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
|
|
185
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if (applicableCount === 0) {
|
|
192
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
193
|
+
}
|
|
194
|
+
if (occurrences.length) {
|
|
195
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
196
|
+
}
|
|
197
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check table-headers-attr-valid
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Table cell "headers" attribute must reference real <th> cells in the same table
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <td>/<th> elements that carry a non-empty headers attribute.
|
|
11
|
+
* @expectation
|
|
12
|
+
* Every id token in the headers attribute resolves to an element that:
|
|
13
|
+
* (a) exists, (b) is a <th> element, (c) is inside the same <table> as
|
|
14
|
+
* the referencing cell, and (d) is not the cell itself.
|
|
15
|
+
* @implementation-notes
|
|
16
|
+
* - One occurrence per offending cell (not per bad token), listing every
|
|
17
|
+
* invalid reference — matches a widely-used reference engine's
|
|
18
|
+
* td-headers-attr reporting granularity.
|
|
19
|
+
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const id = 'table-headers-attr-valid';
|
|
23
|
+
|
|
24
|
+
const meta = {
|
|
25
|
+
title: 'Table cell "headers" attribute must reference valid header cells',
|
|
26
|
+
description: 'Checks that each id in a <td>/<th> headers attribute resolves to a <th> element within the same table (not missing, not a non-th element, not itself).',
|
|
27
|
+
i18n: {
|
|
28
|
+
titleKey: 'tableHeadersAttrValid_title',
|
|
29
|
+
descriptionKey: 'tableHeadersAttrValid_description'
|
|
30
|
+
},
|
|
31
|
+
helpUrl: null,
|
|
32
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
|
|
33
|
+
wcagSc: ['1.3.1'],
|
|
34
|
+
normativeMappings: [
|
|
35
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
36
|
+
],
|
|
37
|
+
defaultSeverity: 'serious',
|
|
38
|
+
category: 'perceivable',
|
|
39
|
+
type: 'automatic',
|
|
40
|
+
defaultConfidence: 'high',
|
|
41
|
+
coverage: { facetsBySc: { '1.3.1': ['table-headers-attr-valid'] } }
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function runInPage(ctx) {
|
|
45
|
+
const { document, root, helpers, rule } = ctx;
|
|
46
|
+
const safeRoot = root || document;
|
|
47
|
+
|
|
48
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('td[headers], th[headers]', safeRoot) : helpers.queryAll('td[headers], th[headers]', safeRoot);
|
|
49
|
+
|
|
50
|
+
const occurrences = [];
|
|
51
|
+
let applicableCount = 0;
|
|
52
|
+
|
|
53
|
+
for (const el of nodes) {
|
|
54
|
+
if (!el || !el.getAttribute) continue;
|
|
55
|
+
|
|
56
|
+
const raw = String(el.getAttribute('headers') || '').trim();
|
|
57
|
+
if (!raw) continue;
|
|
58
|
+
const ids = raw.split(/\s+/).filter(Boolean);
|
|
59
|
+
if (!ids.length) continue;
|
|
60
|
+
|
|
61
|
+
applicableCount += 1;
|
|
62
|
+
|
|
63
|
+
const table = el.closest ? el.closest('table') : null;
|
|
64
|
+
const invalid = [];
|
|
65
|
+
|
|
66
|
+
for (const headerId of ids) {
|
|
67
|
+
let ref = null;
|
|
68
|
+
try {
|
|
69
|
+
ref = document.getElementById(headerId);
|
|
70
|
+
} catch {
|
|
71
|
+
ref = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!ref) {
|
|
75
|
+
invalid.push({ id: headerId, reason: 'missing' });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ref === el) {
|
|
79
|
+
invalid.push({ id: headerId, reason: 'self-reference' });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!ref.tagName || ref.tagName.toLowerCase() !== 'th') {
|
|
83
|
+
invalid.push({ id: headerId, reason: 'not-a-th' });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (table && (!ref.closest || ref.closest('table') !== table)) {
|
|
87
|
+
invalid.push({ id: headerId, reason: 'different-table' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!invalid.length) continue;
|
|
92
|
+
|
|
93
|
+
const tag = el.tagName.toLowerCase();
|
|
94
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
95
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
96
|
+
|
|
97
|
+
occurrences.push({
|
|
98
|
+
selector: stableSelector,
|
|
99
|
+
html,
|
|
100
|
+
summary: 'This cell’s headers attribute references one or more invalid header cells.',
|
|
101
|
+
hint: 'Update the headers attribute so every id refers to a <th> element within the same table.',
|
|
102
|
+
i18n: {
|
|
103
|
+
summaryKey: 'tableHeadersAttrValid_summary_fail',
|
|
104
|
+
hintKey: 'tableHeadersAttrValid_hint_fail',
|
|
105
|
+
params: { element: tag, invalidIds: invalid.map((i) => i.id).join(', ') }
|
|
106
|
+
},
|
|
107
|
+
data: {
|
|
108
|
+
details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (applicableCount === 0) {
|
|
114
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
115
|
+
}
|
|
116
|
+
if (occurrences.length) {
|
|
117
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
118
|
+
}
|
|
119
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check table-th-has-data-cells
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary <th> elements must describe at least one data cell
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to <table> elements that contain at least one <th>.
|
|
11
|
+
* @expectation
|
|
12
|
+
* The table also contains at least one <td> somewhere in it.
|
|
13
|
+
* @implementation-notes
|
|
14
|
+
* - DELIBERATELY SCOPED (see aria-helpers.js file header for the same
|
|
15
|
+
* conservative-scope rationale used throughout this engine): this rule
|
|
16
|
+
* does NOT implement the full HTML5 header-association algorithm
|
|
17
|
+
* (resolving which specific data cells a given <th scope="row"|"col">
|
|
18
|
+
* describes, accounting for colspan/rowspan and default-scope
|
|
19
|
+
* inference). That algorithm is one of the more error-prone parts of
|
|
20
|
+
* the HTML spec to reimplement correctly, and a wrong positional match
|
|
21
|
+
* would risk a false `fail` — unacceptable for this engine's fail-
|
|
22
|
+
* integrity bar.
|
|
23
|
+
* - Instead, this rule only catches the single unambiguous case: a table
|
|
24
|
+
* that has <th> elements but ZERO <td> elements anywhere. In that case
|
|
25
|
+
* every <th> in the table trivially describes no data cell — no
|
|
26
|
+
* positional analysis is needed to know that. A table that has at
|
|
27
|
+
* least one <td> somewhere is not evaluated further by this rule, even
|
|
28
|
+
* if some particular <th> in it doesn't actually describe any cell
|
|
29
|
+
* (false negative, not a false positive — acceptable under this
|
|
30
|
+
* engine's philosophy).
|
|
31
|
+
* - Not gated on isAccTreeEligible: this is a static markup property.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'table-th-has-data-cells';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: '<th> elements must describe at least one data cell',
|
|
38
|
+
description: 'Checks that a table containing <th> elements also contains at least one <td> data cell for those headers to describe.',
|
|
39
|
+
i18n: {
|
|
40
|
+
titleKey: 'tableThHasDataCells_title',
|
|
41
|
+
descriptionKey: 'tableThHasDataCells_description'
|
|
42
|
+
},
|
|
43
|
+
helpUrl: null,
|
|
44
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic', 'table'],
|
|
45
|
+
wcagSc: ['1.3.1'],
|
|
46
|
+
normativeMappings: [
|
|
47
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
48
|
+
],
|
|
49
|
+
defaultSeverity: 'moderate',
|
|
50
|
+
category: 'perceivable',
|
|
51
|
+
type: 'automatic',
|
|
52
|
+
defaultConfidence: 'high',
|
|
53
|
+
coverage: { facetsBySc: { '1.3.1': ['table-th-has-data-cells'] } }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function runInPage(ctx) {
|
|
57
|
+
const { document, root, helpers, rule } = ctx;
|
|
58
|
+
const safeRoot = root || document;
|
|
59
|
+
|
|
60
|
+
const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table', safeRoot) : helpers.queryAll('table', safeRoot);
|
|
61
|
+
|
|
62
|
+
const occurrences = [];
|
|
63
|
+
let applicableCount = 0;
|
|
64
|
+
|
|
65
|
+
for (const table of tables) {
|
|
66
|
+
if (!table || !table.querySelectorAll) continue;
|
|
67
|
+
|
|
68
|
+
let ths = [];
|
|
69
|
+
try {
|
|
70
|
+
ths = table.querySelectorAll('th');
|
|
71
|
+
} catch {
|
|
72
|
+
ths = [];
|
|
73
|
+
}
|
|
74
|
+
if (!ths.length) continue;
|
|
75
|
+
|
|
76
|
+
applicableCount += 1;
|
|
77
|
+
|
|
78
|
+
let hasDataCell = false;
|
|
79
|
+
try {
|
|
80
|
+
hasDataCell = table.querySelectorAll('td').length > 0;
|
|
81
|
+
} catch {
|
|
82
|
+
hasDataCell = false;
|
|
83
|
+
}
|
|
84
|
+
if (hasDataCell) continue;
|
|
85
|
+
|
|
86
|
+
for (const th of ths) {
|
|
87
|
+
if (!th) continue;
|
|
88
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(th) : 'html';
|
|
89
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(th) : (th.outerHTML || '');
|
|
90
|
+
|
|
91
|
+
occurrences.push({
|
|
92
|
+
selector: stableSelector,
|
|
93
|
+
html,
|
|
94
|
+
summary: 'This table has header cells but no data cells for them to describe.',
|
|
95
|
+
hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
|
|
96
|
+
i18n: {
|
|
97
|
+
summaryKey: 'tableThHasDataCells_summary_fail',
|
|
98
|
+
hintKey: 'tableThHasDataCells_hint_fail',
|
|
99
|
+
params: {}
|
|
100
|
+
},
|
|
101
|
+
data: {
|
|
102
|
+
details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (applicableCount === 0) {
|
|
109
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
110
|
+
}
|
|
111
|
+
if (occurrences.length) {
|
|
112
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
113
|
+
}
|
|
114
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { id, meta, runInPage };
|