@surea11y/core 1.1.2 → 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 +48 -4
- package/LICENSE +373 -21
- package/README.md +70 -1
- package/bin/core.js +240 -11
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
- package/docs/CI_INTEGRATIONS.md +103 -0
- package/docs/CLI.md +80 -1
- package/docs/ENGINE_OPTIONS.md +4 -0
- package/docs/INTEGRATION.md +19 -1
- package/docs/OUTPUT_SCHEMA.md +2 -2
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- 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 +50 -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 +125 -29
- 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 +50 -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 +39 -9
- 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 +43 -19
- 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 +117 -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 +37 -8
- package/src/checks/automatic/meta-refresh-timing-absent.js +28 -6
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +32 -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 +26 -7
- 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 -12
- package/src/checks/manual/empty-table-header-manual.js +21 -14
- 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 -12
- 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 +95 -29
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +82 -29
- package/src/checks/manual/landmark-main-is-top-level-manual.js +64 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +54 -23
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +17 -8
- package/src/checks/manual/landmark-one-main-manual.js +35 -21
- package/src/checks/manual/landmark-unique-manual.js +73 -45
- 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 +25 -6
- 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 +40 -23
- package/src/checks/manual/page-title-patterns-manual.js +87 -51
- package/src/checks/manual/presentation-role-conflict-manual.js +59 -19
- package/src/checks/manual/region-manual.js +275 -62
- package/src/checks/manual/scope-attr-valid-manual.js +11 -9
- 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 +11 -9
- 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 +1128 -823
- package/src/core/contrast-helpers.js +1217 -1062
- package/src/core/dom-helpers.js +4176 -3886
- package/src/core/dom-runner.js +720 -596
- 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 +71 -35
- package/src/core.js +37939 -29121
- package/src/i18n/en.js +1194 -887
- package/src/i18n/fr.js +1136 -793
- package/src/policy/contracts.js +13 -13
- package/src/policy/resolvePolicy.js +48 -44
- package/src/report.js +502 -0
- 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',
|
|
@@ -24,13 +30,23 @@ const meta = {
|
|
|
24
30
|
coverage: { facetsBySc: { '2.4.2': ['page-title-patterns'] } }
|
|
25
31
|
};
|
|
26
32
|
|
|
33
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
34
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
35
|
+
// scoped this run narrower than the whole document, or when
|
|
36
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
37
|
+
function applicability(ctx) {
|
|
38
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
function runInPage(ctx) {
|
|
28
42
|
const { document, helpers, rule } = ctx;
|
|
29
|
-
const probes =
|
|
43
|
+
const probes =
|
|
44
|
+
ctx && ctx.inputs && ctx.inputs.probes && typeof ctx.inputs.probes === 'object'
|
|
30
45
|
? ctx.inputs.probes
|
|
31
46
|
: null;
|
|
32
47
|
|
|
33
|
-
const pageTitlesProbe =
|
|
48
|
+
const pageTitlesProbe =
|
|
49
|
+
probes && probes['crawl.pageTitles'] && typeof probes['crawl.pageTitles'] === 'object'
|
|
34
50
|
? probes['crawl.pageTitles']
|
|
35
51
|
: null;
|
|
36
52
|
|
|
@@ -45,16 +61,16 @@ function runInPage(ctx) {
|
|
|
45
61
|
// Cross-page pattern analysis (preferred) if crawl.pageTitles probe is provided
|
|
46
62
|
// =========================
|
|
47
63
|
if (pageTitlesProbe && Array.isArray(pageTitlesProbe.pages)) {
|
|
48
|
-
const pages = pageTitlesProbe.pages.filter(p => p && typeof p === 'object');
|
|
64
|
+
const pages = pageTitlesProbe.pages.filter((p) => p && typeof p === 'object');
|
|
49
65
|
|
|
50
66
|
// Require enough data to avoid noisy conclusions
|
|
51
67
|
const MIN_PAGES = 10;
|
|
52
68
|
const analyzable = pages
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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);
|
|
58
74
|
|
|
59
75
|
if (analyzable.length >= MIN_PAGES) {
|
|
60
76
|
// Build normalized title groups (case-insensitive)
|
|
@@ -66,11 +82,11 @@ function runInPage(ctx) {
|
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
// Duplicate titles across distinct URLs is a strong "review" signal (not a guaranteed failure)
|
|
69
|
-
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);
|
|
70
86
|
|
|
71
87
|
// Boilerplate-ish: detect a long common suffix/prefix across most titles.
|
|
72
88
|
// Keep conservative: only flag if the common part is long and shared by many.
|
|
73
|
-
const titles = analyzable.map(p => p.title);
|
|
89
|
+
const titles = analyzable.map((p) => p.title);
|
|
74
90
|
function commonPrefix(a, b) {
|
|
75
91
|
const n = Math.min(a.length, b.length);
|
|
76
92
|
let i = 0;
|
|
@@ -95,35 +111,41 @@ function runInPage(ctx) {
|
|
|
95
111
|
const suffixLen = sharedSuffix.trim().length;
|
|
96
112
|
|
|
97
113
|
const hasStrongTemplateSignal =
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
(prefixLen >= 12 || suffixLen >= 12) &&
|
|
115
|
+
(prefixLen >= 12 ? sharedPrefix.trim().length : 0) +
|
|
116
|
+
(suffixLen >= 12 ? sharedSuffix.trim().length : 0) >=
|
|
117
|
+
12;
|
|
100
118
|
|
|
101
119
|
// If any cross-page signal exists, emit cantTell occurrence(s)
|
|
102
120
|
if (dupGroups.length || hasStrongTemplateSignal) {
|
|
103
121
|
const reasonCode = dupGroups.length
|
|
104
|
-
|
|
105
|
-
|
|
122
|
+
? 'duplicateTitlesAcrossPages'
|
|
123
|
+
: 'templatedTitlesAcrossPages';
|
|
106
124
|
|
|
107
125
|
// Deterministic example title for i18n params (lexicographic, case-insensitive)
|
|
108
126
|
const exampleTitle = dupGroups.length
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
: '';
|
|
114
136
|
|
|
115
137
|
const summaryKey = dupGroups.length
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
? 'pageTitlePatterns_summary_cantTell_duplicateAcrossPages'
|
|
139
|
+
: 'pageTitlePatterns_summary_cantTell_templatedAcrossPages';
|
|
118
140
|
|
|
119
141
|
const i18nParams = dupGroups.length
|
|
120
|
-
|
|
142
|
+
? {
|
|
121
143
|
reasonCode,
|
|
122
144
|
pagesAnalyzed: analyzable.length,
|
|
123
145
|
duplicateGroups: dupGroups.length,
|
|
124
146
|
exampleTitle
|
|
125
147
|
}
|
|
126
|
-
|
|
148
|
+
: {
|
|
127
149
|
reasonCode,
|
|
128
150
|
pagesAnalyzed: analyzable.length
|
|
129
151
|
};
|
|
@@ -131,7 +153,8 @@ function runInPage(ctx) {
|
|
|
131
153
|
const occBase = {
|
|
132
154
|
selector: 'head > title',
|
|
133
155
|
html: '',
|
|
134
|
-
summary:
|
|
156
|
+
summary:
|
|
157
|
+
'The set of page titles may not be descriptive enough to distinguish pages by topic or purpose.',
|
|
135
158
|
hint: 'Ensure each page title is sufficiently descriptive and helps users distinguish pages (avoid identical or overly templated titles across many pages).',
|
|
136
159
|
i18n: {
|
|
137
160
|
summaryKey,
|
|
@@ -144,12 +167,14 @@ function runInPage(ctx) {
|
|
|
144
167
|
metrics: {
|
|
145
168
|
pagesAnalyzed: analyzable.length,
|
|
146
169
|
duplicateGroups: dupGroups.length,
|
|
147
|
-
largestDuplicateGroupSize: dupGroups.length
|
|
170
|
+
largestDuplicateGroupSize: dupGroups.length
|
|
171
|
+
? Math.max(...dupGroups.map((g) => g.urls.length))
|
|
172
|
+
: 0,
|
|
148
173
|
sharedPrefix: prefixLen >= 12 ? sharedPrefix.trim() : '',
|
|
149
174
|
sharedSuffix: suffixLen >= 12 ? sharedSuffix.trim() : ''
|
|
150
175
|
},
|
|
151
176
|
refs: {
|
|
152
|
-
exampleDuplicateTitles: dupGroups.slice(0, 3).map(g => ({
|
|
177
|
+
exampleDuplicateTitles: dupGroups.slice(0, 3).map((g) => ({
|
|
153
178
|
title: g.title,
|
|
154
179
|
urls: g.urls.slice(0, 5)
|
|
155
180
|
}))
|
|
@@ -165,12 +190,20 @@ function runInPage(ctx) {
|
|
|
165
190
|
// No node available: keep deterministic fallback snippet (no helper calls).
|
|
166
191
|
occurrences.push({
|
|
167
192
|
...occBase,
|
|
168
|
-
html:
|
|
193
|
+
html:
|
|
194
|
+
titleEl && titleEl.outerHTML
|
|
195
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
196
|
+
: '<title>(unknown)</title>'
|
|
169
197
|
});
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
// Cross-page analysis is authoritative when present; do not also run single-page heuristics.
|
|
173
|
-
return {
|
|
201
|
+
return {
|
|
202
|
+
ruleId: rule.ruleId,
|
|
203
|
+
outcome: 'cantTell',
|
|
204
|
+
severity: rule.defaultSeverity || 'minor',
|
|
205
|
+
occurrences
|
|
206
|
+
};
|
|
174
207
|
}
|
|
175
208
|
|
|
176
209
|
// If we had enough pages and found no review signal, there is nothing
|
|
@@ -186,14 +219,7 @@ function runInPage(ctx) {
|
|
|
186
219
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
187
220
|
}
|
|
188
221
|
|
|
189
|
-
const GENERIC_TITLES = new Set([
|
|
190
|
-
'home',
|
|
191
|
-
'homepage',
|
|
192
|
-
'welcome',
|
|
193
|
-
'untitled',
|
|
194
|
-
'page',
|
|
195
|
-
'document'
|
|
196
|
-
]);
|
|
222
|
+
const GENERIC_TITLES = new Set(['home', 'homepage', 'welcome', 'untitled', 'page', 'document']);
|
|
197
223
|
|
|
198
224
|
// Conservative signals:
|
|
199
225
|
// - very short title (likely non-descriptive)
|
|
@@ -202,27 +228,29 @@ function runInPage(ctx) {
|
|
|
202
228
|
const isGeneric = GENERIC_TITLES.has(titleLc);
|
|
203
229
|
|
|
204
230
|
// Template-like: "Brand | Home" or "Home - Brand" where the page-specific part is a generic token.
|
|
205
|
-
const templateLike =
|
|
206
|
-
|
|
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);
|
|
207
234
|
|
|
208
235
|
if (isGeneric || isVeryShort || templateLike) {
|
|
209
236
|
const reasonCode = isGeneric
|
|
210
237
|
? 'genericTitle'
|
|
211
|
-
:
|
|
238
|
+
: isVeryShort
|
|
239
|
+
? 'veryShortTitle'
|
|
240
|
+
: 'templateLikeTitle';
|
|
212
241
|
|
|
213
242
|
const summaryKey =
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
243
|
+
reasonCode === 'genericTitle'
|
|
244
|
+
? 'pageTitlePatterns_summary_cantTell_generic'
|
|
245
|
+
: reasonCode === 'veryShortTitle'
|
|
246
|
+
? 'pageTitlePatterns_summary_cantTell_veryShort'
|
|
247
|
+
: 'pageTitlePatterns_summary_cantTell_templateLike';
|
|
219
248
|
const occBase = {
|
|
220
249
|
selector: 'head > title',
|
|
221
250
|
html: '',
|
|
222
251
|
summary:
|
|
223
|
-
|
|
224
|
-
hint:
|
|
225
|
-
'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).',
|
|
226
254
|
i18n: {
|
|
227
255
|
summaryKey,
|
|
228
256
|
hintKey: 'pageTitlePatterns_hint_cantTell',
|
|
@@ -242,7 +270,10 @@ function runInPage(ctx) {
|
|
|
242
270
|
} else {
|
|
243
271
|
occurrences.push({
|
|
244
272
|
...occBase,
|
|
245
|
-
html:
|
|
273
|
+
html:
|
|
274
|
+
titleEl && titleEl.outerHTML
|
|
275
|
+
? String(titleEl.outerHTML).slice(0, 2000)
|
|
276
|
+
: '<title>(unknown)</title>'
|
|
246
277
|
});
|
|
247
278
|
}
|
|
248
279
|
}
|
|
@@ -253,10 +284,15 @@ function runInPage(ctx) {
|
|
|
253
284
|
|
|
254
285
|
if (occurrences.length) {
|
|
255
286
|
// Patterns are review signals: cantTell rather than fail.
|
|
256
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
ruleId: rule.ruleId,
|
|
289
|
+
outcome: 'cantTell',
|
|
290
|
+
severity: rule.defaultSeverity || 'minor',
|
|
291
|
+
occurrences
|
|
292
|
+
};
|
|
257
293
|
}
|
|
258
294
|
|
|
259
295
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
260
296
|
}
|
|
261
297
|
|
|
262
|
-
module.exports = { id, meta, runInPage };
|
|
298
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -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
|
}
|