@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
|
const id = 'page-title-patterns';
|
|
@@ -15,7 +17,13 @@ const meta = {
|
|
|
15
17
|
tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
|
|
16
18
|
wcagSc: ['2.4.2'],
|
|
17
19
|
normativeMappings: [
|
|
18
|
-
{
|
|
20
|
+
{
|
|
21
|
+
standard: 'WCAG',
|
|
22
|
+
version: '2.2',
|
|
23
|
+
requirement: '2.4.2',
|
|
24
|
+
title: 'Page Titled',
|
|
25
|
+
conformanceLevel: 'A'
|
|
26
|
+
}
|
|
19
27
|
],
|
|
20
28
|
defaultSeverity: 'minor',
|
|
21
29
|
category: 'operable',
|
|
@@ -34,11 +42,13 @@ function applicability(ctx) {
|
|
|
34
42
|
|
|
35
43
|
function runInPage(ctx) {
|
|
36
44
|
const { document, helpers, rule } = ctx;
|
|
37
|
-
const probes =
|
|
45
|
+
const probes =
|
|
46
|
+
ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
|
|
38
47
|
? ctx.inputs.probes
|
|
39
48
|
: null;
|
|
40
49
|
|
|
41
|
-
const pageTitlesProbe =
|
|
50
|
+
const pageTitlesProbe =
|
|
51
|
+
probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
|
|
42
52
|
? probes['crawl.pageTitles']
|
|
43
53
|
: null;
|
|
44
54
|
|
|
@@ -53,16 +63,16 @@ function runInPage(ctx) {
|
|
|
53
63
|
// Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
|
|
54
64
|
// =========================
|
|
55
65
|
if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
|
|
56
|
-
const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
|
|
66
|
+
const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
|
|
57
67
|
|
|
58
68
|
// Require enough data to avoid noisy conclusions
|
|
59
69
|
const MIN_PAGES = 10;
|
|
60
70
|
const analyzable = pages
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
.map((p) => ({
|
|
72
|
+
url: p.url ? String(p.url) : null,
|
|
73
|
+
title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
|
|
74
|
+
}))
|
|
75
|
+
.filter((p) => p.url && p.title);
|
|
66
76
|
|
|
67
77
|
if (analyzable.length >= MIN_PAGES) {
|
|
68
78
|
// Build normalized title groups (case-insensitive)
|
|
@@ -74,11 +84,11 @@ function runInPage(ctx) {
|
|
|
74
84
|
}
|
|
75
85
|
|
|
76
86
|
// Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
|
|
77
|
-
const dupGroups = Array.from(groups.values()).filter(g => g.urls.length >= 2);
|
|
87
|
+
const dupGroups = Array.from(groups.values()).filter((g) => g.urls.length >= 2);
|
|
78
88
|
|
|
79
89
|
// Boilerplate-ish: detect a long common suffix/prefix across most titles.
|
|
80
90
|
// Keep conservative: only flag if the common part is long and shared by many.
|
|
81
|
-
const titles = analyzable.map(p => p.title);
|
|
91
|
+
const titles = analyzable.map((p) => p.title);
|
|
82
92
|
function commonPrefix(a, b) {
|
|
83
93
|
const n = Math.min(a.length, b.length);
|
|
84
94
|
let i = 0;
|
|
@@ -103,35 +113,41 @@ function runInPage(ctx) {
|
|
|
103
113
|
const suffixLen = sharedSuffix.trim().length;
|
|
104
114
|
|
|
105
115
|
const hasStrongTemplateSignal =
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
(prefixLen >= 12 || suffixLen >= 12) &&
|
|
117
|
+
(prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
|
|
118
|
+
(suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
|
|
119
|
+
12;
|
|
108
120
|
|
|
109
121
|
// If any cross-page signal exists, emit cantTell occurrence(s)
|
|
110
122
|
if (dupGroups.length || hasStrongTemplateSignal) {
|
|
111
123
|
const reasonCode = dupGroups.length
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
? 'duplicateTitlesAcrossPages'
|
|
125
|
+
: 'templatedTitlesAcrossPages';
|
|
114
126
|
|
|
115
127
|
// Deterministic example title for i18n params (lexicographic, case-insensitive)
|
|
116
128
|
const exampleTitle = dupGroups.length
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
129
|
+
? dupGroups
|
|
130
|
+
.map((g) =>
|
|
131
|
+
String(g.title || '')
|
|
132
|
+
.replace(/\s+/g, ' ')
|
|
133
|
+
.trim()
|
|
134
|
+
)
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
|
|
137
|
+
: '';
|
|
122
138
|
|
|
123
139
|
const summaryKey = dupGroups.length
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
|
|
141
|
+
: 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
|
|
126
142
|
|
|
127
143
|
const i18nParams = dupGroups.length
|
|
128
|
-
|
|
144
|
+
? {
|
|
129
145
|
reasonCode,
|
|
130
146
|
pagesAnalyzed: analyzable.length,
|
|
131
147
|
duplicateGroups: dupGroups.length,
|
|
132
148
|
exampleTitle
|
|
133
149
|
}
|
|
134
|
-
|
|
150
|
+
: {
|
|
135
151
|
reasonCode,
|
|
136
152
|
pagesAnalyzed: analyzable.length
|
|
137
153
|
};
|
|
@@ -139,7 +155,8 @@ function runInPage(ctx) {
|
|
|
139
155
|
const occBase = {
|
|
140
156
|
selector: 'head > title',
|
|
141
157
|
html: '',
|
|
142
|
-
summary:
|
|
158
|
+
summary:
|
|
159
|
+
'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
143
160
|
hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
|
|
144
161
|
i18n: {
|
|
145
162
|
summaryKey,
|
|
@@ -152,12 +169,14 @@ function runInPage(ctx) {
|
|
|
152
169
|
metrics: {
|
|
153
170
|
pagesAnalyzed: analyzable.length,
|
|
154
171
|
duplicateGroups: dupGroups.length,
|
|
155
|
-
largestDuplicateGroupSize: dupGroups.length
|
|
172
|
+
largestDuplicateGroupSize: dupGroups.length
|
|
173
|
+
? Math.max(...dupGroups.map((g) => g.urls.length))
|
|
174
|
+
: 0,
|
|
156
175
|
sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
|
|
157
176
|
sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
|
|
158
177
|
},
|
|
159
178
|
refs: {
|
|
160
|
-
exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
|
|
179
|
+
exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
|
|
161
180
|
title: g.title,
|
|
162
181
|
urls: g.urls.slice(0, 5)
|
|
163
182
|
}))
|
|
@@ -173,12 +192,20 @@ function runInPage(ctx) {
|
|
|
173
192
|
// No node available: keep deterministic fallback snippet (no helper calls).
|
|
174
193
|
occurrences.push({
|
|
175
194
|
...occBase,
|
|
176
|
-
html:
|
|
195
|
+
html:
|
|
196
|
+
titleEl && titleEl.outerHTML
|
|
197
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
198
|
+
: '<title>(unknown)</title>'
|
|
177
199
|
});
|
|
178
200
|
}
|
|
179
201
|
|
|
180
202
|
// Cross-page analysis is authoritative when present; do not also run single-page heuristics.
|
|
181
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
ruleId: rule.ruleId,
|
|
205
|
+
outcome: 'cantTell',
|
|
206
|
+
severity: rule.defaultSeverity || 'minor',
|
|
207
|
+
occurrences
|
|
208
|
+
};
|
|
182
209
|
}
|
|
183
210
|
|
|
184
211
|
// If we had enough pages and found no review signal, there is nothing
|
|
@@ -194,14 +221,7 @@ function runInPage(ctx) {
|
|
|
194
221
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
195
222
|
}
|
|
196
223
|
|
|
197
|
-
const GENERIC_TITLES = new Set([
|
|
198
|
-
'home',
|
|
199
|
-
'homepage',
|
|
200
|
-
'welcome',
|
|
201
|
-
'untitled',
|
|
202
|
-
'page',
|
|
203
|
-
'document'
|
|
204
|
-
]);
|
|
224
|
+
const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
|
|
205
225
|
|
|
206
226
|
// Conservative signals:
|
|
207
227
|
// - very short title (likely non-descriptive)
|
|
@@ -210,27 +230,29 @@ function runInPage(ctx) {
|
|
|
210
230
|
const isGeneric = GENERIC_TITLES.has(titleLc);
|
|
211
231
|
|
|
212
232
|
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
213
|
-
const templateLike =
|
|
214
|
-
|
|
233
|
+
const templateLike =
|
|
234
|
+
/\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
|
|
235
|
+
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
|
|
215
236
|
|
|
216
237
|
if (isGeneric || isVeryShort || templateLike) {
|
|
217
238
|
const reasonCode = isGeneric
|
|
218
239
|
? 'genericTitle'
|
|
219
|
-
:
|
|
240
|
+
: isVeryShort
|
|
241
|
+
? 'veryShortTitle'
|
|
242
|
+
: 'templateLikeTitle';
|
|
220
243
|
|
|
221
244
|
const summaryKey =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
245
|
+
reasonCode === 'genericTitle'
|
|
246
|
+
? 'pageTitlePatterns_summary_cantTell_generic'
|
|
247
|
+
: reasonCode === 'veryShortTitle'
|
|
248
|
+
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
249
|
+
: 'pageTitlePatterns_summary_cantTell_templateLike';
|
|
227
250
|
const occBase = {
|
|
228
251
|
selector: 'head > title',
|
|
229
252
|
html: '',
|
|
230
253
|
summary:
|
|
231
|
-
|
|
232
|
-
hint:
|
|
233
|
-
'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
|
|
254
|
+
'The page title may not be descriptive enough to identify the page topic or purpose.',
|
|
255
|
+
hint: 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
|
|
234
256
|
i18n: {
|
|
235
257
|
summaryKey,
|
|
236
258
|
hintKey: 'pageTitlePatterns_hint_cantTell',
|
|
@@ -250,7 +272,10 @@ function runInPage(ctx) {
|
|
|
250
272
|
} else {
|
|
251
273
|
occurrences.push({
|
|
252
274
|
...occBase,
|
|
253
|
-
html:
|
|
275
|
+
html:
|
|
276
|
+
titleEl && titleEl.outerHTML
|
|
277
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
278
|
+
: '<title>(unknown)</title>'
|
|
254
279
|
});
|
|
255
280
|
}
|
|
256
281
|
}
|
|
@@ -261,7 +286,12 @@ function runInPage(ctx) {
|
|
|
261
286
|
|
|
262
287
|
if (occurrences.length) {
|
|
263
288
|
// Patterns are review signals: cantTell rather than fail.
|
|
264
|
-
return {
|
|
289
|
+
return {
|
|
290
|
+
ruleId: rule.ruleId,
|
|
291
|
+
outcome: 'cantTell',
|
|
292
|
+
severity: rule.defaultSeverity || 'minor',
|
|
293
|
+
occurrences
|
|
294
|
+
};
|
|
265
295
|
}
|
|
266
296
|
|
|
267
297
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
/* SPDX-License-Identifier: MPL-2.0 */
|
|
2
|
+
|
|
1
3
|
'use strict';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* @check presentation-role-conflict
|
|
5
7
|
* @atomic true
|
|
6
8
|
* @summary role="presentation"/"none" must not be combined with a global ARIA naming attribute or focusability
|
|
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 elements with an explicit role="presentation" or
|
|
10
12
|
* role="none", OR an <img alt=""> (empty alt gives an <img> an implicit
|
|
11
13
|
* presentation role per HTML-AAM, even with no explicit role attribute
|
|
12
|
-
* at all —
|
|
13
|
-
* for this exact check, `img[alt=''], [role="none"], [role="presentation"]`).
|
|
14
|
+
* at all — `img[alt=''], [role="none"], [role="presentation"]`).
|
|
14
15
|
* @expectation
|
|
15
16
|
* The element does not also carry a WAI-ARIA *global* state/property
|
|
16
17
|
* (aria-label, aria-hidden, aria-describedby, aria-live, aria-current,
|
|
@@ -25,28 +26,27 @@
|
|
|
25
26
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
26
27
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
27
28
|
* header comment for the shared rationale/precedent.
|
|
28
|
-
* -
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* here rather than narrower, which is the safer direction to diverge in.
|
|
29
|
+
* - `aria-hidden="true"` (the exact valid truthy value) on the
|
|
30
|
+
* presentational element itself is deliberately EXCLUDED as a trigger,
|
|
31
|
+
* even though it's a global ARIA attribute: it removes the element from
|
|
32
|
+
* the accessibility tree unconditionally, so the "role restoration"
|
|
33
|
+
* this rule warns about never actually reaches assistive tech, making a
|
|
34
|
+
* flag misleading. Any OTHER conflicting attribute present alongside
|
|
35
|
+
* `aria-hidden="true"` is equally inert for the same reason and is not
|
|
36
|
+
* flagged either. An `aria-hidden=""` (empty/invalid value, does NOT
|
|
37
|
+
* hide) still triggers normally. Focusability is unaffected by this
|
|
38
|
+
* exemption (see the code comment at the check site).
|
|
39
|
+
* - The conflicting-attribute set is the full list of ARIA attributes
|
|
40
|
+
* marked `global: true`, not a narrower naming-only list.
|
|
41
|
+
* - Deliberately NOT applying an implicit-role applicability gate that
|
|
42
|
+
* would make the check inapplicable to role="presentation" on elements
|
|
43
|
+
* with no native implicit role to suppress (e.g. `<div
|
|
44
|
+
* role="presentation" aria-hidden="true">` — a <div> has no native role,
|
|
45
|
+
* so there's nothing for the presentational role to "conflict" with).
|
|
46
|
+
* surea11y stays broader/more cautious here rather than narrower, which
|
|
47
|
+
* is the safer direction to diverge in. The native-implicit-role table
|
|
48
|
+
* needed to add that gate, if this scope decision is ever revisited, is
|
|
49
|
+
* in ROADMAP.md §7 item 9.
|
|
50
50
|
* - Focusability is computed via helpers.getFocusableInfo (native +
|
|
51
51
|
* tabindex), same helper aria-hidden-focus already relies on — a
|
|
52
52
|
* `:disabled` or otherwise non-focusable element is not flagged.
|
|
@@ -56,7 +56,8 @@ const id = 'presentation-role-conflict';
|
|
|
56
56
|
|
|
57
57
|
const meta = {
|
|
58
58
|
title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
|
|
59
|
-
description:
|
|
59
|
+
description:
|
|
60
|
+
'Checks that role="presentation"/"none" (including an <img alt=""> implicit presentation role) is not combined with a global ARIA attribute (aria-label, aria-hidden, aria-describedby, ...) or focusability (tabindex/native).',
|
|
60
61
|
i18n: {
|
|
61
62
|
titleKey: 'presentationRoleConflict_title',
|
|
62
63
|
descriptionKey: 'presentationRoleConflict_description'
|
|
@@ -73,25 +74,44 @@ const meta = {
|
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
function runInPage(ctx) {
|
|
76
|
-
const {
|
|
77
|
+
const { helpers, rule } = ctx;
|
|
77
78
|
|
|
78
79
|
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
79
|
-
// spec
|
|
80
|
-
//
|
|
81
|
-
// element restores its implicit role, not just the naming ones.
|
|
80
|
+
// spec — any of these present on a presentational element restores its
|
|
81
|
+
// implicit role, not just the naming ones.
|
|
82
82
|
const CONFLICTING_ATTRS = [
|
|
83
|
-
'aria-atomic',
|
|
84
|
-
'aria-
|
|
85
|
-
'aria-
|
|
86
|
-
'aria-
|
|
87
|
-
'aria-
|
|
83
|
+
'aria-atomic',
|
|
84
|
+
'aria-braillelabel',
|
|
85
|
+
'aria-brailleroledescription',
|
|
86
|
+
'aria-busy',
|
|
87
|
+
'aria-controls',
|
|
88
|
+
'aria-current',
|
|
89
|
+
'aria-describedby',
|
|
90
|
+
'aria-description',
|
|
91
|
+
'aria-details',
|
|
92
|
+
'aria-disabled',
|
|
93
|
+
'aria-dropeffect',
|
|
94
|
+
'aria-errormessage',
|
|
95
|
+
'aria-flowto',
|
|
96
|
+
'aria-grabbed',
|
|
97
|
+
'aria-haspopup',
|
|
98
|
+
'aria-hidden',
|
|
99
|
+
'aria-invalid',
|
|
100
|
+
'aria-keyshortcuts',
|
|
101
|
+
'aria-label',
|
|
102
|
+
'aria-labelledby',
|
|
103
|
+
'aria-live',
|
|
104
|
+
'aria-owns',
|
|
105
|
+
'aria-relevant',
|
|
106
|
+
'aria-roledescription'
|
|
88
107
|
];
|
|
89
108
|
|
|
90
|
-
const getFocusableInfo =
|
|
109
|
+
const getFocusableInfo =
|
|
110
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
91
111
|
|
|
92
112
|
const nodes = helpers.queryAllSmart
|
|
93
|
-
|
|
94
|
-
|
|
113
|
+
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
|
|
114
|
+
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
|
|
95
115
|
|
|
96
116
|
const occurrences = [];
|
|
97
117
|
let applicableCount = 0;
|
|
@@ -103,11 +123,32 @@ function runInPage(ctx) {
|
|
|
103
123
|
|
|
104
124
|
// Presence, not value truthiness: the WAI-ARIA role-conflict-resolution
|
|
105
125
|
// rule triggers on a global ARIA attribute being SPECIFIED at all, even
|
|
106
|
-
// with an empty value —
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
126
|
+
// with an empty value — e.g. <img alt="" aria-hidden="">, where
|
|
127
|
+
// aria-hidden="" (empty string) is still a specified attribute. A
|
|
128
|
+
// truthy-value check would miss this.
|
|
129
|
+
let present = CONFLICTING_ATTRS.filter((attr) =>
|
|
130
|
+
el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// aria-hidden="true" (the exact, valid truthy value — not the
|
|
134
|
+
// empty-string case above, which never actually hides anything) is a
|
|
135
|
+
// special case: it removes the element and its subtree from the
|
|
136
|
+
// accessibility tree unconditionally, independent of role. That makes
|
|
137
|
+
// the "role restoration" this rule warns about ("...which restores its
|
|
138
|
+
// implicit role and cancels the presentational intent") factually
|
|
139
|
+
// inert — no AT will ever expose the restored role OR any of the other
|
|
140
|
+
// conflicting attributes (aria-label, aria-describedby, ...) present
|
|
141
|
+
// alongside it, since the whole element stays out of the tree
|
|
142
|
+
// regardless. This pattern is extremely common (e.g. <svg
|
|
143
|
+
// role="presentation" aria-hidden="true"> decorative icons — a
|
|
144
|
+
// defensive belt-and-suspenders double-hide, not an authoring mistake).
|
|
145
|
+
// Focusability is NOT covered by this exemption — a keyboard user can
|
|
146
|
+
// still tab onto an aria-hidden="true" focusable element (the
|
|
147
|
+
// aria-hidden-focus anti-pattern), a real, independent hazard
|
|
148
|
+
// aria-hidden does nothing to prevent.
|
|
149
|
+
if (el.getAttribute('aria-hidden') === 'true') {
|
|
150
|
+
present = [];
|
|
151
|
+
}
|
|
111
152
|
|
|
112
153
|
let isFocusable = false;
|
|
113
154
|
if (getFocusableInfo) {
|
|
@@ -126,9 +167,12 @@ function runInPage(ctx) {
|
|
|
126
167
|
|
|
127
168
|
// No explicit role attribute means this matched via the img[alt=""]
|
|
128
169
|
// implicit-presentation case.
|
|
129
|
-
const role =
|
|
170
|
+
const role =
|
|
171
|
+
String(el.getAttribute('role') || '')
|
|
172
|
+
.trim()
|
|
173
|
+
.toLowerCase() || 'presentation';
|
|
130
174
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
131
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
175
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
132
176
|
|
|
133
177
|
occurrences.push({
|
|
134
178
|
selector: stableSelector,
|
|
@@ -141,7 +185,12 @@ function runInPage(ctx) {
|
|
|
141
185
|
params: { role, attrs: parts.join(', ') }
|
|
142
186
|
},
|
|
143
187
|
data: {
|
|
144
|
-
details: {
|
|
188
|
+
details: {
|
|
189
|
+
reasonCode: 'PRESENTATION_ROLE_CONFLICT',
|
|
190
|
+
role,
|
|
191
|
+
conflictingAttrs: present,
|
|
192
|
+
focusable: isFocusable
|
|
193
|
+
}
|
|
145
194
|
}
|
|
146
195
|
});
|
|
147
196
|
}
|
|
@@ -150,7 +199,12 @@ function runInPage(ctx) {
|
|
|
150
199
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
151
200
|
}
|
|
152
201
|
if (occurrences.length) {
|
|
153
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
ruleId: rule.ruleId,
|
|
204
|
+
outcome: 'cantTell',
|
|
205
|
+
severity: rule.defaultSeverity || 'minor',
|
|
206
|
+
occurrences
|
|
207
|
+
};
|
|
154
208
|
}
|
|
155
209
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
156
210
|
}
|