@surea11y/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/core.js +244 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
- package/docs/CLI.md +49 -0
- package/docs/ENGINE_OPTIONS.md +155 -0
- package/docs/I18N.md +47 -0
- package/docs/INTEGRATION.md +156 -0
- package/docs/LIMITATIONS.md +31 -0
- package/docs/OUTPUT_SCHEMA.md +237 -0
- package/docs/POLICY.md +71 -0
- package/docs/RULE_AUTHORING.md +375 -0
- package/docs/RULE_CATALOG.md +180 -0
- package/docs/RULE_TAXONOMY.md +145 -0
- package/docs/TROUBLESHOOTING.md +48 -0
- package/docs/WCAG_CONFORMANCE.md +49 -0
- package/package.json +60 -0
- package/src/catalogs/composites.wcag.js +490 -0
- package/src/checks/automatic/area-alt-present.js +225 -0
- package/src/checks/automatic/aria-allowed-attr.js +206 -0
- package/src/checks/automatic/aria-allowed-role.js +102 -0
- package/src/checks/automatic/aria-braille-equivalent.js +139 -0
- package/src/checks/automatic/aria-conditional-attr.js +110 -0
- package/src/checks/automatic/aria-deprecated-role.js +106 -0
- package/src/checks/automatic/aria-hidden-body.js +87 -0
- package/src/checks/automatic/aria-hidden-focus.js +480 -0
- package/src/checks/automatic/aria-prohibited-attr.js +156 -0
- package/src/checks/automatic/aria-prohibited-children.js +265 -0
- package/src/checks/automatic/aria-required-attr.js +154 -0
- package/src/checks/automatic/aria-required-children.js +274 -0
- package/src/checks/automatic/aria-required-parent.js +222 -0
- package/src/checks/automatic/aria-role-name-present.js +201 -0
- package/src/checks/automatic/aria-roles-valid.js +110 -0
- package/src/checks/automatic/aria-valid-attr-value.js +123 -0
- package/src/checks/automatic/aria-valid-attr.js +109 -0
- package/src/checks/automatic/autocomplete-valid.js +134 -0
- package/src/checks/automatic/avoid-inline-spacing.js +107 -0
- package/src/checks/automatic/binary-control-name-present.js +294 -0
- package/src/checks/automatic/button-name-present.js +146 -0
- package/src/checks/automatic/bypass-blocks-present.js +162 -0
- package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
- package/src/checks/automatic/combobox-name-present.js +267 -0
- package/src/checks/automatic/contrast-computable.js +378 -0
- package/src/checks/automatic/contrast-enhanced.js +517 -0
- package/src/checks/automatic/contrast-minimum.js +512 -0
- package/src/checks/automatic/css-orientation-lock.js +206 -0
- package/src/checks/automatic/definition-list-children-valid.js +148 -0
- package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
- package/src/checks/automatic/dialog-name-present.js +209 -0
- package/src/checks/automatic/dlitem-parent-valid.js +100 -0
- package/src/checks/automatic/duplicate-id-aria.js +126 -0
- package/src/checks/automatic/embed-text-alternative-present.js +190 -0
- package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
- package/src/checks/automatic/form-control-single-label.js +117 -0
- package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
- package/src/checks/automatic/iframe-focusable-content.js +141 -0
- package/src/checks/automatic/iframe-name-present.js +102 -0
- package/src/checks/automatic/iframe-title-unique.js +107 -0
- package/src/checks/automatic/img-alt-present.js +223 -0
- package/src/checks/automatic/input-image-alt-present.js +155 -0
- package/src/checks/automatic/label-in-name.js +326 -0
- package/src/checks/automatic/language-page-present.js +159 -0
- package/src/checks/automatic/link-in-text-block.js +218 -0
- package/src/checks/automatic/link-name-present.js +114 -0
- package/src/checks/automatic/list-children-valid.js +152 -0
- package/src/checks/automatic/listbox-name-present.js +236 -0
- package/src/checks/automatic/listitem-parent-valid.js +118 -0
- package/src/checks/automatic/menuitem-name-present.js +201 -0
- package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
- package/src/checks/automatic/meter-name-present.js +160 -0
- package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
- package/src/checks/automatic/object-text-alternative-present.js +193 -0
- package/src/checks/automatic/option-name-present.js +157 -0
- package/src/checks/automatic/page-title-present.js +86 -0
- package/src/checks/automatic/progressbar-name-present.js +165 -0
- package/src/checks/automatic/role-img-alt-present.js +206 -0
- package/src/checks/automatic/searchbox-name-present.js +236 -0
- package/src/checks/automatic/server-side-image-map-absent.js +88 -0
- package/src/checks/automatic/slider-name-present.js +276 -0
- package/src/checks/automatic/spinbutton-name-present.js +236 -0
- package/src/checks/automatic/summary-name-present.js +153 -0
- package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
- package/src/checks/automatic/svg-text-alternative-present.js +298 -0
- package/src/checks/automatic/tab-name-present.js +200 -0
- package/src/checks/automatic/table-headers-attr-valid.js +122 -0
- package/src/checks/automatic/table-th-has-data-cells.js +117 -0
- package/src/checks/automatic/target-size-minimum.js +605 -0
- package/src/checks/automatic/td-has-header.js +151 -0
- package/src/checks/automatic/textbox-name-present.js +236 -0
- package/src/checks/automatic/tooltip-name-present.js +158 -0
- package/src/checks/automatic/treeitem-name-present.js +157 -0
- package/src/checks/automatic/valid-lang.js +100 -0
- package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
- package/src/checks/manual/accesskeys-manual.js +93 -0
- package/src/checks/manual/area-alt-decorative-manual.js +247 -0
- package/src/checks/manual/area-alt-quality-manual.js +204 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
- package/src/checks/manual/aria-text-manual.js +109 -0
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
- package/src/checks/manual/css-hidden-focus.js +259 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
- package/src/checks/manual/empty-heading-manual.js +182 -0
- package/src/checks/manual/empty-table-header-manual.js +163 -0
- package/src/checks/manual/focus-order-semantics-manual.js +117 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
- package/src/checks/manual/heading-order-manual.js +130 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
- package/src/checks/manual/image-redundant-alt-manual.js +118 -0
- package/src/checks/manual/img-alt-decorative-manual.js +148 -0
- package/src/checks/manual/img-alt-quality-manual.js +182 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
- package/src/checks/manual/label-title-only-manual.js +115 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
- package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
- package/src/checks/manual/landmark-one-main-manual.js +151 -0
- package/src/checks/manual/landmark-unique-manual.js +252 -0
- package/src/checks/manual/link-name-quality-manual.js +143 -0
- package/src/checks/manual/media-transcript-present-manual.js +373 -0
- package/src/checks/manual/meta-viewport-large-manual.js +119 -0
- package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
- package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
- package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
- package/src/checks/manual/p-as-heading-manual.js +163 -0
- package/src/checks/manual/page-has-heading-one-manual.js +110 -0
- package/src/checks/manual/page-title-patterns-manual.js +262 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
- package/src/checks/manual/region-manual.js +183 -0
- package/src/checks/manual/scope-attr-valid-manual.js +93 -0
- package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
- package/src/checks/manual/skip-link-manual.js +150 -0
- package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
- package/src/checks/manual/tabindex-manual.js +94 -0
- package/src/checks/manual/table-duplicate-name-manual.js +99 -0
- package/src/checks/manual/table-fake-caption-manual.js +122 -0
- package/src/checks/manual/video-caption-manual.js +118 -0
- package/src/checks/manual-review.js +95 -0
- package/src/checks/rules-and-tags.full.csv +19 -0
- package/src/checks/rules-and-tags.full.json +259 -0
- package/src/core/aria-helpers.js +906 -0
- package/src/core/contrast-helpers.js +1147 -0
- package/src/core/dom-helpers.js +4085 -0
- package/src/core/dom-runner.js +627 -0
- package/src/core/frame-messaging.js +210 -0
- package/src/core/frame-scan.js +178 -0
- package/src/core/rollup-composites.js +135 -0
- package/src/core/rule-meta.js +140 -0
- package/src/core.js +79055 -0
- package/src/coverage/wcag-facets.js +1079 -0
- package/src/coverage/wcag-version-map.js +84 -0
- package/src/i18n/en.js +919 -0
- package/src/i18n/fr.js +527 -0
- package/src/index.js +4 -0
- package/src/policy/contracts.js +18 -0
- package/src/policy/resolvePolicy.js +55 -0
- package/src/policy/schemas/engine-options.schema.json +103 -0
- package/src/policy/schemas/policy-contract.schema.json +40 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @check avoid-inline-spacing
|
|
5
|
+
* @atomic true
|
|
6
|
+
* @summary Inline style must not force text-spacing properties with !important
|
|
7
|
+
* @standard WCAG 2.2
|
|
8
|
+
* @sc 1.4.12
|
|
9
|
+
* @applicability
|
|
10
|
+
* Applies to elements with an inline style attribute that sets
|
|
11
|
+
* line-height, letter-spacing, or word-spacing.
|
|
12
|
+
* @expectation
|
|
13
|
+
* None of those three properties are declared with `!important` in the
|
|
14
|
+
* inline style. WCAG 1.4.12 (Text Spacing) requires that users be able
|
|
15
|
+
* to override these properties via a user stylesheet without losing
|
|
16
|
+
* content or functionality; `!important` on an inline declaration
|
|
17
|
+
* defeats that override (inline `!important` wins over nearly every
|
|
18
|
+
* other origin, including user stylesheets applied the normal way).
|
|
19
|
+
* @implementation-notes
|
|
20
|
+
* - Static text-based parsing of the style attribute string (regex for
|
|
21
|
+
* the three property names followed by `!important`), not computed
|
|
22
|
+
* style — matches the property list a widely-used reference engine's avoid-inline-spacing
|
|
23
|
+
* rule checks.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const id = 'avoid-inline-spacing';
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Inline style must not force text spacing with !important',
|
|
30
|
+
description: 'Checks that inline style does not set line-height, letter-spacing, or word-spacing with !important, which blocks user text-spacing overrides.',
|
|
31
|
+
i18n: {
|
|
32
|
+
titleKey: 'avoidInlineSpacing_title',
|
|
33
|
+
descriptionKey: 'avoidInlineSpacing_description'
|
|
34
|
+
},
|
|
35
|
+
helpUrl: null,
|
|
36
|
+
tags: ['wcag21aa', 'wcag1412', 'structure', 'atomic', 'automatic'],
|
|
37
|
+
wcagSc: ['1.4.12'],
|
|
38
|
+
normativeMappings: [
|
|
39
|
+
{ standard: 'WCAG', version: '2.2', requirement: '1.4.12', title: 'Text Spacing', conformanceLevel: 'AA' }
|
|
40
|
+
],
|
|
41
|
+
defaultSeverity: 'moderate',
|
|
42
|
+
category: 'perceivable',
|
|
43
|
+
type: 'automatic',
|
|
44
|
+
defaultConfidence: 'high',
|
|
45
|
+
coverage: { facetsBySc: { '1.4.12': ['avoid-inline-spacing'] } }
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function runInPage(ctx) {
|
|
49
|
+
const { document, root, helpers, rule } = ctx;
|
|
50
|
+
const safeRoot = root || document;
|
|
51
|
+
|
|
52
|
+
const SPACING_PROPS = ['line-height', 'letter-spacing', 'word-spacing'];
|
|
53
|
+
|
|
54
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[style]', safeRoot) : helpers.queryAll('[style]', safeRoot);
|
|
55
|
+
|
|
56
|
+
const occurrences = [];
|
|
57
|
+
let applicableCount = 0;
|
|
58
|
+
|
|
59
|
+
for (const el of nodes) {
|
|
60
|
+
if (!el || !el.getAttribute) continue;
|
|
61
|
+
const raw = String(el.getAttribute('style') || '');
|
|
62
|
+
if (!raw.trim()) continue;
|
|
63
|
+
|
|
64
|
+
const lower = raw.toLowerCase();
|
|
65
|
+
const hasAnySpacingProp = SPACING_PROPS.some((p) => lower.includes(p));
|
|
66
|
+
if (!hasAnySpacingProp) continue;
|
|
67
|
+
|
|
68
|
+
applicableCount += 1;
|
|
69
|
+
|
|
70
|
+
const flagged = [];
|
|
71
|
+
for (const prop of SPACING_PROPS) {
|
|
72
|
+
const re = new RegExp(prop.replace('-', '\\-') + '\\s*:[^;]*!important', 'i');
|
|
73
|
+
if (re.test(raw)) flagged.push(prop);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!flagged.length) continue;
|
|
77
|
+
|
|
78
|
+
const tag = el.tagName.toLowerCase();
|
|
79
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
80
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
81
|
+
|
|
82
|
+
occurrences.push({
|
|
83
|
+
selector: stableSelector,
|
|
84
|
+
html,
|
|
85
|
+
summary: `This element's inline style forces ${flagged.join(', ')} with !important, blocking user text-spacing overrides.`,
|
|
86
|
+
hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles so users can override text spacing.',
|
|
87
|
+
i18n: {
|
|
88
|
+
summaryKey: 'avoidInlineSpacing_summary_fail',
|
|
89
|
+
hintKey: 'avoidInlineSpacing_hint_fail',
|
|
90
|
+
params: { element: tag, properties: flagged.join(', ') }
|
|
91
|
+
},
|
|
92
|
+
data: {
|
|
93
|
+
details: { reasonCode: 'INLINE_SPACING_IMPORTANT', element: tag, properties: flagged }
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (applicableCount === 0) {
|
|
99
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
100
|
+
}
|
|
101
|
+
if (occurrences.length) {
|
|
102
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'moderate', occurrences };
|
|
103
|
+
}
|
|
104
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const id = 'binary-control-name-present';
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Binary controls have an accessible name',
|
|
7
|
+
description: 'Checks that checkbox, radio, and switch controls expose a non-empty accessible name.',
|
|
8
|
+
i18n: {
|
|
9
|
+
titleKey: 'binaryControlNamePresent_title',
|
|
10
|
+
descriptionKey: 'binaryControlNamePresent_description'
|
|
11
|
+
},
|
|
12
|
+
helpUrl: null,
|
|
13
|
+
tags: ['wcag2a','wcag412','forms','atomic','automatic','name','checkbox','radio','switch'],
|
|
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': ['checkbox-name-present','radio-name-present','switch-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
|
+
// A <label> contributes a name via its own aria-label/aria-labelledby
|
|
94
|
+
// (checked first, same ARIA-over-content precedence any element's
|
|
95
|
+
// accessible name gives — e.g. <label aria-label="Search"><svg
|
|
96
|
+
// aria-hidden="true">...</svg></label> names its control "Search" even
|
|
97
|
+
// though the label's only child content is aria-hidden) or, failing
|
|
98
|
+
// that, its rendered content (getConservativeSubtreeText).
|
|
99
|
+
function getLabelText(lab) {
|
|
100
|
+
if (helpers.getAriaNameInfo) {
|
|
101
|
+
try {
|
|
102
|
+
const aria = helpers.getAriaNameInfo(lab, ctx);
|
|
103
|
+
if (aria && aria.present && aria.value) return normalizeWs(aria.value);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
const content = getConservativeSubtreeText(document, lab);
|
|
107
|
+
if (content) return content;
|
|
108
|
+
// Final fallback per the general accname text-alternative algorithm,
|
|
109
|
+
// which applies to any element being asked for its name regardless of
|
|
110
|
+
// why (own aria-label, an aria-labelledby reference, or — here — native
|
|
111
|
+
// <label for> association): title, when nothing else yields a name.
|
|
112
|
+
// Purely additive (only fills in a name where there was none before),
|
|
113
|
+
// so it carries no false-positive risk — see dialog-name-present.js's
|
|
114
|
+
// identical <iframe>-title-fallback fix for the concrete real-world
|
|
115
|
+
// trigger this same accname step covers elsewhere.
|
|
116
|
+
return getAttr(lab, 'title');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveAriaLabelledbyText(document, el, maxRefs) {
|
|
120
|
+
const raw = getAttr(el, 'aria-labelledby');
|
|
121
|
+
if (!raw) return '';
|
|
122
|
+
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
123
|
+
// name-from-content of the referenced element — see dialog-name-
|
|
124
|
+
// present.js's identical fix for the full rationale (an <iframe>
|
|
125
|
+
// aria-labelledby target's only name source is its title attribute,
|
|
126
|
+
// which name-from-content alone can never see).
|
|
127
|
+
if (helpers.getTextFromIdRefs) {
|
|
128
|
+
try {
|
|
129
|
+
const r = helpers.getTextFromIdRefs(raw, ctx, { maxRefs: maxRefs || 8 });
|
|
130
|
+
return normalizeWs(r && r.text);
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getInputValueName(el) {
|
|
137
|
+
try {
|
|
138
|
+
const type = getAttr(el, 'type').toLowerCase();
|
|
139
|
+
if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
|
|
140
|
+
const v = getAttr(el, 'value');
|
|
141
|
+
return v;
|
|
142
|
+
} catch { return ''; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isEligibleAcc(helpers, el, ctx) {
|
|
146
|
+
const fn = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
147
|
+
if (!fn) return true;
|
|
148
|
+
try {
|
|
149
|
+
const r = fn(el, ctx);
|
|
150
|
+
if (typeof r === 'boolean') return r;
|
|
151
|
+
return !!(r && r.eligible);
|
|
152
|
+
} catch {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
const occurrences = [];
|
|
159
|
+
let applicableCount = 0;
|
|
160
|
+
|
|
161
|
+
const selector = 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
|
|
162
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// Precompute label[for] associations once for speed/determinism.
|
|
166
|
+
const labelForMap = buildLabelForMap(document);
|
|
167
|
+
|
|
168
|
+
function getNativeLabelText(el) {
|
|
169
|
+
// 1) labels API
|
|
170
|
+
try {
|
|
171
|
+
if ('labels' in el && el.labels && el.labels.length) {
|
|
172
|
+
// concatenate conservative text of all associated labels (cap at 4 for determinism)
|
|
173
|
+
const parts = [];
|
|
174
|
+
const max = Math.min(4, el.labels.length);
|
|
175
|
+
for (let i = 0; i < max; i += 1) {
|
|
176
|
+
const lab = el.labels[i];
|
|
177
|
+
const t = lab ? getLabelText(lab) : '';
|
|
178
|
+
if (t) parts.push(t);
|
|
179
|
+
}
|
|
180
|
+
const joined = normalizeWs(parts.join(' '));
|
|
181
|
+
if (joined) return joined;
|
|
182
|
+
}
|
|
183
|
+
} catch {}
|
|
184
|
+
|
|
185
|
+
// 2) wrapped by <label>
|
|
186
|
+
try {
|
|
187
|
+
if (el.closest) {
|
|
188
|
+
const wrap = el.closest('label');
|
|
189
|
+
if (wrap) {
|
|
190
|
+
const t = getLabelText(wrap);
|
|
191
|
+
if (t) return t;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
|
|
196
|
+
// 3) label[for=id]
|
|
197
|
+
try {
|
|
198
|
+
const idAttr = getAttr(el, 'id');
|
|
199
|
+
if (idAttr && labelForMap.has(idAttr)) {
|
|
200
|
+
const lab = labelForMap.get(idAttr);
|
|
201
|
+
const t = lab ? getLabelText(lab) : '';
|
|
202
|
+
if (t) return t;
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
|
|
206
|
+
return '';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function evaluate(el, controlType) {
|
|
210
|
+
// Programmatic mechanisms
|
|
211
|
+
const ariaLabel = getAttr(el, 'aria-label');
|
|
212
|
+
if (ariaLabel) return { ok: true, method: 'aria-label' };
|
|
213
|
+
|
|
214
|
+
const ariaLabelledby = resolveAriaLabelledbyText(document, el, 8);
|
|
215
|
+
if (ariaLabelledby) return { ok: true, method: 'aria-labelledby' };
|
|
216
|
+
|
|
217
|
+
const title = getAttr(el, 'title');
|
|
218
|
+
if (title) return { ok: true, method: 'title' };
|
|
219
|
+
|
|
220
|
+
// Native input label association (for input[type=checkbox|radio])
|
|
221
|
+
if (controlType === 'checkbox' || controlType === 'radio') {
|
|
222
|
+
const lab = getNativeLabelText(el);
|
|
223
|
+
if (lab) return { ok: true, method: 'label' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ARIA roles (checkbox/radio/switch) can use content as name
|
|
227
|
+
if (controlType === 'switch' || controlType === 'aria-checkbox' || controlType === 'aria-radio') {
|
|
228
|
+
const t = getConservativeSubtreeText(document, el);
|
|
229
|
+
if (t) return { ok: true, method: 'content' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { ok: false, method: 'none' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const el of nodes) {
|
|
236
|
+
if (!el) continue;
|
|
237
|
+
if (!isEligibleAcc(helpers, el, ctx)) continue;
|
|
238
|
+
|
|
239
|
+
// Determine control type
|
|
240
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
241
|
+
const type = getAttr(el, 'type').toLowerCase();
|
|
242
|
+
const role = getAttr(el, 'role').toLowerCase();
|
|
243
|
+
|
|
244
|
+
let controlType = '';
|
|
245
|
+
if (tag === 'input' && type === 'checkbox') controlType = 'checkbox';
|
|
246
|
+
else if (tag === 'input' && type === 'radio') controlType = 'radio';
|
|
247
|
+
else if (role === 'checkbox') controlType = 'aria-checkbox';
|
|
248
|
+
else if (role === 'radio') controlType = 'aria-radio';
|
|
249
|
+
else if (role === 'switch') controlType = 'switch';
|
|
250
|
+
else continue;
|
|
251
|
+
|
|
252
|
+
applicableCount += 1;
|
|
253
|
+
|
|
254
|
+
const res = evaluate(el, controlType);
|
|
255
|
+
if (res.ok) continue;
|
|
256
|
+
|
|
257
|
+
const eligInfo = getEligibilityInfo
|
|
258
|
+
? (() => { try { return getEligibilityInfo(el, ctx, { targetSet: 'acc' }); } catch { return null; } })()
|
|
259
|
+
: null;
|
|
260
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
261
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
262
|
+
|
|
263
|
+
occurrences.push({
|
|
264
|
+
selector: stableSelector,
|
|
265
|
+
html,
|
|
266
|
+
summary: 'This control has no accessible name.',
|
|
267
|
+
hint: 'Provide a label, aria-label, aria-labelledby, or other accessible-name mechanism so assistive technologies can identify the control.',
|
|
268
|
+
i18n: {
|
|
269
|
+
summaryKey: 'binaryControlNamePresent_summary_fail',
|
|
270
|
+
hintKey: 'binaryControlNamePresent_hint_fail',
|
|
271
|
+
params: { controlType }
|
|
272
|
+
},
|
|
273
|
+
data: {
|
|
274
|
+
details: {
|
|
275
|
+
reasonCode: 'name_missing',
|
|
276
|
+
controlType,
|
|
277
|
+
methodTried: res.method
|
|
278
|
+
},
|
|
279
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if (applicableCount === 0) {
|
|
286
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
287
|
+
}
|
|
288
|
+
if (occurrences.length) {
|
|
289
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
290
|
+
}
|
|
291
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { id, meta, runInPage };
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// NOTE: Repo ruleId contract requires ENGINE_TAG prefix in the rule id.
|
|
4
|
+
// File name intentionally has no prefix, per request.
|
|
5
|
+
const id = 'button-name-present';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Buttons have an accessible name',
|
|
9
|
+
description: 'Checks that buttons expose a non-empty accessible name.',
|
|
10
|
+
i18n: {
|
|
11
|
+
titleKey: 'buttonNamePresent_title',
|
|
12
|
+
descriptionKey: 'buttonNamePresent_description'
|
|
13
|
+
},
|
|
14
|
+
helpUrl: null,
|
|
15
|
+
tags: ['wcag2a', 'wcag412', 'forms', 'atomic', 'automatic', 'buttons', 'name'],
|
|
16
|
+
wcagSc: ['4.1.2'],
|
|
17
|
+
normativeMappings: [
|
|
18
|
+
{ standard: 'WCAG', version: '2.2', requirement: '4.1.2', title: 'Name, Role, Value', conformanceLevel: 'A' }
|
|
19
|
+
],
|
|
20
|
+
defaultSeverity: 'serious',
|
|
21
|
+
category: 'robust',
|
|
22
|
+
type: 'automatic',
|
|
23
|
+
defaultConfidence: 'high',
|
|
24
|
+
coverage: { facetsBySc: { '4.1.2': ['button-name-present'] } }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function runInPage(ctx) {
|
|
28
|
+
const { document, root, helpers, rule } = ctx;
|
|
29
|
+
const safeRoot = root || document;
|
|
30
|
+
|
|
31
|
+
const occurrences = [];
|
|
32
|
+
let applicableCount = 0;
|
|
33
|
+
|
|
34
|
+
function normalizeWs(s) {
|
|
35
|
+
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getConservativeSubtreeText(container) {
|
|
39
|
+
// "Name from content" — recurses into descendants and uses each one's
|
|
40
|
+
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
41
|
+
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
42
|
+
// header comment in src/core/dom-helpers.js for the full rationale
|
|
43
|
+
// (this replaced a text-node-only TreeWalker that missed the common
|
|
44
|
+
// "<a><img alt='...'></a>" logo-link / "<button><img alt='...'></button>"
|
|
45
|
+
// icon-button pattern).
|
|
46
|
+
if (helpers.getContentNameInfo) {
|
|
47
|
+
const info = helpers.getContentNameInfo(container, ctx);
|
|
48
|
+
return info && info.present ? info.value : '';
|
|
49
|
+
}
|
|
50
|
+
const t = (container && container.textContent) ? String(container.textContent) : '';
|
|
51
|
+
return t.replace(/\s+/g, ' ').trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getInputButtonValueName(el) {
|
|
55
|
+
try {
|
|
56
|
+
const type = normalizeWs(el.getAttribute ? el.getAttribute('type') : '').toLowerCase();
|
|
57
|
+
if (type !== 'button' && type !== 'submit' && type !== 'reset') return '';
|
|
58
|
+
const vAttr = el.getAttribute ? el.getAttribute('value') : '';
|
|
59
|
+
return normalizeWs(vAttr != null ? vAttr : (typeof el.value === 'string' ? el.value : ''));
|
|
60
|
+
} catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const selector = 'button, input[type="button"], input[type="submit"], input[type="reset"], [role="button"]';
|
|
66
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
|
|
67
|
+
|
|
68
|
+
for (const el of nodes) {
|
|
69
|
+
// isAccTreeEligible returns { eligible, reasons }, not a boolean.
|
|
70
|
+
const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
|
|
71
|
+
const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
|
|
72
|
+
if (!eligible) continue;
|
|
73
|
+
|
|
74
|
+
applicableCount += 1;
|
|
75
|
+
|
|
76
|
+
const tag = (el.tagName || '').toLowerCase();
|
|
77
|
+
const role = el.getAttribute ? el.getAttribute('role') : null;
|
|
78
|
+
|
|
79
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
80
|
+
|
|
81
|
+
// getAccessibleNameInfo only resolves programmatic mechanisms (aria-labelledby,
|
|
82
|
+
// aria-label, native <label> association, title) — it never falls back to
|
|
83
|
+
// subtree content — so it's safe to trust directly whenever present.
|
|
84
|
+
const trustedProgrammaticName =
|
|
85
|
+
normalizeWs(nameInfo && nameInfo.present && typeof nameInfo.value === 'string' ? nameInfo.value : '');
|
|
86
|
+
const explicitProg = !!trustedProgrammaticName;
|
|
87
|
+
|
|
88
|
+
let inputValueName = '';
|
|
89
|
+
if (!trustedProgrammaticName && tag === 'input') {
|
|
90
|
+
inputValueName = getInputButtonValueName(el);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const isContentNameCandidate = tag === 'button' || role === 'button';
|
|
94
|
+
const contentName =
|
|
95
|
+
(!trustedProgrammaticName && !inputValueName && isContentNameCandidate)
|
|
96
|
+
? getConservativeSubtreeText(el)
|
|
97
|
+
: '';
|
|
98
|
+
|
|
99
|
+
const finalName = normalizeWs(trustedProgrammaticName || inputValueName || contentName);
|
|
100
|
+
|
|
101
|
+
if (!finalName) {
|
|
102
|
+
// Only compute the richer eligibility-info payload (used solely for
|
|
103
|
+
// the occurrence's visibilityFilter) once we know an occurrence is
|
|
104
|
+
// actually being built, rather than for every applicable element.
|
|
105
|
+
const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
106
|
+
|
|
107
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
108
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
109
|
+
|
|
110
|
+
occurrences.push({
|
|
111
|
+
selector: stableSelector,
|
|
112
|
+
html,
|
|
113
|
+
summary: 'This button has no accessible name.',
|
|
114
|
+
hint: 'Provide visible button text or a programmatic accessible-name mechanism (for example aria-label) so assistive technologies can identify the button.',
|
|
115
|
+
i18n: {
|
|
116
|
+
summaryKey: 'buttonNamePresent_summary_fail',
|
|
117
|
+
hintKey: 'buttonNamePresent_hint_fail',
|
|
118
|
+
params: { element: tag }
|
|
119
|
+
},
|
|
120
|
+
data: {
|
|
121
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
122
|
+
details: {
|
|
123
|
+
reasonCode: 'name_missing',
|
|
124
|
+
metrics: {
|
|
125
|
+
trustedProgrammaticNameLength: trustedProgrammaticName.length,
|
|
126
|
+
inputValueNameLength: inputValueName.length,
|
|
127
|
+
contentNameLength: contentName.length,
|
|
128
|
+
explicitProgrammatic: explicitProg ? 1 : 0
|
|
129
|
+
},
|
|
130
|
+
refs: { accessibleName: nameInfo || null }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (applicableCount === 0) {
|
|
138
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
139
|
+
}
|
|
140
|
+
if (occurrences.length) {
|
|
141
|
+
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'minor', occurrences };
|
|
142
|
+
}
|
|
143
|
+
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = { id, meta, runInPage };
|