@surea11y/core 1.1.2 → 1.2.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 +14 -0
- package/README.md +3 -0
- package/bin/core.js +107 -3
- package/docs/API_STABILITY.md +61 -0
- package/docs/BASELINE.md +66 -0
- package/docs/CLI.md +26 -0
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/INTEGRATION.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +1 -1
- package/docs/REPORT.md +33 -0
- package/docs/RULE_AUTHORING.md +31 -0
- package/package.json +1 -1
- package/src/baseline.js +0 -0
- package/src/checks/automatic/aria-hidden-body.js +9 -1
- package/src/checks/automatic/aria-prohibited-children.js +71 -14
- package/src/checks/automatic/bypass-blocks-present.js +9 -1
- package/src/checks/automatic/css-orientation-lock.js +9 -1
- package/src/checks/automatic/html-xml-lang-mismatch.js +9 -1
- package/src/checks/automatic/language-page-present.js +9 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +9 -1
- package/src/checks/automatic/meta-refresh-timing-absent.js +9 -1
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +9 -1
- package/src/checks/automatic/page-title-present.js +9 -1
- package/src/checks/manual/empty-heading-manual.js +1 -6
- package/src/checks/manual/empty-table-header-manual.js +5 -9
- package/src/checks/manual/heading-order-manual.js +1 -6
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-main-is-top-level-manual.js +30 -14
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +25 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +25 -13
- package/src/checks/manual/landmark-one-main-manual.js +9 -1
- package/src/checks/manual/landmark-unique-manual.js +32 -30
- package/src/checks/manual/meta-viewport-large-manual.js +9 -1
- package/src/checks/manual/page-has-heading-one-manual.js +9 -1
- package/src/checks/manual/page-title-patterns-manual.js +9 -1
- package/src/checks/manual/region-manual.js +34 -14
- package/src/checks/manual/scope-attr-valid-manual.js +2 -7
- package/src/checks/manual/tabindex-manual.js +2 -7
- package/src/core/aria-helpers.js +82 -18
- package/src/core/dom-helpers.js +32 -0
- package/src/core/dom-runner.js +8 -0
- package/src/core/rule-meta.js +19 -0
- package/src/core.js +1781 -404
- package/src/i18n/en.js +2 -0
- package/src/i18n/fr.js +2 -0
- package/src/report.js +482 -0
|
@@ -52,12 +52,42 @@
|
|
|
52
52
|
* focusability) are static, declarative markup facts with no live-DOM/
|
|
53
53
|
* hydration risk, unlike e.g. `aria-checked-state-mismatch`'s DOM-
|
|
54
54
|
* property comparison.
|
|
55
|
+
* - Fixed 2026-07-30: a roleless-but-focusable descendant's message and
|
|
56
|
+
* `data.details.attr` used to always claim "carries tabindex" even when
|
|
57
|
+
* the element had no tabindex attribute at all and was only focusable
|
|
58
|
+
* natively (e.g. an <a href> link). `helpers.getFocusableInfo`'s
|
|
59
|
+
* `mechanism` field ('tabindex' | 'native' | ...) is now used to tell
|
|
60
|
+
* the two apart, with a distinct `nativeFocusable` attr/message for the
|
|
61
|
+
* native case. Found via a real Angular app: a routerLink <a> inside a
|
|
62
|
+
* role="list" was reported as "carries tabindex" though the rendered
|
|
63
|
+
* markup had no such attribute.
|
|
55
64
|
* - Recursion stops at the first non-transparent role boundary, same as
|
|
56
65
|
* that reference engine: a nested container with its own real role (e.g. a
|
|
57
66
|
* <div role="listbox"> inside a menubar) is evaluated as ITS OWN
|
|
58
67
|
* owned-role entry against the outer container (and, separately, gets
|
|
59
68
|
* its own applicability pass as a container in the same rule run) —
|
|
60
69
|
* its descendants are never misattributed to the outer container.
|
|
70
|
+
* - Fixed 2026-07-31: child-role resolution used `ariaHelpers.getExplicitRole`
|
|
71
|
+
* (explicit role="" attribute only), unlike aria-required-children's
|
|
72
|
+
* descendant matching which uses `ariaHelpers.getContainmentRole` (explicit
|
|
73
|
+
* role, falling back to the native-tag map — li/tr/td/th/tbody/ul/ol/
|
|
74
|
+
* table/select/input[type=radio] — see that helper's own header comment).
|
|
75
|
+
* A bare `<li>` with no role="" attribute — the common CSS-reset
|
|
76
|
+
* workaround `<ul role="list"><li>...</li></ul>` that getContainmentRole
|
|
77
|
+
* exists specifically to handle — was therefore read as roleless here,
|
|
78
|
+
* making it structurally transparent: the walk recursed straight through
|
|
79
|
+
* the listitem boundary into its subtree and could report a focusable
|
|
80
|
+
* descendant several levels down as a disallowed owned child of the list,
|
|
81
|
+
* instead of stopping at the (implicit) listitem the way
|
|
82
|
+
* aria-required-children already does. Switched to getContainmentRole so
|
|
83
|
+
* both rules resolve an owned child's role identically. This is a general
|
|
84
|
+
* fix, not list/listitem-specific: it applies to every container role in
|
|
85
|
+
* REQUIRED_OWNED_ROLES whose native-tag counterpart the child map covers
|
|
86
|
+
* (e.g. a bare `<tr>`/`<td>` under a role="table"/"grid"/"row" container
|
|
87
|
+
* with no explicit role="" was subject to the same flattening bug). Found
|
|
88
|
+
* via a real Angular Material-style component library: an `<a routerlink>`
|
|
89
|
+
* several DOM levels inside a bare `<li>` under `<ul role="list">` was
|
|
90
|
+
* reported as an unallowed owned child of the list.
|
|
61
91
|
* - Gated on isAccTreeEligible for the container itself, matching the fix
|
|
62
92
|
* applied to aria-required-children (see that rule's header): the
|
|
63
93
|
* original "not gated" note here just cited that rule's reasoning
|
|
@@ -151,6 +181,11 @@ function runInPage(ctx) {
|
|
|
151
181
|
// entry with role: null, which can never satisfy a required-role set)
|
|
152
182
|
// when it carries a global aria-* attribute or is focusable — matches
|
|
153
183
|
// a widely-used reference engine's own getOwnedRoles exactly (see header comment).
|
|
184
|
+
// kidRole comes from getContainmentRole, not getExplicitRole (see header
|
|
185
|
+
// comment's 2026-07-31 fix): "roleless" here means neither an explicit
|
|
186
|
+
// role="" NOR one of the native containment tags (li, tr, td, ...), so a
|
|
187
|
+
// bare <li>/<tr>/... is a real listitem/row boundary, not a transparent
|
|
188
|
+
// wrapper the walk should pass through.
|
|
154
189
|
function collectOwnedRoles(el, requiredSet, out, depth) {
|
|
155
190
|
if (depth > MAX_DEPTH) return;
|
|
156
191
|
const kids = el.children ? Array.prototype.slice.call(el.children) : [];
|
|
@@ -158,21 +193,27 @@ function runInPage(ctx) {
|
|
|
158
193
|
if (!kid || kid.nodeType !== 1) continue;
|
|
159
194
|
if (!isEligibleAcc(kid)) continue;
|
|
160
195
|
|
|
161
|
-
const kidRole = ariaHelpers.
|
|
196
|
+
const kidRole = ariaHelpers.getContainmentRole(kid);
|
|
162
197
|
const isPresentational = kidRole === 'presentation' || kidRole === 'none';
|
|
163
198
|
const isTransparentGroup = (kidRole === 'group' || kidRole === 'rowgroup') && requiredSet.has(kidRole);
|
|
164
199
|
|
|
165
200
|
if (!kidRole && !isPresentational) {
|
|
166
201
|
const globalAttr = getGlobalAriaAttr(kid);
|
|
167
|
-
let
|
|
202
|
+
let mechanism = 'none';
|
|
168
203
|
try {
|
|
169
204
|
const fi = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo(kid, ctx) : null;
|
|
170
|
-
|
|
205
|
+
mechanism = (fi && fi.focusable && fi.mechanism) || 'none';
|
|
171
206
|
} catch {
|
|
172
|
-
|
|
207
|
+
mechanism = 'none';
|
|
173
208
|
}
|
|
174
|
-
if (globalAttr ||
|
|
175
|
-
|
|
209
|
+
if (globalAttr || mechanism !== 'none') {
|
|
210
|
+
// `mechanism` distinguishes an actual tabindex="" attribute from
|
|
211
|
+
// native focusability (e.g. <a href>, <button>, <input>) — these
|
|
212
|
+
// are different facts and must not be reported as the same
|
|
213
|
+
// "carries tabindex" claim (a native anchor with no tabindex
|
|
214
|
+
// attribute at all is not "carrying tabindex").
|
|
215
|
+
const attr = globalAttr || (mechanism === 'tabindex' ? 'tabindex' : 'nativeFocusable');
|
|
216
|
+
out.push({ el: kid, role: null, attr });
|
|
176
217
|
continue; // real accessible-tree node: stop here, do not recurse further
|
|
177
218
|
}
|
|
178
219
|
}
|
|
@@ -219,12 +260,28 @@ function runInPage(ctx) {
|
|
|
219
260
|
const containerSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
220
261
|
|
|
221
262
|
const isRoleless = !entry.role;
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
263
|
+
const isNativeFocusable = entry.attr === 'nativeFocusable';
|
|
264
|
+
|
|
265
|
+
let summary;
|
|
266
|
+
let hint;
|
|
267
|
+
let summaryKey;
|
|
268
|
+
let hintKey;
|
|
269
|
+
if (isNativeFocusable) {
|
|
270
|
+
summary = `This element has no explicit role but is natively focusable, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
|
|
271
|
+
hint = `Give this element role="presentation"/"none", remove its native focusability (e.g. drop the href/tabindex-granting attribute), or move it outside the ${role} container.`;
|
|
272
|
+
summaryKey = 'ariaProhibitedChildren_summary_fail_native_focusable';
|
|
273
|
+
hintKey = 'ariaProhibitedChildren_hint_fail_native_focusable';
|
|
274
|
+
} else if (isRoleless) {
|
|
275
|
+
summary = `This element has no explicit role but carries ${entry.attr}, making it a real accessible-tree node that is not an allowed owned child of the enclosing role="${role}" container.`;
|
|
276
|
+
hint = `Remove ${entry.attr} (or the role="${role}" container ownership), or give this element role="presentation"/"none" if it isn't meant to be its own accessible-tree node.`;
|
|
277
|
+
summaryKey = 'ariaProhibitedChildren_summary_fail_roleless';
|
|
278
|
+
hintKey = 'ariaProhibitedChildren_hint_fail_roleless';
|
|
279
|
+
} else {
|
|
280
|
+
summary = `This element has role="${entry.role}", which is not an allowed owned child of the enclosing role="${role}" container.`;
|
|
281
|
+
hint = `Remove or change this role so it matches one of the container's allowed owned roles (${requiredOwned.join(', ')}), or move this element outside the ${role} container.`;
|
|
282
|
+
summaryKey = 'ariaProhibitedChildren_summary_fail';
|
|
283
|
+
hintKey = 'ariaProhibitedChildren_hint_fail';
|
|
284
|
+
}
|
|
228
285
|
|
|
229
286
|
occurrences.push({
|
|
230
287
|
selector: stableSelector,
|
|
@@ -232,8 +289,8 @@ function runInPage(ctx) {
|
|
|
232
289
|
summary,
|
|
233
290
|
hint,
|
|
234
291
|
i18n: {
|
|
235
|
-
summaryKey
|
|
236
|
-
hintKey
|
|
292
|
+
summaryKey,
|
|
293
|
+
hintKey,
|
|
237
294
|
params: isRoleless
|
|
238
295
|
? { attr: entry.attr, containerRole: role }
|
|
239
296
|
: { childRole: entry.role, containerRole: role, allowedRoles: requiredOwned.join(', ') }
|
|
@@ -67,6 +67,14 @@ const meta = {
|
|
|
67
67
|
coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
71
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
72
|
+
// scoped this run narrower than the whole document, or when
|
|
73
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
74
|
+
function applicability(ctx) {
|
|
75
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
76
|
+
}
|
|
77
|
+
|
|
70
78
|
function runInPage(ctx) {
|
|
71
79
|
const { document, helpers, rule } = ctx;
|
|
72
80
|
|
|
@@ -187,4 +195,4 @@ function runInPage(ctx) {
|
|
|
187
195
|
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
188
196
|
}
|
|
189
197
|
|
|
190
|
-
module.exports = { id, meta, runInPage };
|
|
198
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -73,6 +73,14 @@ const meta = {
|
|
|
73
73
|
coverage: { facetsBySc: { '1.3.4': ['css-orientation-lock'] } }
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
77
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
78
|
+
// scoped this run narrower than the whole document, or when
|
|
79
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
80
|
+
function applicability(ctx) {
|
|
81
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
function runInPage(ctx) {
|
|
77
85
|
const { document, helpers, rule } = ctx;
|
|
78
86
|
|
|
@@ -203,4 +211,4 @@ function runInPage(ctx) {
|
|
|
203
211
|
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
module.exports = { id, meta, runInPage };
|
|
214
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -45,6 +45,14 @@ const meta = {
|
|
|
45
45
|
coverage: { facetsBySc: { '3.1.1': ['html-xml-lang-mismatch'] } }
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
49
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
50
|
+
// scoped this run narrower than the whole document, or when
|
|
51
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
52
|
+
function applicability(ctx) {
|
|
53
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
function runInPage(ctx) {
|
|
49
57
|
const { document, helpers, rule } = ctx;
|
|
50
58
|
|
|
@@ -88,4 +96,4 @@ function runInPage(ctx) {
|
|
|
88
96
|
return { ruleId: rule.ruleId, outcome: 'fail', severity: rule.defaultSeverity || 'serious', occurrences };
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
module.exports = { id, meta, runInPage };
|
|
99
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -63,6 +63,14 @@ const meta = {
|
|
|
63
63
|
}
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
67
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
68
|
+
// scoped this run narrower than the whole document, or when
|
|
69
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
70
|
+
function applicability(ctx) {
|
|
71
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
function runInPage(ctx) {
|
|
67
75
|
const { document, rule, helpers } = ctx;
|
|
68
76
|
const html = document && document.documentElement;
|
|
@@ -156,4 +164,4 @@ function runInPage(ctx) {
|
|
|
156
164
|
};
|
|
157
165
|
}
|
|
158
166
|
|
|
159
|
-
module.exports = { id, meta, runInPage };
|
|
167
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -58,6 +58,14 @@ const meta = {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
62
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
63
|
+
// scoped this run narrower than the whole document, or when
|
|
64
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
65
|
+
function applicability(ctx) {
|
|
66
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
function runInPage(ctx) {
|
|
62
70
|
const { document, helpers, rule } = ctx;
|
|
63
71
|
|
|
@@ -102,4 +110,4 @@ function runInPage(ctx) {
|
|
|
102
110
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
module.exports = { id, meta, runInPage };
|
|
113
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -49,6 +49,14 @@ const meta = {
|
|
|
49
49
|
coverage: { facetsBySc: { '2.2.1': ['meta-refresh-timing-absent'] } }
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
53
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
54
|
+
// scoped this run narrower than the whole document, or when
|
|
55
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
56
|
+
function applicability(ctx) {
|
|
57
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
function runInPage(ctx) {
|
|
53
61
|
const { document, helpers, rule } = ctx;
|
|
54
62
|
|
|
@@ -104,4 +112,4 @@ function runInPage(ctx) {
|
|
|
104
112
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
module.exports = { id, meta, runInPage };
|
|
115
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -42,6 +42,14 @@ const meta = {
|
|
|
42
42
|
coverage: { facetsBySc: { '1.4.4': ['meta-viewport-zoom-enabled'] } }
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
46
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
47
|
+
// scoped this run narrower than the whole document, or when
|
|
48
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
49
|
+
function applicability(ctx) {
|
|
50
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
function runInPage(ctx) {
|
|
46
54
|
const { document, helpers, rule } = ctx;
|
|
47
55
|
|
|
@@ -115,4 +123,4 @@ function runInPage(ctx) {
|
|
|
115
123
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
116
124
|
}
|
|
117
125
|
|
|
118
|
-
module.exports = { id, meta, runInPage };
|
|
126
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -22,6 +22,14 @@ const meta = {
|
|
|
22
22
|
coverage: { facetsBySc: { '2.4.2': ['page-title-present'] } }
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// This check is inherently whole-document (does the PAGE have this
|
|
26
|
+
// property?), not evaluable per-subtree -- notApplicable when contextSelector
|
|
27
|
+
// scoped this run narrower than the whole document, or when
|
|
28
|
+
// engineOptions.fragment:true was set (see helpers.isWholeDocumentScope).
|
|
29
|
+
function applicability(ctx) {
|
|
30
|
+
return ctx.helpers.isWholeDocumentScope ? ctx.helpers.isWholeDocumentScope() : true;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
function runInPage(ctx) {
|
|
26
34
|
const { document, helpers, rule } = ctx;
|
|
27
35
|
|
|
@@ -83,4 +91,4 @@ function runInPage(ctx) {
|
|
|
83
91
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
84
92
|
}
|
|
85
93
|
|
|
86
|
-
module.exports = { id, meta, runInPage };
|
|
94
|
+
module.exports = { id, meta, runInPage, applicability };
|
|
@@ -112,12 +112,7 @@ function runInPage(ctx) {
|
|
|
112
112
|
return normalizeWs(el.getAttribute && el.getAttribute('title'));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
118
|
-
} catch {
|
|
119
|
-
nodes = [];
|
|
120
|
-
}
|
|
115
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
121
116
|
|
|
122
117
|
const occurrences = [];
|
|
123
118
|
let applicableCount = 0;
|
|
@@ -94,15 +94,11 @@ function runInPage(ctx) {
|
|
|
94
94
|
return '';
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
nodes = document.querySelectorAll('th:not([role]), [role="columnheader"], [role="rowheader"]');
|
|
103
|
-
} catch {
|
|
104
|
-
nodes = [];
|
|
105
|
-
}
|
|
97
|
+
// Matches a widely-used reference engine's own empty-table-header selector exactly: a <th> with
|
|
98
|
+
// no conflicting explicit role, plus any element carrying an explicit
|
|
99
|
+
// columnheader/rowheader role (native or not).
|
|
100
|
+
const selector = 'th:not([role]), [role="columnheader"], [role="rowheader"]';
|
|
101
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
106
102
|
|
|
107
103
|
const occurrences = [];
|
|
108
104
|
let applicableCount = 0;
|
|
@@ -72,12 +72,7 @@ function runInPage(ctx) {
|
|
|
72
72
|
return m ? parseInt(m[1], 10) : 0;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
nodes = document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
78
|
-
} catch {
|
|
79
|
-
nodes = [];
|
|
80
|
-
}
|
|
75
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('h1, h2, h3, h4, h5, h6, [role]') : helpers.queryAll('h1, h2, h3, h4, h5, h6, [role]');
|
|
81
76
|
|
|
82
77
|
const headings = [];
|
|
83
78
|
const seen = new Set();
|
|
@@ -52,7 +52,7 @@ const meta = {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
function runInPage(ctx) {
|
|
55
|
-
const { document, helpers, rule } = ctx;
|
|
55
|
+
const { document, root, helpers, rule } = ctx;
|
|
56
56
|
|
|
57
57
|
// Declared inside runInPage — see scripts/build-core.js header
|
|
58
58
|
// ("runInPage MUST be self-contained").
|
|
@@ -81,25 +81,37 @@ function runInPage(ctx) {
|
|
|
81
81
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
84
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
85
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
86
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
87
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
88
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
89
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
90
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
91
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
92
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
93
|
+
// rule's own former tag-only copy as a false negative.
|
|
94
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
95
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
96
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
97
|
+
: false;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
function getImplicitLandmarkRole(el) {
|
|
97
101
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
98
|
-
if (tag === 'header') return
|
|
99
|
-
if (tag === 'footer') return
|
|
102
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
103
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
100
104
|
if (tag === 'main') return 'main';
|
|
101
105
|
if (tag === 'nav') return 'navigation';
|
|
102
|
-
if (tag === 'aside')
|
|
106
|
+
if (tag === 'aside') {
|
|
107
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
108
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
109
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
110
|
+
// "complementary" when the element has an accessible name, even
|
|
111
|
+
// inside sectioning content); propagated here for consistency.
|
|
112
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
113
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
114
|
+
}
|
|
103
115
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
104
116
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
105
117
|
return '';
|
|
@@ -115,9 +127,13 @@ function runInPage(ctx) {
|
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
function hasLandmarkAncestor(el) {
|
|
130
|
+
const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
|
|
118
131
|
let p = el.parentElement;
|
|
119
132
|
while (p) {
|
|
120
133
|
if (getLandmarkRole(p)) return true;
|
|
134
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
135
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
136
|
+
if (scopeRoots.includes(p)) break;
|
|
121
137
|
p = p.parentElement;
|
|
122
138
|
}
|
|
123
139
|
return false;
|
|
@@ -43,7 +43,7 @@ const meta = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
function runInPage(ctx) {
|
|
46
|
-
const { document, helpers, rule } = ctx;
|
|
46
|
+
const { document, root, helpers, rule } = ctx;
|
|
47
47
|
|
|
48
48
|
function normalizeWs(s) {
|
|
49
49
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
@@ -70,25 +70,37 @@ function runInPage(ctx) {
|
|
|
70
70
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
74
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
75
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
76
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
77
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
78
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
79
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
80
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
81
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
82
|
+
// rule's own former tag-only copy as a false negative.
|
|
83
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
84
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
85
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
86
|
+
: false;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function getImplicitLandmarkRole(el) {
|
|
86
90
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
87
|
-
if (tag === 'header') return
|
|
88
|
-
if (tag === 'footer') return
|
|
91
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
92
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
89
93
|
if (tag === 'main') return 'main';
|
|
90
94
|
if (tag === 'nav') return 'navigation';
|
|
91
|
-
if (tag === 'aside')
|
|
95
|
+
if (tag === 'aside') {
|
|
96
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
97
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
98
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
99
|
+
// "complementary" when the element has an accessible name, even
|
|
100
|
+
// inside sectioning content); propagated here for consistency.
|
|
101
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
102
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
103
|
+
}
|
|
92
104
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
93
105
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
94
106
|
return '';
|
|
@@ -104,9 +116,13 @@ function runInPage(ctx) {
|
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
function hasLandmarkAncestor(el) {
|
|
119
|
+
const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
|
|
107
120
|
let p = el.parentElement;
|
|
108
121
|
while (p) {
|
|
109
122
|
if (getLandmarkRole(p)) return true;
|
|
123
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
124
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
125
|
+
if (scopeRoots.includes(p)) break;
|
|
110
126
|
p = p.parentElement;
|
|
111
127
|
}
|
|
112
128
|
return false;
|
|
@@ -41,7 +41,7 @@ const meta = {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
function runInPage(ctx) {
|
|
44
|
-
const { document, helpers, rule } = ctx;
|
|
44
|
+
const { document, root, helpers, rule } = ctx;
|
|
45
45
|
|
|
46
46
|
function normalizeWs(s) {
|
|
47
47
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
@@ -68,25 +68,37 @@ function runInPage(ctx) {
|
|
|
68
68
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
72
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
73
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
74
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
75
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
76
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
77
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
78
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
79
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
80
|
+
// rule's own former tag-only copy as a false negative.
|
|
81
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
82
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
83
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
84
|
+
: false;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
function getImplicitLandmarkRole(el) {
|
|
84
88
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
85
|
-
if (tag === 'header') return
|
|
86
|
-
if (tag === 'footer') return
|
|
89
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
90
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
87
91
|
if (tag === 'main') return 'main';
|
|
88
92
|
if (tag === 'nav') return 'navigation';
|
|
89
|
-
if (tag === 'aside')
|
|
93
|
+
if (tag === 'aside') {
|
|
94
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
95
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
96
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
97
|
+
// "complementary" when the element has an accessible name, even
|
|
98
|
+
// inside sectioning content); propagated here for consistency.
|
|
99
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
100
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
101
|
+
}
|
|
90
102
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
91
103
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
92
104
|
return '';
|
|
@@ -102,9 +114,13 @@ function runInPage(ctx) {
|
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
function hasLandmarkAncestor(el) {
|
|
117
|
+
const scopeRoots = Array.isArray(root) ? root : (root ? [root] : []);
|
|
105
118
|
let p = el.parentElement;
|
|
106
119
|
while (p) {
|
|
107
120
|
if (getLandmarkRole(p)) return true;
|
|
121
|
+
// Don't climb past the scanned scope -- see aria-helpers.js's
|
|
122
|
+
// hasLandmarkScopingAncestor for the same fix and rationale.
|
|
123
|
+
if (scopeRoots.includes(p)) break;
|
|
108
124
|
p = p.parentElement;
|
|
109
125
|
}
|
|
110
126
|
return false;
|
|
@@ -79,25 +79,37 @@ function runInPage(ctx) {
|
|
|
79
79
|
return raw.split(/\s+/)[0].toLowerCase();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
// Delegates to the shared helpers.hasLandmarkScopingAncestor for the
|
|
83
|
+
// question "does this element sit inside a sectioning-content/<main>
|
|
84
|
+
// ancestor that suppresses its conditional implicit role" — role-aware
|
|
85
|
+
// (an ancestor's bare TAG only counts when it carries no role attribute
|
|
86
|
+
// at all; an explicit role="dialog"-style override no longer suppresses)
|
|
87
|
+
// rather than a local tag-only copy. See that function's header comment
|
|
88
|
+
// in src/core/aria-helpers.js for the full algorithm and the real page
|
|
89
|
+
// (handsontable.com's docs-assistant side panel, an
|
|
90
|
+
// <aside role="dialog"> containing its own <header>) that surfaced this
|
|
91
|
+
// rule's own former tag-only copy as a false negative.
|
|
92
|
+
function hasSectioningAncestor(el, includeMain) {
|
|
93
|
+
return helpers && typeof helpers.hasLandmarkScopingAncestor === 'function'
|
|
94
|
+
? helpers.hasLandmarkScopingAncestor(el, { includeMain })
|
|
95
|
+
: false;
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
function getImplicitLandmarkRole(el) {
|
|
95
99
|
const tag = el.tagName ? el.tagName.toLowerCase() : '';
|
|
96
|
-
if (tag === 'header') return
|
|
97
|
-
if (tag === 'footer') return
|
|
100
|
+
if (tag === 'header') return hasSectioningAncestor(el, true) ? '' : 'banner';
|
|
101
|
+
if (tag === 'footer') return hasSectioningAncestor(el, true) ? '' : 'contentinfo';
|
|
98
102
|
if (tag === 'main') return 'main';
|
|
99
103
|
if (tag === 'nav') return 'navigation';
|
|
100
|
-
if (tag === 'aside')
|
|
104
|
+
if (tag === 'aside') {
|
|
105
|
+
// A named <aside> is never suppressed, even when nested — matches
|
|
106
|
+
// landmark-unique's own verified-against-reference-engine precedent
|
|
107
|
+
// (that engine's real `aside` implicit-role function keeps
|
|
108
|
+
// "complementary" when the element has an accessible name, even
|
|
109
|
+
// inside sectioning content); propagated here for consistency.
|
|
110
|
+
if (!hasSectioningAncestor(el, false)) return 'complementary';
|
|
111
|
+
return getAccessibleLandmarkName(el) ? 'complementary' : '';
|
|
112
|
+
}
|
|
101
113
|
if (tag === 'section') return getAccessibleLandmarkName(el) ? 'region' : '';
|
|
102
114
|
if (tag === 'form') return getAccessibleLandmarkName(el) ? 'form' : '';
|
|
103
115
|
return '';
|