@surea11y/core 1.2.0 → 1.3.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 +37 -7
- package/LICENSE +373 -21
- package/README.md +67 -1
- package/bin/core.js +144 -19
- package/docs/API_STABILITY.md +1 -1
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +54 -1
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +18 -0
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/RULE_CATALOG.md +1 -1
- package/docs/SARIF.md +59 -0
- package/package.json +15 -4
- package/src/baseline.js +0 -0
- package/src/catalogs/composites.wcag.js +414 -450
- package/src/checks/automatic/area-alt-present.js +59 -25
- package/src/checks/automatic/aria-allowed-attr.js +193 -33
- package/src/checks/automatic/aria-allowed-role.js +21 -7
- package/src/checks/automatic/aria-braille-equivalent.js +32 -10
- package/src/checks/automatic/aria-conditional-attr.js +24 -7
- package/src/checks/automatic/aria-deprecated-role.js +22 -8
- package/src/checks/automatic/aria-hidden-body.js +42 -19
- package/src/checks/automatic/aria-hidden-focus.js +408 -53
- package/src/checks/automatic/aria-prohibited-attr.js +296 -22
- package/src/checks/automatic/aria-prohibited-children.js +55 -16
- package/src/checks/automatic/aria-required-attr.js +23 -8
- package/src/checks/automatic/aria-required-children.js +37 -14
- package/src/checks/automatic/aria-required-parent.js +48 -14
- package/src/checks/automatic/aria-role-name-present.js +47 -21
- package/src/checks/automatic/aria-roles-valid.js +22 -12
- package/src/checks/automatic/aria-valid-attr-value.js +28 -7
- package/src/checks/automatic/aria-valid-attr.js +17 -5
- package/src/checks/automatic/autocomplete-valid.js +74 -16
- package/src/checks/automatic/avoid-inline-spacing.js +20 -6
- package/src/checks/automatic/binary-control-name-present.js +60 -50
- package/src/checks/automatic/button-name-present.js +48 -18
- package/src/checks/automatic/bypass-blocks-present.js +42 -25
- package/src/checks/automatic/canvas-text-alternative-present.js +57 -26
- package/src/checks/automatic/combobox-name-present.js +38 -45
- package/src/checks/automatic/contrast-computable.js +361 -341
- package/src/checks/automatic/contrast-enhanced.js +487 -466
- package/src/checks/automatic/contrast-minimum.js +486 -465
- package/src/checks/automatic/css-orientation-lock.js +30 -8
- package/src/checks/automatic/definition-list-children-valid.js +40 -19
- package/src/checks/automatic/deprecated-elements-not-used.js +21 -7
- package/src/checks/automatic/dialog-name-present.js +37 -75
- package/src/checks/automatic/dlitem-parent-valid.js +23 -8
- package/src/checks/automatic/duplicate-id-aria.js +24 -6
- package/src/checks/automatic/embed-text-alternative-present.js +86 -35
- package/src/checks/automatic/form-control-programmatic-label-present.js +79 -196
- package/src/checks/automatic/form-control-single-label.js +47 -10
- package/src/checks/automatic/html-xml-lang-mismatch.js +34 -18
- package/src/checks/automatic/iframe-focusable-content.js +26 -11
- package/src/checks/automatic/iframe-name-present.js +31 -9
- package/src/checks/automatic/iframe-title-unique.js +29 -8
- package/src/checks/automatic/img-alt-present.js +47 -43
- package/src/checks/automatic/input-image-alt-present.js +143 -112
- package/src/checks/automatic/label-in-name.js +50 -22
- package/src/checks/automatic/language-page-present.js +109 -109
- package/src/checks/automatic/link-in-text-block.js +59 -19
- package/src/checks/automatic/link-name-present.js +45 -14
- package/src/checks/automatic/list-children-valid.js +26 -9
- package/src/checks/automatic/listbox-name-present.js +39 -19
- package/src/checks/automatic/listitem-parent-valid.js +18 -6
- package/src/checks/automatic/menuitem-name-present.js +39 -61
- package/src/checks/automatic/meta-refresh-no-exceptions.js +28 -7
- package/src/checks/automatic/meta-refresh-timing-absent.js +20 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +24 -7
- package/src/checks/automatic/meter-name-present.js +36 -33
- package/src/checks/automatic/nested-interactive-controls-absent.js +31 -10
- package/src/checks/automatic/object-text-alternative-present.js +91 -39
- package/src/checks/automatic/option-name-present.js +38 -21
- package/src/checks/automatic/page-title-present.js +17 -6
- package/src/checks/automatic/progressbar-name-present.js +41 -34
- package/src/checks/automatic/role-img-alt-present.js +209 -157
- package/src/checks/automatic/searchbox-name-present.js +39 -19
- package/src/checks/automatic/server-side-image-map-absent.js +23 -8
- package/src/checks/automatic/slider-name-present.js +40 -47
- package/src/checks/automatic/spinbutton-name-present.js +39 -19
- package/src/checks/automatic/summary-name-present.js +37 -17
- package/src/checks/automatic/svg-image-text-alternative-present.js +114 -47
- package/src/checks/automatic/svg-text-alternative-present.js +246 -226
- package/src/checks/automatic/tab-name-present.js +37 -60
- package/src/checks/automatic/table-headers-attr-valid.js +24 -8
- package/src/checks/automatic/table-th-has-data-cells.js +22 -8
- package/src/checks/automatic/target-size-minimum.js +118 -48
- package/src/checks/automatic/td-has-header.js +29 -11
- package/src/checks/automatic/textbox-name-present.js +39 -19
- package/src/checks/automatic/tooltip-name-present.js +37 -18
- package/src/checks/automatic/treeitem-name-present.js +38 -21
- package/src/checks/automatic/valid-lang.js +20 -6
- package/src/checks/automatic/video-poster-text-alternative-present.js +79 -36
- package/src/checks/manual/accesskeys-manual.js +14 -5
- package/src/checks/manual/area-alt-decorative-manual.js +192 -193
- package/src/checks/manual/area-alt-quality-manual.js +182 -141
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +34 -11
- package/src/checks/manual/aria-text-manual.js +14 -6
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +149 -114
- package/src/checks/manual/css-hidden-focus.js +196 -165
- package/src/checks/manual/embed-text-alternative-quality-manual.js +171 -160
- package/src/checks/manual/empty-heading-manual.js +24 -7
- package/src/checks/manual/empty-table-header-manual.js +17 -6
- package/src/checks/manual/focus-order-semantics-manual.js +45 -10
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +207 -246
- package/src/checks/manual/heading-order-manual.js +22 -7
- package/src/checks/manual/identical-links-same-purpose-manual.js +34 -12
- package/src/checks/manual/image-redundant-alt-manual.js +17 -7
- package/src/checks/manual/img-alt-decorative-manual.js +131 -96
- package/src/checks/manual/img-alt-quality-manual.js +176 -127
- package/src/checks/manual/input-image-alt-decorative-manual.js +125 -92
- package/src/checks/manual/input-image-alt-quality-manual.js +125 -92
- package/src/checks/manual/label-title-only-manual.js +14 -5
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +66 -16
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +53 -16
- package/src/checks/manual/landmark-main-is-top-level-manual.js +35 -10
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +29 -10
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +26 -20
- package/src/checks/manual/landmark-unique-manual.js +41 -15
- package/src/checks/manual/link-name-quality-manual.js +43 -12
- package/src/checks/manual/media-transcript-present-manual.js +35 -22
- package/src/checks/manual/meta-viewport-large-manual.js +16 -5
- package/src/checks/manual/mouse-only-event-handlers-manual.js +38 -11
- package/src/checks/manual/no-autoplay-audio-manual.js +20 -6
- package/src/checks/manual/object-text-alternative-quality-manual.js +175 -154
- package/src/checks/manual/p-as-heading-manual.js +22 -7
- package/src/checks/manual/page-has-heading-one-manual.js +31 -22
- package/src/checks/manual/page-title-patterns-manual.js +78 -50
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +241 -48
- package/src/checks/manual/scope-attr-valid-manual.js +10 -3
- package/src/checks/manual/scrollable-region-focusable-manual.js +37 -11
- package/src/checks/manual/skip-link-manual.js +35 -12
- package/src/checks/manual/svg-text-alternative-quality-manual.js +206 -165
- package/src/checks/manual/tabindex-manual.js +10 -3
- package/src/checks/manual/table-duplicate-name-manual.js +17 -7
- package/src/checks/manual/table-fake-caption-manual.js +24 -7
- package/src/checks/manual/video-caption-manual.js +15 -4
- package/src/checks/manual-review.js +56 -12
- package/src/core/aria-helpers.js +1127 -886
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4175 -3917
- package/src/core/dom-runner.js +720 -604
- package/src/core/frame-messaging.js +189 -138
- package/src/core/frame-scan.js +94 -82
- package/src/core/rollup-composites.js +94 -102
- package/src/core/rule-meta.js +58 -41
- package/src/core.js +36552 -29111
- package/src/i18n/en.js +1194 -889
- package/src/i18n/fr.js +1136 -795
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +63 -43
- package/src/sarif.js +175 -0
- package/surea11y.browser.js +36042 -0
|
@@ -15,7 +15,13 @@ const meta = {
|
|
|
15
15
|
tags: ['wcag2a', 'wcag242', 'titles', 'atomic', 'navigation', 'manual'],
|
|
16
16
|
wcagSc: ['2.4.2'],
|
|
17
17
|
normativeMappings: [
|
|
18
|
-
{
|
|
18
|
+
{
|
|
19
|
+
standard: 'WCAG',
|
|
20
|
+
version: '2.2',
|
|
21
|
+
requirement: '2.4.2',
|
|
22
|
+
title: 'Page Titled',
|
|
23
|
+
conformanceLevel: 'A'
|
|
24
|
+
}
|
|
19
25
|
],
|
|
20
26
|
defaultSeverity: 'minor',
|
|
21
27
|
category: 'operable',
|
|
@@ -34,11 +40,13 @@ function applicability(ctx) {
|
|
|
34
40
|
|
|
35
41
|
function runInPage(ctx) {
|
|
36
42
|
const { document, helpers, rule } = ctx;
|
|
37
|
-
const probes =
|
|
43
|
+
const probes =
|
|
44
|
+
ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
|
|
38
45
|
? ctx.inputs.probes
|
|
39
46
|
: null;
|
|
40
47
|
|
|
41
|
-
const pageTitlesProbe =
|
|
48
|
+
const pageTitlesProbe =
|
|
49
|
+
probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
|
|
42
50
|
? probes['crawl.pageTitles']
|
|
43
51
|
: null;
|
|
44
52
|
|
|
@@ -53,16 +61,16 @@ function runInPage(ctx) {
|
|
|
53
61
|
// Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
|
|
54
62
|
// =========================
|
|
55
63
|
if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
|
|
56
|
-
const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
|
|
64
|
+
const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
|
|
57
65
|
|
|
58
66
|
// Require enough data to avoid noisy conclusions
|
|
59
67
|
const MIN_PAGES = 10;
|
|
60
68
|
const analyzable = pages
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
.map((p) => ({
|
|
70
|
+
url: p.url ? String(p.url) : null,
|
|
71
|
+
title: typeof p.title === 'string' ? p.title.replace(/\s+/g, ' ').trim() : ''
|
|
72
|
+
}))
|
|
73
|
+
.filter((p) => p.url && p.title);
|
|
66
74
|
|
|
67
75
|
if (analyzable.length >= MIN_PAGES) {
|
|
68
76
|
// Build normalized title groups (case-insensitive)
|
|
@@ -74,11 +82,11 @@ function runInPage(ctx) {
|
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
// 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);
|
|
85
|
+
const dupGroups = Array.from(groups.values()).filter((g) => g.urls.length >= 2);
|
|
78
86
|
|
|
79
87
|
// Boilerplate-ish: detect a long common suffix/prefix across most titles.
|
|
80
88
|
// Keep conservative: only flag if the common part is long and shared by many.
|
|
81
|
-
const titles = analyzable.map(p => p.title);
|
|
89
|
+
const titles = analyzable.map((p) => p.title);
|
|
82
90
|
function commonPrefix(a, b) {
|
|
83
91
|
const n = Math.min(a.length, b.length);
|
|
84
92
|
let i = 0;
|
|
@@ -103,35 +111,41 @@ function runInPage(ctx) {
|
|
|
103
111
|
const suffixLen = sharedSuffix.trim().length;
|
|
104
112
|
|
|
105
113
|
const hasStrongTemplateSignal =
|
|
106
|
-
|
|
107
|
-
|
|
114
|
+
(prefixLen >= 12 || suffixLen >= 12) &&
|
|
115
|
+
(prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
|
|
116
|
+
(suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
|
|
117
|
+
12;
|
|
108
118
|
|
|
109
119
|
// If any cross-page signal exists, emit cantTell occurrence(s)
|
|
110
120
|
if (dupGroups.length || hasStrongTemplateSignal) {
|
|
111
121
|
const reasonCode = dupGroups.length
|
|
112
|
-
|
|
113
|
-
|
|
122
|
+
? 'duplicateTitlesAcrossPages'
|
|
123
|
+
: 'templatedTitlesAcrossPages';
|
|
114
124
|
|
|
115
125
|
// Deterministic example title for i18n params (lexicographic, case-insensitive)
|
|
116
126
|
const exampleTitle = dupGroups.length
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
? dupGroups
|
|
128
|
+
.map((g) =>
|
|
129
|
+
String(g.title || '')
|
|
130
|
+
.replace(/\s+/g, ' ')
|
|
131
|
+
.trim()
|
|
132
|
+
)
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))[0] || ''
|
|
135
|
+
: '';
|
|
122
136
|
|
|
123
137
|
const summaryKey = dupGroups.length
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
|
|
139
|
+
: 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
|
|
126
140
|
|
|
127
141
|
const i18nParams = dupGroups.length
|
|
128
|
-
|
|
142
|
+
? {
|
|
129
143
|
reasonCode,
|
|
130
144
|
pagesAnalyzed: analyzable.length,
|
|
131
145
|
duplicateGroups: dupGroups.length,
|
|
132
146
|
exampleTitle
|
|
133
147
|
}
|
|
134
|
-
|
|
148
|
+
: {
|
|
135
149
|
reasonCode,
|
|
136
150
|
pagesAnalyzed: analyzable.length
|
|
137
151
|
};
|
|
@@ -139,7 +153,8 @@ function runInPage(ctx) {
|
|
|
139
153
|
const occBase = {
|
|
140
154
|
selector: 'head > title',
|
|
141
155
|
html: '',
|
|
142
|
-
summary:
|
|
156
|
+
summary:
|
|
157
|
+
'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
143
158
|
hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
|
|
144
159
|
i18n: {
|
|
145
160
|
summaryKey,
|
|
@@ -152,12 +167,14 @@ function runInPage(ctx) {
|
|
|
152
167
|
metrics: {
|
|
153
168
|
pagesAnalyzed: analyzable.length,
|
|
154
169
|
duplicateGroups: dupGroups.length,
|
|
155
|
-
largestDuplicateGroupSize: dupGroups.length
|
|
170
|
+
largestDuplicateGroupSize: dupGroups.length
|
|
171
|
+
? Math.max(...dupGroups.map((g) => g.urls.length))
|
|
172
|
+
: 0,
|
|
156
173
|
sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
|
|
157
174
|
sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
|
|
158
175
|
},
|
|
159
176
|
refs: {
|
|
160
|
-
exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
|
|
177
|
+
exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
|
|
161
178
|
title: g.title,
|
|
162
179
|
urls: g.urls.slice(0, 5)
|
|
163
180
|
}))
|
|
@@ -173,12 +190,20 @@ function runInPage(ctx) {
|
|
|
173
190
|
// No node available: keep deterministic fallback snippet (no helper calls).
|
|
174
191
|
occurrences.push({
|
|
175
192
|
...occBase,
|
|
176
|
-
html:
|
|
193
|
+
html:
|
|
194
|
+
titleEl && titleEl.outerHTML
|
|
195
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
196
|
+
: '<title>(unknown)</title>'
|
|
177
197
|
});
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
// Cross-page analysis is authoritative when present; do not also run single-page heuristics.
|
|
181
|
-
return {
|
|
201
|
+
return {
|
|
202
|
+
ruleId: rule.ruleId,
|
|
203
|
+
outcome: 'cantTell',
|
|
204
|
+
severity: rule.defaultSeverity || 'minor',
|
|
205
|
+
occurrences
|
|
206
|
+
};
|
|
182
207
|
}
|
|
183
208
|
|
|
184
209
|
// If we had enough pages and found no review signal, there is nothing
|
|
@@ -194,14 +219,7 @@ function runInPage(ctx) {
|
|
|
194
219
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
195
220
|
}
|
|
196
221
|
|
|
197
|
-
const GENERIC_TITLES = new Set([
|
|
198
|
-
'home',
|
|
199
|
-
'homepage',
|
|
200
|
-
'welcome',
|
|
201
|
-
'untitled',
|
|
202
|
-
'page',
|
|
203
|
-
'document'
|
|
204
|
-
]);
|
|
222
|
+
const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
|
|
205
223
|
|
|
206
224
|
// Conservative signals:
|
|
207
225
|
// - very short title (likely non-descriptive)
|
|
@@ -210,27 +228,29 @@ function runInPage(ctx) {
|
|
|
210
228
|
const isGeneric = GENERIC_TITLES.has(titleLc);
|
|
211
229
|
|
|
212
230
|
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
213
|
-
const templateLike =
|
|
214
|
-
|
|
231
|
+
const templateLike =
|
|
232
|
+
/\b(home|homepage|welcome)\b\s*(\||-|—|:)\s*.+/i.test(titleText) ||
|
|
233
|
+
/.+\s*(\||-|—|:)\s*\b(home|homepage|welcome)\b/i.test(titleText);
|
|
215
234
|
|
|
216
235
|
if (isGeneric || isVeryShort || templateLike) {
|
|
217
236
|
const reasonCode = isGeneric
|
|
218
237
|
? 'genericTitle'
|
|
219
|
-
:
|
|
238
|
+
: isVeryShort
|
|
239
|
+
? 'veryShortTitle'
|
|
240
|
+
: 'templateLikeTitle';
|
|
220
241
|
|
|
221
242
|
const summaryKey =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
243
|
+
reasonCode === 'genericTitle'
|
|
244
|
+
? 'pageTitlePatterns_summary_cantTell_generic'
|
|
245
|
+
: reasonCode === 'veryShortTitle'
|
|
246
|
+
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
247
|
+
: 'pageTitlePatterns_summary_cantTell_templateLike';
|
|
227
248
|
const occBase = {
|
|
228
249
|
selector: 'head > title',
|
|
229
250
|
html: '',
|
|
230
251
|
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).',
|
|
252
|
+
'The page title may not be descriptive enough to identify the page topic or purpose.',
|
|
253
|
+
hint: 'Use a more specific title that identifies the page topic or purpose (for example, include the section name or task).',
|
|
234
254
|
i18n: {
|
|
235
255
|
summaryKey,
|
|
236
256
|
hintKey: 'pageTitlePatterns_hint_cantTell',
|
|
@@ -250,7 +270,10 @@ function runInPage(ctx) {
|
|
|
250
270
|
} else {
|
|
251
271
|
occurrences.push({
|
|
252
272
|
...occBase,
|
|
253
|
-
html:
|
|
273
|
+
html:
|
|
274
|
+
titleEl && titleEl.outerHTML
|
|
275
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
276
|
+
: '<title>(unknown)</title>'
|
|
254
277
|
});
|
|
255
278
|
}
|
|
256
279
|
}
|
|
@@ -261,7 +284,12 @@ function runInPage(ctx) {
|
|
|
261
284
|
|
|
262
285
|
if (occurrences.length) {
|
|
263
286
|
// Patterns are review signals: cantTell rather than fail.
|
|
264
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
ruleId: rule.ruleId,
|
|
289
|
+
outcome: 'cantTell',
|
|
290
|
+
severity: rule.defaultSeverity || 'minor',
|
|
291
|
+
occurrences
|
|
292
|
+
};
|
|
265
293
|
}
|
|
266
294
|
|
|
267
295
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
@@ -43,10 +43,14 @@
|
|
|
43
43
|
* native implicit role to suppress in the first place (e.g.
|
|
44
44
|
* `<div role="presentation" aria-hidden="true">` — a <div> has no native
|
|
45
45
|
* role, so there's nothing for the presentational role to "conflict"
|
|
46
|
-
* with per that engine's own scope decision).
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* with per that engine's own scope decision). surea11y stays
|
|
47
|
+
* broader/more cautious here rather than narrower, which is the safer
|
|
48
|
+
* direction to diverge in, and no real false positive from staying
|
|
49
|
+
* broad has surfaced in any corpus round to date.
|
|
50
|
+
* - The native-implicit-role table needed to replicate that gate (if this
|
|
51
|
+
* scope decision is ever revisited) has since been produced — see
|
|
52
|
+
* ROADMAP.md §7 item 9 (2026-07-31) for the full table and the open
|
|
53
|
+
* maintainer decision; not implemented here pending that call.
|
|
50
54
|
* - Focusability is computed via helpers.getFocusableInfo (native +
|
|
51
55
|
* tabindex), same helper aria-hidden-focus already relies on — a
|
|
52
56
|
* `:disabled` or otherwise non-focusable element is not flagged.
|
|
@@ -56,7 +60,8 @@ const id = 'presentation-role-conflict';
|
|
|
56
60
|
|
|
57
61
|
const meta = {
|
|
58
62
|
title: 'Presentational role must not conflict with a global ARIA attribute or focusability',
|
|
59
|
-
description:
|
|
63
|
+
description:
|
|
64
|
+
'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
65
|
i18n: {
|
|
61
66
|
titleKey: 'presentationRoleConflict_title',
|
|
62
67
|
descriptionKey: 'presentationRoleConflict_description'
|
|
@@ -73,25 +78,45 @@ const meta = {
|
|
|
73
78
|
};
|
|
74
79
|
|
|
75
80
|
function runInPage(ctx) {
|
|
76
|
-
const {
|
|
81
|
+
const { helpers, rule } = ctx;
|
|
77
82
|
|
|
78
83
|
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
79
84
|
// spec (confirmed against a widely-used reference engine's own
|
|
80
85
|
// `standards.ariaAttrs` data at runtime, 2026-07-20) — any of these present on a presentational
|
|
81
86
|
// element restores its implicit role, not just the naming ones.
|
|
82
87
|
const CONFLICTING_ATTRS = [
|
|
83
|
-
'aria-atomic',
|
|
84
|
-
'aria-
|
|
85
|
-
'aria-
|
|
86
|
-
'aria-
|
|
87
|
-
'aria-
|
|
88
|
+
'aria-atomic',
|
|
89
|
+
'aria-braillelabel',
|
|
90
|
+
'aria-brailleroledescription',
|
|
91
|
+
'aria-busy',
|
|
92
|
+
'aria-controls',
|
|
93
|
+
'aria-current',
|
|
94
|
+
'aria-describedby',
|
|
95
|
+
'aria-description',
|
|
96
|
+
'aria-details',
|
|
97
|
+
'aria-disabled',
|
|
98
|
+
'aria-dropeffect',
|
|
99
|
+
'aria-errormessage',
|
|
100
|
+
'aria-flowto',
|
|
101
|
+
'aria-grabbed',
|
|
102
|
+
'aria-haspopup',
|
|
103
|
+
'aria-hidden',
|
|
104
|
+
'aria-invalid',
|
|
105
|
+
'aria-keyshortcuts',
|
|
106
|
+
'aria-label',
|
|
107
|
+
'aria-labelledby',
|
|
108
|
+
'aria-live',
|
|
109
|
+
'aria-owns',
|
|
110
|
+
'aria-relevant',
|
|
111
|
+
'aria-roledescription'
|
|
88
112
|
];
|
|
89
113
|
|
|
90
|
-
const getFocusableInfo =
|
|
114
|
+
const getFocusableInfo =
|
|
115
|
+
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
91
116
|
|
|
92
117
|
const nodes = helpers.queryAllSmart
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
|
|
119
|
+
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
|
|
95
120
|
|
|
96
121
|
const occurrences = [];
|
|
97
122
|
let applicableCount = 0;
|
|
@@ -107,7 +132,9 @@ function runInPage(ctx) {
|
|
|
107
132
|
// <img alt="" aria-hidden="">, where aria-hidden="" (empty string) is
|
|
108
133
|
// still a specified attribute. A truthy-value check would have missed
|
|
109
134
|
// this even after aria-hidden was added to CONFLICTING_ATTRS above.
|
|
110
|
-
const present = CONFLICTING_ATTRS.filter((attr) =>
|
|
135
|
+
const present = CONFLICTING_ATTRS.filter((attr) =>
|
|
136
|
+
el.hasAttribute ? el.hasAttribute(attr) : el.getAttribute(attr) != null
|
|
137
|
+
);
|
|
111
138
|
|
|
112
139
|
let isFocusable = false;
|
|
113
140
|
if (getFocusableInfo) {
|
|
@@ -126,9 +153,12 @@ function runInPage(ctx) {
|
|
|
126
153
|
|
|
127
154
|
// No explicit role attribute means this matched via the img[alt=""]
|
|
128
155
|
// implicit-presentation case.
|
|
129
|
-
const role =
|
|
156
|
+
const role =
|
|
157
|
+
String(el.getAttribute('role') || '')
|
|
158
|
+
.trim()
|
|
159
|
+
.toLowerCase() || 'presentation';
|
|
130
160
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
131
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) :
|
|
161
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
132
162
|
|
|
133
163
|
occurrences.push({
|
|
134
164
|
selector: stableSelector,
|
|
@@ -141,7 +171,12 @@ function runInPage(ctx) {
|
|
|
141
171
|
params: { role, attrs: parts.join(', ') }
|
|
142
172
|
},
|
|
143
173
|
data: {
|
|
144
|
-
details: {
|
|
174
|
+
details: {
|
|
175
|
+
reasonCode: 'PRESENTATION_ROLE_CONFLICT',
|
|
176
|
+
role,
|
|
177
|
+
conflictingAttrs: present,
|
|
178
|
+
focusable: isFocusable
|
|
179
|
+
}
|
|
145
180
|
}
|
|
146
181
|
});
|
|
147
182
|
}
|
|
@@ -150,7 +185,12 @@ function runInPage(ctx) {
|
|
|
150
185
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
151
186
|
}
|
|
152
187
|
if (occurrences.length) {
|
|
153
|
-
return {
|
|
188
|
+
return {
|
|
189
|
+
ruleId: rule.ruleId,
|
|
190
|
+
outcome: 'cantTell',
|
|
191
|
+
severity: rule.defaultSeverity || 'minor',
|
|
192
|
+
occurrences
|
|
193
|
+
};
|
|
154
194
|
}
|
|
155
195
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
156
196
|
}
|