@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,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check td-has-header
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Data cells in large tables must have an associated header
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.3.1
|
|
9
|
+
* @applicability
|
|
10
|
+
* `<table>` elements with at least 4 rows and at least 4 columns
|
|
11
|
+
* (a "large" table, where implicit row/column header association is
|
|
12
|
+
* genuinely useful — small tables are usually self-evident), and with
|
|
13
|
+
* NO `colspan`/`rowspan` anywhere in the table.
|
|
14
|
+
* @expectation
|
|
15
|
+
* Every `<td>` has an associated header, via one of:
|
|
16
|
+
* - a non-empty `headers` attribute (trusted here; whether it
|
|
17
|
+
* resolves to real `<th>` ids is `table-headers-attr-valid`'s
|
|
18
|
+
* concern, not this rule's), OR
|
|
19
|
+
* - an implicit column header: some `<th>` in the same column, in an
|
|
20
|
+
* earlier row, OR
|
|
21
|
+
* - an implicit row header: some `<th>` earlier in the same row.
|
|
22
|
+
* @implementation-notes
|
|
23
|
+
* - Closes the gap `table-th-has-data-cells` deliberately deferred (see
|
|
24
|
+
* that rule's own implementation notes): this is the fuller positional
|
|
25
|
+
* header-association algorithm, but still intentionally scoped —
|
|
26
|
+
* tables with any `colspan`/`rowspan` are skipped entirely (marked
|
|
27
|
+
* `notApplicable`) rather than risk a wrong column-index computation
|
|
28
|
+
* producing a false `fail`.
|
|
29
|
+
* - The `headers`-attribute branch does not itself validate that the
|
|
30
|
+
* referenced ids exist or point at `<th>` elements — that's already
|
|
31
|
+
* `table-headers-attr-valid`'s job.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const id = 'td-has-header';
|
|
35
|
+
|
|
36
|
+
const meta = {
|
|
37
|
+
title: 'Data cells in large tables must have an associated header',
|
|
38
|
+
description:
|
|
39
|
+
'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header — via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
|
|
40
|
+
i18n: {
|
|
41
|
+
titleKey: 'tdHasHeader_title',
|
|
42
|
+
descriptionKey: 'tdHasHeader_description'
|
|
43
|
+
},
|
|
44
|
+
helpUrl: null,
|
|
45
|
+
tags: ['wcag2a', 'wcag131', 'structure', 'atomic', 'automatic'],
|
|
46
|
+
wcagSc: ['1.3.1'],
|
|
47
|
+
normativeMappings: [
|
|
48
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.3.1', title: 'Info and Relationships', conformanceLevel: 'A' }
|
|
49
|
+
],
|
|
50
|
+
defaultSeverity: 'serious',
|
|
51
|
+
category: 'perceivable',
|
|
52
|
+
type: 'automatic',
|
|
53
|
+
defaultConfidence: 'high',
|
|
54
|
+
coverage: { facetsBySc: { '1.3.1': ['td-has-header'] } }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function runInPage(ctx) {
|
|
58
|
+
const { document, root, helpers, rule } = ctx;
|
|
59
|
+
const safeRoot = root || document;
|
|
60
|
+
|
|
61
|
+
const MIN_SIZE = 4;
|
|
62
|
+
|
|
63
|
+
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
64
|
+
|
|
65
|
+
const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table', safeRoot) : helpers.queryAll('table', safeRoot);
|
|
66
|
+
|
|
67
|
+
const occurrences = [];
|
|
68
|
+
let applicableCount = 0;
|
|
69
|
+
|
|
70
|
+
for (const table of tables) {
|
|
71
|
+
if (!table || !table.rows) continue;
|
|
72
|
+
|
|
73
|
+
const rows = Array.from(table.rows);
|
|
74
|
+
if (rows.length < MIN_SIZE) continue;
|
|
75
|
+
|
|
76
|
+
const rowCells = rows.map((r) => Array.from(r.cells || []));
|
|
77
|
+
const maxCols = rowCells.reduce((m, cells) => Math.max(m, cells.length), 0);
|
|
78
|
+
if (maxCols < MIN_SIZE) continue;
|
|
79
|
+
|
|
80
|
+
const hasSpan = rowCells.some((cells) => cells.some((c) => {
|
|
81
|
+
const cs = Number.parseInt(c.getAttribute('colspan') || '1', 10);
|
|
82
|
+
const rs = Number.parseInt(c.getAttribute('rowspan') || '1', 10);
|
|
83
|
+
return (Number.isFinite(cs) && cs > 1) || (Number.isFinite(rs) && rs > 1);
|
|
84
|
+
}));
|
|
85
|
+
if (hasSpan) continue;
|
|
86
|
+
|
|
87
|
+
applicableCount += 1;
|
|
88
|
+
|
|
89
|
+
function isHeaderCell(cell) {
|
|
90
|
+
return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hasColumnHeaderAbove(r, c) {
|
|
94
|
+
for (let ri = 0; ri < r; ri++) {
|
|
95
|
+
const cell = rowCells[ri] && rowCells[ri][c];
|
|
96
|
+
if (isHeaderCell(cell)) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasRowHeaderBefore(r, c) {
|
|
102
|
+
const cells = rowCells[r] || [];
|
|
103
|
+
for (let ci = 0; ci < c; ci++) {
|
|
104
|
+
if (isHeaderCell(cells[ci])) return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let r = 0; r < rowCells.length; r++) {
|
|
110
|
+
const cells = rowCells[r];
|
|
111
|
+
for (let c = 0; c < cells.length; c++) {
|
|
112
|
+
const cell = cells[c];
|
|
113
|
+
if (!cell || isHeaderCell(cell)) continue;
|
|
114
|
+
|
|
115
|
+
const headersAttr = trim(cell.getAttribute('headers'));
|
|
116
|
+
if (headersAttr) continue;
|
|
117
|
+
|
|
118
|
+
if (hasColumnHeaderAbove(r, c)) continue;
|
|
119
|
+
if (hasRowHeaderBefore(r, c)) continue;
|
|
120
|
+
|
|
121
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(cell) : 'html';
|
|
122
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(cell) : (cell.outerHTML || '');
|
|
123
|
+
|
|
124
|
+
occurrences.push({
|
|
125
|
+
selector: stableSelector,
|
|
126
|
+
html,
|
|
127
|
+
summary: 'This data cell has no associated header (no headers attribute, no column <th> above it, no row <th> to its left).',
|
|
128
|
+
hint: 'Add a headers attribute referencing the relevant <th> id(s), or restructure the table so this cell has an implicit row/column header.',
|
|
129
|
+
i18n: {
|
|
130
|
+
summaryKey: 'tdHasHeader_summary_fail',
|
|
131
|
+
hintKey: 'tdHasHeader_hint_fail',
|
|
132
|
+
params: { row: String(r), column: String(c) }
|
|
133
|
+
},
|
|
134
|
+
data: {
|
|
135
|
+
details: { reasonCode: 'TD_NO_ASSOCIATED_HEADER', row: r, column: c }
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (applicableCount === 0) {
|
|
143
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
144
|
+
}
|
|
145
|
+
if (occurrences.length) {
|
|
146
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
147
|
+
}
|
|
148
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'textbox-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Accessible name is present',
|
|
7
|
+
description: 'Checks that elements expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'textboxNamePresent_title',
|
|
10
|
+
descriptionKey: 'textboxNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ["wcag2a", "wcag412", "forms", "atomic", "automatic", "name", "textbox"],
|
|
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': ['textbox-name-present'] } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runInPage(ctx) {
|
|
26
|
+
const { document, root, helpers, rule } = ctx;
|
|
27
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
|
+
? helpers.getEligibilityInfo
|
|
29
|
+
: null;
|
|
30
|
+
const safeRoot = root || document;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
function normalizeWs(s) {
|
|
34
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAttr(el, name) {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !el.getAttribute) return '';
|
|
40
|
+
return normalizeWs(el.getAttribute(name));
|
|
41
|
+
} catch { return ''; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getConservativeSubtreeText(document, container) {
|
|
45
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
46
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
47
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
48
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
49
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
50
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
51
|
+
// icon-button pattern).
|
|
52
|
+
if (helpers.getContentNameInfo) {
|
|
53
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
54
|
+
return info && info.present ? info.value : '';
|
|
55
|
+
}
|
|
56
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
57
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A <label> contributes a name via its own aria-label/aria-labelledby
|
|
61
|
+
// (checked first, same ARIA-over-content precedence any element's
|
|
62
|
+
// accessible name gives — e.g. <label aria-label="Search"><svg
|
|
63
|
+
// aria-hidden="true">...</svg></label> names its control "Search" even
|
|
64
|
+
// though the label's only child content is aria-hidden) or, failing
|
|
65
|
+
// that, its rendered content (getConservativeSubtreeText).
|
|
66
|
+
function getLabelText(lab) {
|
|
67
|
+
if (helpers.getAriaNameInfo) {
|
|
68
|
+
try {
|
|
69
|
+
const aria = helpers.getAriaNameInfo(lab, ctx);
|
|
70
|
+
if (aria && aria.present && aria.value) return normalizeWs(aria.value);
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
const content = getConservativeSubtreeText(document, lab);
|
|
74
|
+
if (content) return content;
|
|
75
|
+
// Final fallback per the general accname text-alternative algorithm,
|
|
76
|
+
// which applies to any element being asked for its name regardless of
|
|
77
|
+
// why (own aria-label, an aria-labelledby reference, or — here — native
|
|
78
|
+
// <label for> association): title, when nothing else yields a name.
|
|
79
|
+
// Purely additive (only fills in a name where there was none before),
|
|
80
|
+
// so it carries no false-positive risk — see dialog-name-present.js's
|
|
81
|
+
// identical <iframe>-title-fallback fix for the concrete real-world
|
|
82
|
+
// trigger this same accname step covers elsewhere.
|
|
83
|
+
return getAttr(lab, 'title');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
87
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
88
|
+
if (!raw) return '';
|
|
89
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
90
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
91
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
92
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
93
|
+
// which name-from-content alone can never see).
|
|
94
|
+
if (helpers.getTextFromIdRefs) {
|
|
95
|
+
try {
|
|
96
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
97
|
+
return normalizeWs(r && r.text);
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
104
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
105
|
+
if (!fn) return true;
|
|
106
|
+
try {
|
|
107
|
+
const r = fn(el, ctx);
|
|
108
|
+
if (typeof r === 'boolean') return r;
|
|
109
|
+
return !!(r && r.eligible);
|
|
110
|
+
} catch {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildLabelForMap(doc) {
|
|
116
|
+
const map = new Map(); // id -> label element (first)
|
|
117
|
+
try {
|
|
118
|
+
const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
|
|
119
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
120
|
+
const lab = labels[i];
|
|
121
|
+
if (!lab || !lab.getAttribute) continue;
|
|
122
|
+
const f = normalizeWs(lab.getAttribute('for'));
|
|
123
|
+
if (!f) continue;
|
|
124
|
+
if (!map.has(f)) map.set(f, lab);
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
return map;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
const occurrences = [];
|
|
132
|
+
let applicableCount = 0;
|
|
133
|
+
|
|
134
|
+
const selector = "[role=\"textbox\"]";
|
|
135
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
136
|
+
|
|
137
|
+
// Precompute label[for] map for textbox elements that are labelable
|
|
138
|
+
// native form controls (e.g. <input role="textbox">).
|
|
139
|
+
const labelForMap = buildLabelForMap(document);
|
|
140
|
+
|
|
141
|
+
function getNativeLabelText(el) {
|
|
142
|
+
try {
|
|
143
|
+
if ('labels' in el && el.labels && el.labels.length) {
|
|
144
|
+
const parts = [];
|
|
145
|
+
const max = Math.min(4, el.labels.length);
|
|
146
|
+
for (let i = 0; i < max; i += 1) {
|
|
147
|
+
const lab = el.labels[i];
|
|
148
|
+
const t = lab ? getLabelText(lab) : '';
|
|
149
|
+
if (t) parts.push(t);
|
|
150
|
+
}
|
|
151
|
+
const joined = normalizeWs(parts.join(' '));
|
|
152
|
+
if (joined) return joined;
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
try {
|
|
156
|
+
if (el.closest) {
|
|
157
|
+
const wrap = el.closest('label');
|
|
158
|
+
if (wrap) {
|
|
159
|
+
const t = getLabelText(wrap);
|
|
160
|
+
if (t) return t;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
try {
|
|
165
|
+
const idAttr = getAttr(el, 'id');
|
|
166
|
+
if (idAttr && labelForMap.has(idAttr)) {
|
|
167
|
+
const lab = labelForMap.get(idAttr);
|
|
168
|
+
const t = lab ? getLabelText(lab) : '';
|
|
169
|
+
if (t) return t;
|
|
170
|
+
}
|
|
171
|
+
} catch {}
|
|
172
|
+
return '';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hasName(el) {
|
|
176
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
177
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
178
|
+
|
|
179
|
+
const labelled = resolveAriaLabelledbyText(document, el, 8);
|
|
180
|
+
if (labelled) return { ok: true, method: 'aria-labelledby' };
|
|
181
|
+
|
|
182
|
+
const title = getAttr(el, 'title');
|
|
183
|
+
if (title) return { ok: true, method: 'title' };
|
|
184
|
+
|
|
185
|
+
// Native <label> association (e.g. <input role="textbox">).
|
|
186
|
+
const lab = getNativeLabelText(el);
|
|
187
|
+
if (lab) return { ok: true, method: 'label' };
|
|
188
|
+
|
|
189
|
+
// role="textbox" is name-from-author-only per WAI-ARIA: it must NOT
|
|
190
|
+
// fall back to subtree content.
|
|
191
|
+
return { ok: false, method: 'none' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const el of nodes) {
|
|
195
|
+
if (!el) continue;
|
|
196
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
197
|
+
|
|
198
|
+
applicableCount += 1;
|
|
199
|
+
|
|
200
|
+
const res = hasName(el);
|
|
201
|
+
if (res.ok) continue;
|
|
202
|
+
|
|
203
|
+
const eligInfo = getEligibilityInfo
|
|
204
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
205
|
+
: null;
|
|
206
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
207
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
208
|
+
|
|
209
|
+
occurrences.push({
|
|
210
|
+
selector: stableSelector,
|
|
211
|
+
html,
|
|
212
|
+
summary: 'This element has no accessible name.',
|
|
213
|
+
hint: 'Provide aria-label or aria-labelledby (preferred), or provide visible text that is not hidden from assistive technologies.',
|
|
214
|
+
i18n: {
|
|
215
|
+
summaryKey: 'textboxNamePresent_summary_fail',
|
|
216
|
+
hintKey: 'textboxNamePresent_hint_fail',
|
|
217
|
+
params: { controlType: 'textbox' }
|
|
218
|
+
},
|
|
219
|
+
data: {
|
|
220
|
+
details: { reasonCode: 'name_missing', controlType: 'textbox', methodTried: res.method },
|
|
221
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if (applicableCount === 0) {
|
|
228
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
229
|
+
}
|
|
230
|
+
if (occurrences.length) {
|
|
231
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
232
|
+
}
|
|
233
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'tooltip-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Tooltips have an accessible name',
|
|
7
|
+
description: 'Checks that elements with role="tooltip" expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'tooltipNamePresent_title',
|
|
10
|
+
descriptionKey: 'tooltipNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
|
|
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': ['tooltip-name-present'] } }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function runInPage(ctx) {
|
|
26
|
+
const { document, root, helpers, rule } = ctx;
|
|
27
|
+
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
|
+
? helpers.getEligibilityInfo
|
|
29
|
+
: null;
|
|
30
|
+
const safeRoot = root || document;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
function normalizeWs(s) {
|
|
34
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getAttr(el, name) {
|
|
38
|
+
try {
|
|
39
|
+
if (!el || !el.getAttribute) return '';
|
|
40
|
+
return normalizeWs(el.getAttribute(name));
|
|
41
|
+
} catch { return ''; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getConservativeSubtreeText(document, container) {
|
|
45
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
46
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
47
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
48
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
49
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
50
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
51
|
+
// icon-button pattern).
|
|
52
|
+
if (helpers.getContentNameInfo) {
|
|
53
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
54
|
+
return info && info.present ? info.value : '';
|
|
55
|
+
}
|
|
56
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
57
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
61
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
62
|
+
if (!raw) return '';
|
|
63
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
64
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
65
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
66
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
67
|
+
// which name-from-content alone can never see).
|
|
68
|
+
if (helpers.getTextFromIdRefs) {
|
|
69
|
+
try {
|
|
70
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
71
|
+
return normalizeWs(r && r.text);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
78
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
79
|
+
if (!fn) return true;
|
|
80
|
+
try {
|
|
81
|
+
const r = fn(el, ctx);
|
|
82
|
+
if (typeof r === 'boolean') return r;
|
|
83
|
+
return !!(r && r.eligible);
|
|
84
|
+
} catch {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
const occurrences = [];
|
|
91
|
+
let applicableCount = 0;
|
|
92
|
+
|
|
93
|
+
const selector = '[role="tooltip"]';
|
|
94
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
function evaluate(el) {
|
|
98
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
99
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
100
|
+
|
|
101
|
+
const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
|
|
102
|
+
if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
|
|
103
|
+
|
|
104
|
+
const title = getAttr(el, 'title');
|
|
105
|
+
if (title) return { ok: true, method: 'title' };
|
|
106
|
+
|
|
107
|
+
const t = getConservativeSubtreeText(document, el);
|
|
108
|
+
if (t) return { ok: true, method: 'content' };
|
|
109
|
+
|
|
110
|
+
return { ok: false, method: 'none' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const el of nodes) {
|
|
114
|
+
if (!el) continue;
|
|
115
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
116
|
+
|
|
117
|
+
const role = getAttr(el, 'role').toLowerCase();
|
|
118
|
+
if (role !== 'tooltip') continue;
|
|
119
|
+
|
|
120
|
+
applicableCount += 1;
|
|
121
|
+
|
|
122
|
+
const res = evaluate(el);
|
|
123
|
+
if (res.ok) continue;
|
|
124
|
+
|
|
125
|
+
const eligInfo = getEligibilityInfo
|
|
126
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
127
|
+
: null;
|
|
128
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
129
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
130
|
+
|
|
131
|
+
occurrences.push({
|
|
132
|
+
selector: stableSelector,
|
|
133
|
+
html,
|
|
134
|
+
summary: 'This tooltip has no accessible name.',
|
|
135
|
+
hint: 'Provide tooltip text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
|
|
136
|
+
i18n: {
|
|
137
|
+
summaryKey: 'tooltipNamePresent_summary_fail',
|
|
138
|
+
hintKey: 'tooltipNamePresent_hint_fail',
|
|
139
|
+
params: {}
|
|
140
|
+
},
|
|
141
|
+
data: {
|
|
142
|
+
details: { reasonCode: 'name_missing', controlType: 'tooltip', methodTried: res.method },
|
|
143
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if (applicableCount === 0) {
|
|
150
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
151
|
+
}
|
|
152
|
+
if (occurrences.length) {
|
|
153
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
154
|
+
}
|
|
155
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { id, meta, runInPage };
|