@surea11y/core 1.2.0 → 1.4.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 +81 -7
- package/LICENSE +373 -21
- package/README.md +175 -35
- package/bin/surea11y-core.js +20 -0
- package/docs/API_STABILITY.md +27 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/I18N.md +12 -9
- package/docs/INTEGRATION.md +19 -1
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +63 -18
- package/src/baseline.js +0 -0
- package/src/checks/automatic/area-alt-present.js +63 -31
- package/src/checks/automatic/aria-allowed-attr.js +204 -80
- package/src/checks/automatic/aria-allowed-role.js +23 -7
- package/src/checks/automatic/aria-braille-equivalent.js +34 -10
- package/src/checks/automatic/aria-conditional-attr.js +32 -14
- package/src/checks/automatic/aria-deprecated-role.js +26 -11
- package/src/checks/automatic/aria-hidden-body.js +48 -23
- package/src/checks/automatic/aria-hidden-focus.js +420 -66
- package/src/checks/automatic/aria-prohibited-attr.js +327 -60
- package/src/checks/automatic/aria-prohibited-children.js +111 -103
- package/src/checks/automatic/aria-required-attr.js +29 -15
- package/src/checks/automatic/aria-required-children.js +44 -24
- package/src/checks/automatic/aria-required-parent.js +64 -35
- package/src/checks/automatic/aria-role-name-present.js +49 -21
- package/src/checks/automatic/aria-roles-valid.js +24 -12
- package/src/checks/automatic/aria-valid-attr-value.js +46 -22
- package/src/checks/automatic/aria-valid-attr.js +19 -5
- package/src/checks/automatic/autocomplete-valid.js +76 -16
- package/src/checks/automatic/avoid-inline-spacing.js +23 -8
- package/src/checks/automatic/binary-control-name-present.js +62 -50
- package/src/checks/automatic/button-name-present.js +54 -24
- package/src/checks/automatic/bypass-blocks-present.js +51 -32
- package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
- package/src/checks/automatic/combobox-name-present.js +40 -45
- package/src/checks/automatic/contrast-computable.js +363 -341
- package/src/checks/automatic/contrast-enhanced.js +489 -466
- package/src/checks/automatic/contrast-minimum.js +488 -465
- package/src/checks/automatic/css-orientation-lock.js +51 -35
- package/src/checks/automatic/definition-list-children-valid.js +46 -25
- package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
- package/src/checks/automatic/dialog-name-present.js +47 -85
- package/src/checks/automatic/dlitem-parent-valid.js +25 -8
- package/src/checks/automatic/duplicate-id-aria.js +28 -9
- package/src/checks/automatic/embed-text-alternative-present.js +88 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
- package/src/checks/automatic/form-control-single-label.js +50 -14
- package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
- package/src/checks/automatic/iframe-focusable-content.js +265 -22
- package/src/checks/automatic/iframe-name-present.js +33 -9
- package/src/checks/automatic/iframe-title-unique.js +32 -9
- package/src/checks/automatic/img-alt-present.js +54 -52
- package/src/checks/automatic/input-image-alt-present.js +141 -112
- package/src/checks/automatic/label-in-name.js +65 -41
- package/src/checks/automatic/language-page-present.js +111 -109
- package/src/checks/automatic/link-in-text-block.js +61 -19
- package/src/checks/automatic/link-name-present.js +47 -14
- package/src/checks/automatic/list-children-valid.js +40 -33
- package/src/checks/automatic/listbox-name-present.js +41 -19
- package/src/checks/automatic/listitem-parent-valid.js +48 -13
- package/src/checks/automatic/menuitem-name-present.js +41 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
- package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
- package/src/checks/automatic/meter-name-present.js +40 -36
- package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
- package/src/checks/automatic/object-text-alternative-present.js +93 -39
- package/src/checks/automatic/option-name-present.js +40 -21
- package/src/checks/automatic/page-title-present.js +19 -6
- package/src/checks/automatic/progressbar-name-present.js +49 -44
- package/src/checks/automatic/role-img-alt-present.js +211 -159
- package/src/checks/automatic/searchbox-name-present.js +41 -19
- package/src/checks/automatic/server-side-image-map-absent.js +27 -11
- package/src/checks/automatic/slider-name-present.js +42 -47
- package/src/checks/automatic/spinbutton-name-present.js +41 -19
- package/src/checks/automatic/summary-name-present.js +39 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
- package/src/checks/automatic/svg-text-alternative-present.js +262 -230
- package/src/checks/automatic/tab-name-present.js +39 -60
- package/src/checks/automatic/table-headers-attr-valid.js +27 -10
- package/src/checks/automatic/table-th-has-data-cells.js +24 -8
- package/src/checks/automatic/target-size-minimum.js +123 -48
- package/src/checks/automatic/td-has-header.js +53 -12
- package/src/checks/automatic/textbox-name-present.js +41 -19
- package/src/checks/automatic/tooltip-name-present.js +39 -18
- package/src/checks/automatic/treeitem-name-present.js +40 -21
- package/src/checks/automatic/valid-lang.js +22 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
- package/src/checks/manual/accesskeys-manual.js +17 -6
- package/src/checks/manual/area-alt-decorative-manual.js +194 -193
- package/src/checks/manual/area-alt-quality-manual.js +184 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
- package/src/checks/manual/aria-text-manual.js +20 -11
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
- package/src/checks/manual/css-hidden-focus.js +375 -169
- package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
- package/src/checks/manual/empty-heading-manual.js +41 -24
- package/src/checks/manual/empty-table-header-manual.js +69 -31
- package/src/checks/manual/focus-order-semantics-manual.js +60 -13
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
- package/src/checks/manual/heading-order-manual.js +50 -8
- package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
- package/src/checks/manual/image-redundant-alt-manual.js +38 -8
- package/src/checks/manual/img-alt-decorative-manual.js +133 -96
- package/src/checks/manual/img-alt-quality-manual.js +178 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
- package/src/checks/manual/label-title-only-manual.js +44 -28
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
- package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
- package/src/checks/manual/landmark-one-main-manual.js +38 -43
- package/src/checks/manual/landmark-unique-manual.js +78 -67
- package/src/checks/manual/link-name-quality-manual.js +45 -12
- package/src/checks/manual/media-transcript-present-manual.js +37 -22
- package/src/checks/manual/meta-viewport-large-manual.js +19 -6
- package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
- package/src/checks/manual/p-as-heading-manual.js +24 -7
- package/src/checks/manual/page-has-heading-one-manual.js +42 -32
- package/src/checks/manual/page-title-patterns-manual.js +80 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
- package/src/checks/manual/region-manual.js +244 -60
- package/src/checks/manual/scope-attr-valid-manual.js +13 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
- package/src/checks/manual/skip-link-manual.js +42 -18
- package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
- package/src/checks/manual/tabindex-manual.js +13 -4
- package/src/checks/manual/table-duplicate-name-manual.js +22 -11
- package/src/checks/manual/table-fake-caption-manual.js +48 -10
- package/src/checks/manual/video-caption-manual.js +17 -4
- package/src/checks/manual-review.js +58 -12
- package/src/core.js +41705 -29650
- package/src/index.js +2 -0
- package/src/report.js +109 -47
- package/src/sarif.js +190 -0
- package/surea11y.browser.js +37774 -0
- package/bin/core.js +0 -348
- package/docs/CLI.md +0 -75
- package/src/catalogs/composites.wcag.js +0 -490
- package/src/checks/rules-and-tags.full.csv +0 -19
- package/src/checks/rules-and-tags.full.json +0 -259
- package/src/core/aria-helpers.js +0 -970
- package/src/core/contrast-helpers.js +0 -1147
- package/src/core/dom-helpers.js +0 -4235
- package/src/core/dom-runner.js +0 -671
- package/src/core/frame-messaging.js +0 -210
- package/src/core/frame-scan.js +0 -178
- package/src/core/rollup-composites.js +0 -135
- package/src/core/rule-meta.js +0 -159
- package/src/coverage/wcag-facets.js +0 -1079
- package/src/coverage/wcag-version-map.js +0 -84
- package/src/i18n/en.js +0 -923
- package/src/i18n/fr.js +0 -844
- package/src/policy/contracts.js +0 -18
- package/src/policy/resolvePolicy.js +0 -55
- package/src/policy/schemas/engine-options.schema.json +0 -103
- package/src/policy/schemas/policy-contract.schema.json +0 -40
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -11,20 +13,27 @@
|
|
|
11
13
|
* Human review is required to confirm that the provided text alternative is accurate and appropriate.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
const id =
|
|
16
|
+
const id = 'input-image-alt-quality';
|
|
15
17
|
|
|
16
18
|
const meta = {
|
|
17
|
-
title:
|
|
18
|
-
description:
|
|
19
|
+
title: '<input type="image"> alt text must be appropriate (manual review)',
|
|
20
|
+
description:
|
|
21
|
+
'Flags <input type="image"> elements with non-empty alt text for human review of appropriateness.',
|
|
19
22
|
i18n: {
|
|
20
|
-
titleKey:
|
|
21
|
-
descriptionKey:
|
|
23
|
+
titleKey: 'inputImage_altQuality_title',
|
|
24
|
+
descriptionKey: 'inputImage_altQuality_description'
|
|
22
25
|
},
|
|
23
26
|
helpUrl: null,
|
|
24
|
-
tags: [
|
|
27
|
+
tags: ['wcag2a', 'wcag111', 'forms', 'images', 'manual', 'atomic'],
|
|
25
28
|
wcagSc: ['1.1.1'],
|
|
26
29
|
normativeMappings: [
|
|
27
|
-
{
|
|
30
|
+
{
|
|
31
|
+
standard: 'WCAG',
|
|
32
|
+
version: '2.2',
|
|
33
|
+
requirement: '1.1.1',
|
|
34
|
+
title: 'Non-text Content',
|
|
35
|
+
conformanceLevel: 'A'
|
|
36
|
+
}
|
|
28
37
|
],
|
|
29
38
|
defaultSeverity: 'minor',
|
|
30
39
|
category: 'perceivable',
|
|
@@ -38,107 +47,133 @@ const meta = {
|
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
function runInPage(ctx) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const { document, root, helpers, rule } = ctx;
|
|
51
|
+
const safeRoot = root || document;
|
|
52
|
+
|
|
53
|
+
const queryAllSmart =
|
|
54
|
+
helpers && typeof helpers.queryAllSmart === 'function' ? helpers.queryAllSmart : null;
|
|
55
|
+
const queryAll =
|
|
56
|
+
helpers && typeof helpers.queryAll === 'function'
|
|
57
|
+
? helpers.queryAll
|
|
58
|
+
: (sel) => {
|
|
59
|
+
try {
|
|
60
|
+
return safeRoot && safeRoot.querySelectorAll
|
|
61
|
+
? Array.from(safeRoot.querySelectorAll(sel))
|
|
62
|
+
: [];
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
51
66
|
};
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: null;
|
|
56
|
-
|
|
57
|
-
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
58
|
-
? helpers.isAccTreeEligible
|
|
59
|
-
: null;
|
|
60
|
-
|
|
61
|
-
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function'
|
|
62
|
-
? helpers.getFocusableInfo
|
|
63
|
-
: null;
|
|
64
|
-
|
|
65
|
-
function isRolePresentationExcluded(el) {
|
|
66
|
-
const role = (() => {
|
|
67
|
-
try { return String(el.getAttribute('role') || '').trim().toLowerCase(); }
|
|
68
|
-
catch { return ''; }
|
|
69
|
-
})();
|
|
70
|
-
if (role !== 'presentation' && role !== 'none') return false;
|
|
71
|
-
|
|
72
|
-
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
73
|
-
let focusable = false;
|
|
74
|
-
if (getFocusableInfo) {
|
|
75
|
-
const fi = (() => { try { return getFocusableInfo(el, ctx); } catch { return null; } })();
|
|
76
|
-
focusable = !!(fi && fi.focusable);
|
|
77
|
-
} else {
|
|
78
|
-
const tabindex = el.getAttribute('tabindex');
|
|
79
|
-
focusable = tabindex != null && String(tabindex).trim() !== '' && !Number.isNaN(Number(String(tabindex).trim()));
|
|
80
|
-
}
|
|
81
|
-
return !focusable;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const els = (() => {
|
|
86
|
-
try { return Array.from((queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) || []); }
|
|
87
|
-
catch { return queryAll('input[type="image"]'); }
|
|
88
|
-
})();
|
|
89
|
-
|
|
90
|
-
if (!els.length) {
|
|
91
|
-
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
92
|
-
}
|
|
68
|
+
const getEligibilityInfo =
|
|
69
|
+
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
93
70
|
|
|
94
|
-
|
|
95
|
-
|
|
71
|
+
const isAccTreeEligible =
|
|
72
|
+
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
96
73
|
|
|
97
|
-
|
|
98
|
-
|
|
74
|
+
const getFocusableInfo =
|
|
75
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
99
76
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
77
|
+
function isRolePresentationExcluded(el) {
|
|
78
|
+
const role = (() => {
|
|
79
|
+
try {
|
|
80
|
+
return String(el.getAttribute('role') || '')
|
|
81
|
+
.trim()
|
|
82
|
+
.toLowerCase();
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
if (role !== 'presentation' && role !== 'none') return false;
|
|
88
|
+
|
|
89
|
+
// Exclude only when NOT focusable (mirrors img-alt-present policy)
|
|
90
|
+
let focusable;
|
|
91
|
+
if (getFocusableInfo) {
|
|
92
|
+
const fi = (() => {
|
|
93
|
+
try {
|
|
94
|
+
return getFocusableInfo(el, ctx);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
105
97
|
}
|
|
98
|
+
})();
|
|
99
|
+
focusable = !!(fi && fi.focusable);
|
|
100
|
+
} else {
|
|
101
|
+
const tabindex = el.getAttribute('tabindex');
|
|
102
|
+
focusable =
|
|
103
|
+
tabindex != null &&
|
|
104
|
+
String(tabindex).trim() !== '' &&
|
|
105
|
+
!Number.isNaN(Number(String(tabindex).trim()));
|
|
106
|
+
}
|
|
107
|
+
return !focusable;
|
|
108
|
+
}
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
const els = (() => {
|
|
111
|
+
try {
|
|
112
|
+
return Array.from(
|
|
113
|
+
(queryAllSmart ? queryAllSmart('input[type="image"]') : queryAll('input[type="image"]')) ||
|
|
114
|
+
[]
|
|
115
|
+
);
|
|
116
|
+
} catch {
|
|
117
|
+
return queryAll('input[type="image"]');
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
111
120
|
|
|
112
|
-
|
|
121
|
+
if (!els.length) {
|
|
122
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
123
|
+
}
|
|
113
124
|
|
|
114
|
-
|
|
125
|
+
const occurrences = [];
|
|
126
|
+
let applicableCount = 0;
|
|
115
127
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
|
|
119
|
-
i18n: {
|
|
120
|
-
summaryKey: 'inputImage_altQuality_summary_cantTell',
|
|
121
|
-
hintKey: 'inputImage_altQuality_hint_cantTell',
|
|
122
|
-
params: { element: 'input[type=image]' }
|
|
123
|
-
},
|
|
124
|
-
data: {
|
|
125
|
-
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
126
|
-
details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
|
|
127
|
-
}
|
|
128
|
-
};
|
|
128
|
+
for (const el of els) {
|
|
129
|
+
if (!el || !el.getAttribute) continue;
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
if (isAccTreeEligible) {
|
|
132
|
+
const elig = (() => {
|
|
133
|
+
try {
|
|
134
|
+
return isAccTreeEligible(el, ctx);
|
|
135
|
+
} catch {
|
|
136
|
+
return { eligible: true, reasons: [] };
|
|
134
137
|
}
|
|
138
|
+
})();
|
|
139
|
+
if (elig && elig.eligible === false) continue;
|
|
135
140
|
}
|
|
136
141
|
|
|
137
|
-
if (
|
|
138
|
-
|
|
142
|
+
if (isRolePresentationExcluded(el)) continue;
|
|
143
|
+
|
|
144
|
+
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
145
|
+
if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() !== '')) continue;
|
|
146
|
+
|
|
147
|
+
applicableCount += 1;
|
|
148
|
+
|
|
149
|
+
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
150
|
+
|
|
151
|
+
const baseOccurrence = {
|
|
152
|
+
summary: 'Review alt text on <input type="image"> for accuracy and appropriateness.',
|
|
153
|
+
hint: 'Ensure the alt text describes the control’s action (e.g., “Search”, “Submit order”) in context.',
|
|
154
|
+
i18n: {
|
|
155
|
+
summaryKey: 'inputImage_altQuality_summary_cantTell',
|
|
156
|
+
hintKey: 'inputImage_altQuality_hint_cantTell',
|
|
157
|
+
params: { element: 'input[type=image]' }
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
|
+
details: { alt: String(el.getAttribute('alt') || '') } // optional but useful
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
166
|
+
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
167
|
+
} else {
|
|
168
|
+
occurrences.push({ selector: '', html: '', ...baseOccurrence });
|
|
139
169
|
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (applicableCount === 0) {
|
|
173
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
174
|
+
}
|
|
140
175
|
|
|
141
|
-
|
|
176
|
+
return { ruleId: rule.ruleId, outcome: 'cantTell', severity: 'minor', occurrences };
|
|
142
177
|
}
|
|
143
178
|
|
|
144
179
|
module.exports = { id, meta, runInPage };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check label-title-only
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary Form controls should not rely on the title attribute as their only label
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
11
|
* Applies to labelable form controls (input, excluding
|
|
10
12
|
* hidden/submit/reset/button/image; select; textarea) that have a
|
|
@@ -28,7 +30,8 @@ const id = 'label-title-only';
|
|
|
28
30
|
|
|
29
31
|
const meta = {
|
|
30
32
|
title: 'Form controls should not use title as their only label',
|
|
31
|
-
description:
|
|
33
|
+
description:
|
|
34
|
+
'Checks that a form control with a title attribute also has a real label (label element, aria-label, or aria-labelledby).',
|
|
32
35
|
i18n: {
|
|
33
36
|
titleKey: 'labelTitleOnly_title',
|
|
34
37
|
descriptionKey: 'labelTitleOnly_description'
|
|
@@ -45,20 +48,13 @@ const meta = {
|
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
function runInPage(ctx) {
|
|
48
|
-
const {
|
|
49
|
-
|
|
50
|
-
const selector =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
for (const lab of allLabels) {
|
|
56
|
-
if (!lab || !lab.getAttribute) continue;
|
|
57
|
-
const forValue = String(lab.getAttribute('for') || '').trim();
|
|
58
|
-
if (!forValue) continue;
|
|
59
|
-
if (!labelsByFor.has(forValue)) labelsByFor.set(forValue, []);
|
|
60
|
-
labelsByFor.get(forValue).push(lab);
|
|
61
|
-
}
|
|
51
|
+
const { helpers, rule } = ctx;
|
|
52
|
+
|
|
53
|
+
const selector =
|
|
54
|
+
'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
55
|
+
const nodes = helpers.queryAllSmart
|
|
56
|
+
? helpers.queryAllSmart(selector)
|
|
57
|
+
: helpers.queryAll(selector);
|
|
62
58
|
|
|
63
59
|
const occurrences = [];
|
|
64
60
|
let applicableCount = 0;
|
|
@@ -66,25 +62,40 @@ function runInPage(ctx) {
|
|
|
66
62
|
for (const el of nodes) {
|
|
67
63
|
if (!el || !el.getAttribute) continue;
|
|
68
64
|
|
|
65
|
+
if (helpers.isAccTreeEligible) {
|
|
66
|
+
const elig = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
69
|
+
} catch {
|
|
70
|
+
return { eligible: true, reasons: [] };
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
if (elig && elig.eligible === false) continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
69
76
|
const title = String(el.getAttribute('title') || '').trim();
|
|
70
77
|
if (!title) continue;
|
|
71
78
|
|
|
72
79
|
applicableCount += 1;
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
// Delegates to the shared helpers.getAccessibleNameInfo (aria ->
|
|
82
|
+
// native <label> -> title, the same precedence every other
|
|
83
|
+
// name-dependent rule in this engine uses) rather than a local,
|
|
84
|
+
// hand-rolled "does a <label for>/wrapping <label> exist" check. A
|
|
85
|
+
// structural-association-only check (for="..."/wrapping) never verifies
|
|
86
|
+
// the label actually contributes a name -- an empty <label for="x">
|
|
87
|
+
// </label> or empty wrapping <label> would exempt the control even
|
|
88
|
+
// though title is functionally its only real label (see
|
|
89
|
+
// dom-helpers.js's hasLabelAssociation/labelContributesAccessibleName).
|
|
90
|
+
// If the resolved mechanism isn't 'title', some higher-priority
|
|
91
|
+
// mechanism (aria-label/aria-labelledby/a real contributing label)
|
|
92
|
+
// already won and this control isn't title-only.
|
|
93
|
+
const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
|
|
94
|
+
if (!nameInfo || nameInfo.mechanism !== 'title') continue;
|
|
84
95
|
|
|
85
96
|
const tag = el.tagName.toLowerCase();
|
|
86
97
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
87
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
98
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
88
99
|
|
|
89
100
|
occurrences.push({
|
|
90
101
|
selector: stableSelector,
|
|
@@ -106,7 +117,12 @@ function runInPage(ctx) {
|
|
|
106
117
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
107
118
|
}
|
|
108
119
|
if (occurrences.length) {
|
|
109
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
ruleId: rule.ruleId,
|
|
122
|
+
outcome: 'cantTell',
|
|
123
|
+
severity: rule.defaultSeverity || 'minor',
|
|
124
|
+
occurrences
|
|
125
|
+
};
|
|
110
126
|
}
|
|
111
127
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
112
128
|
}
|
|
@@ -1,24 +1,25 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check landmark-banner-is-top-level
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary The banner landmark must not be nested inside another landmark
|
|
7
|
-
* @standard Best Practices (
|
|
9
|
+
* @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
10
|
* @applicability
|
|
9
|
-
* Applies whenever the page contains at least one banner
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Applies whenever the page contains at least one banner candidate:
|
|
12
|
+
* explicit role="banner", OR a <header> with NO role attribute at all,
|
|
13
|
+
* regardless of nesting (see implementation notes on why candidate
|
|
14
|
+
* selection is deliberately unconditional).
|
|
13
15
|
* @expectation
|
|
14
|
-
* No banner
|
|
16
|
+
* No banner candidate has an ancestor that is itself any landmark
|
|
15
17
|
* region. A banner nested inside another landmark is not a top-level,
|
|
16
18
|
* whole-page banner and confuses landmark-based navigation for
|
|
17
19
|
* assistive technology users.
|
|
18
20
|
* @implementation-notes
|
|
19
|
-
* - Not WCAG-normative
|
|
20
|
-
*
|
|
21
|
-
* rule per ROADMAP.md Tier 1b and the design doc's policy model
|
|
21
|
+
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
22
|
+
* `type: 'manual'` rule per ROADMAP.md Tier 1b and the design doc's policy model
|
|
22
23
|
* ("Advisory / best-practice rules may exist, but must not produce
|
|
23
24
|
* `fail`"). Matches the existing `page-title-patterns-manual.js`
|
|
24
25
|
* precedent: deterministic DOM analysis, no human required, but
|
|
@@ -26,16 +27,28 @@
|
|
|
26
27
|
* - Landmark detection here models WAI-ARIA APG landmark roles and the
|
|
27
28
|
* HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
|
|
28
29
|
* main→main, nav→navigation, aside→complementary, section/form→
|
|
29
|
-
* region/form only when accessibly named)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* region/form only when accessibly named).
|
|
31
|
+
* - Candidate selection (`isBannerCandidate` below) is deliberately
|
|
32
|
+
* unconditional — a `<header>`/`role="banner"` counts as a candidate
|
|
33
|
+
* regardless of nesting — rather than reusing the same HTML-AAM
|
|
34
|
+
* sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
|
|
35
|
+
* `hasSectioningAncestor` gate) that the violation check itself relies
|
|
36
|
+
* on. Gating candidate selection on that suppression would be
|
|
37
|
+
* self-defeating: the moment a `<header>` is nested inside another
|
|
38
|
+
* landmark, that same nesting would make it stop counting as a banner
|
|
39
|
+
* candidate in the first place, so the rule could never flag the one
|
|
40
|
+
* case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
|
|
41
|
+
* asymmetric: it still uses the full suppression-aware
|
|
42
|
+
* `getLandmarkRole` for each ancestor, since an ancestor genuinely
|
|
43
|
+
* needs its own real role to count as blocking.
|
|
32
44
|
*/
|
|
33
45
|
|
|
34
46
|
const id = 'landmark-banner-is-top-level';
|
|
35
47
|
|
|
36
48
|
const meta = {
|
|
37
49
|
title: 'Banner landmark must be top-level',
|
|
38
|
-
description:
|
|
50
|
+
description:
|
|
51
|
+
'Checks that the banner landmark (role="banner" or a non-nested <header>) is not nested inside another landmark region.',
|
|
39
52
|
i18n: {
|
|
40
53
|
titleKey: 'landmarkBannerIsTopLevel_title',
|
|
41
54
|
descriptionKey: 'landmarkBannerIsTopLevel_description'
|
|
@@ -57,14 +70,15 @@ function runInPage(ctx) {
|
|
|
57
70
|
// Declared inside runInPage — see scripts/build-core.js header
|
|
58
71
|
// ("runInPage MUST be self-contained").
|
|
59
72
|
function normalizeWs(s) {
|
|
60
|
-
return String(s || '')
|
|
73
|
+
return String(s || '')
|
|
74
|
+
.replace(/\s+/g, ' ')
|
|
75
|
+
.trim();
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
// Delegates to the shared helpers.getLandmarkNameInfo (aria-label -> aria-labelledby, via the
|
|
64
79
|
// target's own accessible name, not raw textContent -> title attribute fallback) rather than a
|
|
65
|
-
// local copy -- see that function's header comment in src/core/dom-helpers.js
|
|
66
|
-
//
|
|
67
|
-
// copy of this logic.
|
|
80
|
+
// local copy -- see that function's header comment in src/core/dom-helpers.js. Sharing it keeps
|
|
81
|
+
// the title-attribute fallback consistent across the landmark rules.
|
|
68
82
|
function getAccessibleLandmarkName(el) {
|
|
69
83
|
try {
|
|
70
84
|
if (helpers && typeof helpers.getLandmarkNameInfo === 'function') {
|
|
@@ -87,10 +101,8 @@ function runInPage(ctx) {
|
|
|
87
101
|
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
88
102
|
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
89
103
|
// rather than a local tag-only copy. See that function's header comment
|
|
90
|
-
// in src/core/aria-helpers.js for the full algorithm
|
|
91
|
-
//
|
|
92
|
-
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
93
|
-
// rule's own former tag-only copy as a false negative.
|
|
104
|
+
// in src/core/aria-helpers.js for the full algorithm. Example: an
|
|
105
|
+
// <aside role="dialog"> containing its own <header>.
|
|
94
106
|
function hasSectioningAncestor(el, includeMain) {
|
|
95
107
|
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
96
108
|
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
@@ -104,11 +116,9 @@ function runInPage(ctx) {
|
|
|
104
116
|
if (tag === 'main') return 'main';
|
|
105
117
|
if (tag === 'nav') return 'navigation';
|
|
106
118
|
if (tag === 'aside') {
|
|
107
|
-
// A named <aside> is never suppressed, even when nested —
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// "complementary" when the element has an accessible name, even
|
|
111
|
-
// inside sectioning content); propagated here for consistency.
|
|
119
|
+
// A named <aside> is never suppressed, even when nested — it keeps
|
|
120
|
+
// "complementary" when it has an accessible name, even inside
|
|
121
|
+
// sectioning content. Matches landmark-unique's precedent.
|
|
112
122
|
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
113
123
|
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
114
124
|
}
|
|
@@ -117,7 +127,16 @@ function runInPage(ctx) {
|
|
|
117
127
|
return '';
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
const LANDMARK_ROLES = new Set([
|
|
130
|
+
const LANDMARK_ROLES = new Set([
|
|
131
|
+
'banner',
|
|
132
|
+
'contentinfo',
|
|
133
|
+
'main',
|
|
134
|
+
'navigation',
|
|
135
|
+
'complementary',
|
|
136
|
+
'region',
|
|
137
|
+
'form',
|
|
138
|
+
'search'
|
|
139
|
+
]);
|
|
121
140
|
|
|
122
141
|
function getLandmarkRole(el) {
|
|
123
142
|
if (!el || !el.getAttribute) return '';
|
|
@@ -126,8 +145,20 @@ function runInPage(ctx) {
|
|
|
126
145
|
return getImplicitLandmarkRole(el);
|
|
127
146
|
}
|
|
128
147
|
|
|
148
|
+
// Candidate selection is deliberately NOT the same as getLandmarkRole()
|
|
149
|
+
// === 'banner' — see the fix note above. A <header> is a
|
|
150
|
+
// candidate purely by tag + absence of any role attribute, independent
|
|
151
|
+
// of whether sectioning-ancestor nesting would currently suppress its
|
|
152
|
+
// implicit role; an explicit role="banner" is always a candidate too.
|
|
153
|
+
function isBannerCandidate(el) {
|
|
154
|
+
if (!el || !el.getAttribute) return false;
|
|
155
|
+
const explicit = getExplicitRoleToken(el);
|
|
156
|
+
if (explicit) return explicit === 'banner';
|
|
157
|
+
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
158
|
+
}
|
|
159
|
+
|
|
129
160
|
function hasLandmarkAncestor(el) {
|
|
130
|
-
const scopeRoots = Array.isArray(root) ? root :
|
|
161
|
+
const scopeRoots = Array.isArray(root) ? root : root ? [root] : [];
|
|
131
162
|
let p = el.parentElement;
|
|
132
163
|
while (p) {
|
|
133
164
|
if (getLandmarkRole(p)) return true;
|
|
@@ -140,14 +171,14 @@ function runInPage(ctx) {
|
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
143
|
-
// landmark-unique-manual.js's header comment
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
let nodes = [];
|
|
174
|
+
// landmark-unique-manual.js's header comment. A third-party shadow-DOM-hosted
|
|
175
|
+
// widget's own landmark is invisible to a light-DOM-only query.
|
|
176
|
+
let nodes;
|
|
147
177
|
try {
|
|
148
|
-
nodes =
|
|
149
|
-
|
|
150
|
-
|
|
178
|
+
nodes =
|
|
179
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
180
|
+
? helpers.queryAllSmart('header, footer, main, nav, aside, section, form, [role]')
|
|
181
|
+
: document.querySelectorAll('header, footer, main, nav, aside, section, form, [role]');
|
|
151
182
|
} catch {
|
|
152
183
|
nodes = [];
|
|
153
184
|
}
|
|
@@ -157,7 +188,28 @@ function runInPage(ctx) {
|
|
|
157
188
|
for (const el of nodes) {
|
|
158
189
|
if (!el || seen.has(el)) continue;
|
|
159
190
|
seen.add(el);
|
|
160
|
-
if (
|
|
191
|
+
if (!isBannerCandidate(el)) continue;
|
|
192
|
+
|
|
193
|
+
// An aria-hidden banner candidate is removed from the accessibility
|
|
194
|
+
// tree entirely -- it isn't part of the landmark structure assistive
|
|
195
|
+
// technology users navigate at all, so it shouldn't be flagged as
|
|
196
|
+
// "nested inside another landmark" (there's no real landmark there to
|
|
197
|
+
// begin with, from AT's perspective). queryAllSmart's default hidden-
|
|
198
|
+
// content policy only excludes "hard" CSS-based hiding (display:none,
|
|
199
|
+
// etc.), not the softer aria-hidden exclusion, so this needs its own
|
|
200
|
+
// check.
|
|
201
|
+
if (helpers && typeof helpers.isAccTreeEligible === 'function') {
|
|
202
|
+
const elig = (() => {
|
|
203
|
+
try {
|
|
204
|
+
return helpers.isAccTreeEligible(el, ctx);
|
|
205
|
+
} catch {
|
|
206
|
+
return { eligible: true, reasons: [] };
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
if (elig && elig.eligible === false) continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
banners.push(el);
|
|
161
213
|
}
|
|
162
214
|
|
|
163
215
|
if (banners.length === 0) {
|
|
@@ -169,7 +221,7 @@ function runInPage(ctx) {
|
|
|
169
221
|
if (!hasLandmarkAncestor(el)) continue;
|
|
170
222
|
|
|
171
223
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
172
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
224
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
173
225
|
|
|
174
226
|
occurrences.push({
|
|
175
227
|
selector: stableSelector,
|
|
@@ -188,7 +240,12 @@ function runInPage(ctx) {
|
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
if (occurrences.length) {
|
|
191
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
ruleId: rule.ruleId,
|
|
245
|
+
outcome: 'cantTell',
|
|
246
|
+
severity: rule.defaultSeverity || 'minor',
|
|
247
|
+
occurrences
|
|
248
|
+
};
|
|
192
249
|
}
|
|
193
250
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
194
251
|
}
|