@surea11y/core 1.1.0 → 1.1.2
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 +25 -2
- package/docs/ENGINE_OPTIONS.md +2 -0
- package/docs/LIMITATIONS.md +1 -1
- package/package.json +1 -1
- package/src/checks/automatic/aria-allowed-attr.js +5 -4
- package/src/checks/automatic/aria-allowed-role.js +5 -4
- package/src/checks/automatic/aria-braille-equivalent.js +3 -4
- package/src/checks/automatic/aria-conditional-attr.js +3 -4
- package/src/checks/automatic/aria-deprecated-role.js +2 -3
- package/src/checks/automatic/aria-prohibited-attr.js +5 -4
- package/src/checks/automatic/aria-prohibited-children.js +2 -3
- package/src/checks/automatic/aria-required-attr.js +2 -3
- package/src/checks/automatic/aria-required-children.js +2 -3
- package/src/checks/automatic/aria-required-parent.js +3 -4
- package/src/checks/automatic/aria-roles-valid.js +2 -3
- package/src/checks/automatic/aria-valid-attr-value.js +5 -4
- package/src/checks/automatic/aria-valid-attr.js +5 -4
- package/src/checks/automatic/autocomplete-valid.js +2 -3
- package/src/checks/automatic/avoid-inline-spacing.js +2 -3
- package/src/checks/automatic/binary-control-name-present.js +2 -3
- package/src/checks/automatic/button-name-present.js +14 -4
- package/src/checks/automatic/bypass-blocks-present.js +35 -7
- package/src/checks/automatic/combobox-name-present.js +2 -3
- package/src/checks/automatic/definition-list-children-valid.js +2 -3
- package/src/checks/automatic/deprecated-elements-not-used.js +5 -4
- package/src/checks/automatic/dialog-name-present.js +2 -3
- package/src/checks/automatic/dlitem-parent-valid.js +2 -3
- package/src/checks/automatic/form-control-single-label.js +2 -3
- package/src/checks/automatic/iframe-focusable-content.js +2 -3
- package/src/checks/automatic/iframe-name-present.js +2 -3
- package/src/checks/automatic/iframe-title-unique.js +5 -5
- package/src/checks/automatic/label-in-name.js +7 -8
- package/src/checks/automatic/link-in-text-block.js +2 -3
- package/src/checks/automatic/link-name-present.js +16 -4
- package/src/checks/automatic/list-children-valid.js +2 -3
- package/src/checks/automatic/listbox-name-present.js +2 -3
- package/src/checks/automatic/listitem-parent-valid.js +2 -3
- package/src/checks/automatic/menuitem-name-present.js +2 -3
- package/src/checks/automatic/meter-name-present.js +2 -3
- package/src/checks/automatic/nested-interactive-controls-absent.js +2 -3
- package/src/checks/automatic/option-name-present.js +2 -3
- package/src/checks/automatic/progressbar-name-present.js +2 -3
- package/src/checks/automatic/searchbox-name-present.js +2 -3
- package/src/checks/automatic/server-side-image-map-absent.js +2 -3
- package/src/checks/automatic/slider-name-present.js +2 -3
- package/src/checks/automatic/spinbutton-name-present.js +2 -3
- package/src/checks/automatic/summary-name-present.js +2 -3
- package/src/checks/automatic/tab-name-present.js +2 -3
- package/src/checks/automatic/table-headers-attr-valid.js +5 -4
- package/src/checks/automatic/table-th-has-data-cells.js +5 -4
- package/src/checks/automatic/td-has-header.js +2 -3
- package/src/checks/automatic/textbox-name-present.js +2 -3
- package/src/checks/automatic/tooltip-name-present.js +2 -3
- package/src/checks/automatic/treeitem-name-present.js +2 -3
- package/src/checks/automatic/valid-lang.js +2 -3
- package/src/checks/manual/accesskeys-manual.js +1 -7
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +7 -6
- package/src/checks/manual/aria-text-manual.js +2 -3
- package/src/checks/manual/focus-order-semantics-manual.js +2 -3
- package/src/checks/manual/identical-links-same-purpose-manual.js +2 -3
- package/src/checks/manual/image-redundant-alt-manual.js +2 -3
- package/src/checks/manual/label-title-only-manual.js +2 -3
- package/src/checks/manual/link-name-quality-manual.js +2 -3
- package/src/checks/manual/mouse-only-event-handlers-manual.js +2 -3
- package/src/checks/manual/no-autoplay-audio-manual.js +2 -3
- package/src/checks/manual/p-as-heading-manual.js +2 -3
- package/src/checks/manual/page-has-heading-one-manual.js +31 -2
- package/src/checks/manual/presentation-role-conflict-manual.js +3 -4
- package/src/checks/manual/scrollable-region-focusable-manual.js +2 -3
- package/src/checks/manual/skip-link-manual.js +91 -9
- package/src/checks/manual/table-duplicate-name-manual.js +2 -3
- package/src/checks/manual/table-fake-caption-manual.js +2 -3
- package/src/checks/manual/video-caption-manual.js +2 -3
- package/src/core/dom-helpers.js +89 -3
- package/src/core/dom-runner.js +19 -0
- package/src/core.js +962 -481
- package/src/i18n/en.js +4 -2
- package/src/i18n/fr.js +4 -2
|
@@ -23,11 +23,10 @@ const meta = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
function runInPage(ctx) {
|
|
26
|
-
const { document,
|
|
26
|
+
const { document, helpers, rule } = ctx;
|
|
27
27
|
const getEligibilityInfo = helpers && typeof helpers.getEligibilityInfo === 'function'
|
|
28
28
|
? helpers.getEligibilityInfo
|
|
29
29
|
: null;
|
|
30
|
-
const safeRoot = root || document;
|
|
31
30
|
|
|
32
31
|
|
|
33
32
|
function normalizeWs(s) {
|
|
@@ -91,7 +90,7 @@ function runInPage(ctx) {
|
|
|
91
90
|
let applicableCount = 0;
|
|
92
91
|
|
|
93
92
|
const selector = "[role=\"treeitem\"]";
|
|
94
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
93
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
95
94
|
|
|
96
95
|
|
|
97
96
|
function hasName(el) {
|
|
@@ -47,12 +47,11 @@ const meta = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
function runInPage(ctx) {
|
|
50
|
-
const { document,
|
|
51
|
-
const safeRoot = root || document;
|
|
50
|
+
const { document, helpers, rule } = ctx;
|
|
52
51
|
|
|
53
52
|
const BCP47_RE = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
|
|
54
53
|
|
|
55
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]'
|
|
54
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[lang]') : helpers.queryAll('[lang]');
|
|
56
55
|
|
|
57
56
|
const occurrences = [];
|
|
58
57
|
let applicableCount = 0;
|
|
@@ -41,13 +41,7 @@ const meta = {
|
|
|
41
41
|
|
|
42
42
|
function runInPage(ctx) {
|
|
43
43
|
const { document, helpers, rule } = ctx;
|
|
44
|
-
|
|
45
|
-
let nodes = [];
|
|
46
|
-
try {
|
|
47
|
-
nodes = document.querySelectorAll('[accesskey]');
|
|
48
|
-
} catch {
|
|
49
|
-
nodes = [];
|
|
50
|
-
}
|
|
44
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[accesskey]') : helpers.queryAll('[accesskey]');
|
|
51
45
|
|
|
52
46
|
const groups = new Map(); // normalized key -> elements[]
|
|
53
47
|
for (const el of nodes) {
|
|
@@ -42,8 +42,10 @@
|
|
|
42
42
|
* - Any `aria-checked` value other than "true"/"false"/"mixed" (checkbox)
|
|
43
43
|
* or "true"/"false" (radio) is treated as equivalent to "false" — also
|
|
44
44
|
* matches that reference engine's own normalization exactly.
|
|
45
|
-
* - Not gated on isAccTreeEligible: whether the accessible state
|
|
46
|
-
* is a markup-correctness property
|
|
45
|
+
* - Not rule-gated on isAccTreeEligible: whether the accessible state
|
|
46
|
+
* matches is still a markup-correctness property, while engine-level
|
|
47
|
+
* hidden-subtree filtering applies unless engineOptions.includeHiddenElements
|
|
48
|
+
* is true.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
51
|
const id = 'aria-checked-state-mismatch';
|
|
@@ -69,14 +71,13 @@ const meta = {
|
|
|
69
71
|
};
|
|
70
72
|
|
|
71
73
|
function runInPage(ctx) {
|
|
72
|
-
const { document,
|
|
73
|
-
const safeRoot = root || document;
|
|
74
|
+
const { document, helpers, rule } = ctx;
|
|
74
75
|
|
|
75
76
|
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
76
77
|
|
|
77
78
|
const nodes = helpers.queryAllSmart
|
|
78
|
-
? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]'
|
|
79
|
-
: helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]'
|
|
79
|
+
? helpers.queryAllSmart('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]')
|
|
80
|
+
: helpers.queryAll('input[type="checkbox"][aria-checked], input[type="radio"][aria-checked]');
|
|
80
81
|
|
|
81
82
|
const occurrences = [];
|
|
82
83
|
let applicableCount = 0;
|
|
@@ -46,13 +46,12 @@ const meta = {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
function runInPage(ctx) {
|
|
49
|
-
const { document,
|
|
50
|
-
const safeRoot = root || document;
|
|
49
|
+
const { document, helpers, rule } = ctx;
|
|
51
50
|
|
|
52
51
|
const FOCUSABLE_DESCENDANT_SELECTOR =
|
|
53
52
|
'a[href], button, input, select, textarea, [tabindex], iframe, [contenteditable]:not([contenteditable="false"])';
|
|
54
53
|
|
|
55
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]'
|
|
54
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[role="text"]') : helpers.queryAll('[role="text"]');
|
|
56
55
|
|
|
57
56
|
const occurrences = [];
|
|
58
57
|
let applicableCount = 0;
|
|
@@ -48,8 +48,7 @@ const meta = {
|
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
function runInPage(ctx) {
|
|
51
|
-
const { document,
|
|
52
|
-
const safeRoot = root || document;
|
|
51
|
+
const { document, helpers, rule } = ctx;
|
|
53
52
|
|
|
54
53
|
const NON_INTERACTIVE_ROLES = new Set([
|
|
55
54
|
'generic', 'group', 'text', 'presentation', 'none', 'img', 'heading',
|
|
@@ -59,7 +58,7 @@ function runInPage(ctx) {
|
|
|
59
58
|
'definition', 'paragraph', 'caption', 'status', 'alert', 'log', 'tooltip'
|
|
60
59
|
]);
|
|
61
60
|
|
|
62
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]'
|
|
61
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('[tabindex][role]') : helpers.queryAll('[tabindex][role]');
|
|
63
62
|
|
|
64
63
|
const occurrences = [];
|
|
65
64
|
let applicableCount = 0;
|
|
@@ -52,14 +52,13 @@ const meta = {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
function runInPage(ctx) {
|
|
55
|
-
const { document,
|
|
56
|
-
const safeRoot = root || document;
|
|
55
|
+
const { document, helpers, rule } = ctx;
|
|
57
56
|
|
|
58
57
|
function normName(s) {
|
|
59
58
|
return (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim().toLowerCase();
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]'
|
|
61
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
|
|
63
62
|
|
|
64
63
|
const groups = new Map(); // normName -> [{ el, href }]
|
|
65
64
|
let applicableCount = 0;
|
|
@@ -47,8 +47,7 @@ const meta = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
function runInPage(ctx) {
|
|
50
|
-
const { document,
|
|
51
|
-
const safeRoot = root || document;
|
|
50
|
+
const { document, helpers, rule } = ctx;
|
|
52
51
|
|
|
53
52
|
function normalizeWs(s) {
|
|
54
53
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
@@ -67,7 +66,7 @@ function runInPage(ctx) {
|
|
|
67
66
|
return normalizeWs(text);
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]'
|
|
69
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('img[alt]') : helpers.queryAll('img[alt]');
|
|
71
70
|
|
|
72
71
|
const occurrences = [];
|
|
73
72
|
let applicableCount = 0;
|
|
@@ -45,11 +45,10 @@ const meta = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
function runInPage(ctx) {
|
|
48
|
-
const { document,
|
|
49
|
-
const safeRoot = root || document;
|
|
48
|
+
const { document, helpers, rule } = ctx;
|
|
50
49
|
|
|
51
50
|
const selector = 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]),select,textarea';
|
|
52
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
51
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
53
52
|
|
|
54
53
|
const labelsByFor = new Map();
|
|
55
54
|
const allLabels = document.getElementsByTagName ? document.getElementsByTagName('label') : [];
|
|
@@ -60,8 +60,7 @@ const meta = {
|
|
|
60
60
|
};
|
|
61
61
|
|
|
62
62
|
function runInPage(ctx) {
|
|
63
|
-
const { document,
|
|
64
|
-
const safeRoot = root || document;
|
|
63
|
+
const { document, helpers, rule } = ctx;
|
|
65
64
|
|
|
66
65
|
const GENERIC_LINK_TEXT = new Set([
|
|
67
66
|
'click here', 'here', 'click', 'more', 'more info', 'more information',
|
|
@@ -80,7 +79,7 @@ function runInPage(ctx) {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
const selector = 'a[href]';
|
|
83
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
82
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
84
83
|
|
|
85
84
|
const occurrences = [];
|
|
86
85
|
let applicableCount = 0;
|
|
@@ -67,8 +67,7 @@ const meta = {
|
|
|
67
67
|
};
|
|
68
68
|
|
|
69
69
|
function runInPage(ctx) {
|
|
70
|
-
const { document,
|
|
71
|
-
const safeRoot = root || document;
|
|
70
|
+
const { document, helpers, rule } = ctx;
|
|
72
71
|
|
|
73
72
|
const MOUSE_ONLY_ATTRS = [
|
|
74
73
|
'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup',
|
|
@@ -79,7 +78,7 @@ function runInPage(ctx) {
|
|
|
79
78
|
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
80
79
|
|
|
81
80
|
const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
|
|
82
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector
|
|
81
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector) : helpers.queryAll(selector);
|
|
83
82
|
|
|
84
83
|
const occurrences = [];
|
|
85
84
|
let applicableCount = 0;
|
|
@@ -58,10 +58,9 @@ const meta = {
|
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
function runInPage(ctx) {
|
|
61
|
-
const { document,
|
|
62
|
-
const safeRoot = root || document;
|
|
61
|
+
const { document, helpers, rule } = ctx;
|
|
63
62
|
|
|
64
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]'
|
|
63
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('audio[autoplay], video[autoplay]') : helpers.queryAll('audio[autoplay], video[autoplay]');
|
|
65
64
|
|
|
66
65
|
const occurrences = [];
|
|
67
66
|
let applicableCount = 0;
|
|
@@ -53,8 +53,7 @@ const meta = {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
function runInPage(ctx) {
|
|
56
|
-
const { document,
|
|
57
|
-
const safeRoot = root || document;
|
|
56
|
+
const { document, helpers, rule } = ctx;
|
|
58
57
|
|
|
59
58
|
const MAX_HEADING_LIKE_CHARS = 120;
|
|
60
59
|
const MIN_FONT_SIZE_PX = 18;
|
|
@@ -106,7 +105,7 @@ function runInPage(ctx) {
|
|
|
106
105
|
return Number.isFinite(px) ? px : 0;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p'
|
|
108
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('p') : helpers.queryAll('p');
|
|
110
109
|
|
|
111
110
|
const occurrences = [];
|
|
112
111
|
let applicableCount = 0;
|
|
@@ -19,6 +19,18 @@
|
|
|
19
19
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
20
20
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
21
21
|
* header comment for the shared rationale/precedent.
|
|
22
|
+
* - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
|
|
23
|
+
* display:none/inert elements don't count as "the page has a heading
|
|
24
|
+
* one"), matching `landmark-one-main`'s own precedent. Found via a real
|
|
25
|
+
* page (CDC's flu page, 2026-07-30): its only `<h1>` sits inside a
|
|
26
|
+
* `display:none` ancestor — genuinely unreachable by sighted and screen
|
|
27
|
+
* reader users alike — and a raw `document.querySelectorAll` credited it
|
|
28
|
+
* anyway, reporting `notApplicable` where axe-core correctly fails. This
|
|
29
|
+
* does NOT regress purely-visually-clipped-but-AT-exposed headings (e.g.
|
|
30
|
+
* eBay's homepage `<h1>` hidden via clip-path/off-screen positioning,
|
|
31
|
+
* `visibility:visible`, no `aria-hidden`) — `isAccTreeEligible` only
|
|
32
|
+
* excludes elements actually removed from the accessibility tree, not
|
|
33
|
+
* ones merely clipped from the visual viewport.
|
|
22
34
|
*/
|
|
23
35
|
|
|
24
36
|
const id = 'page-has-heading-one';
|
|
@@ -70,14 +82,31 @@ function runInPage(ctx) {
|
|
|
70
82
|
return tag === 'h1';
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
const isAccTreeEligible = helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
86
|
+
|
|
87
|
+
function isExposedToAt(el) {
|
|
88
|
+
if (!isAccTreeEligible) return true;
|
|
89
|
+
try {
|
|
90
|
+
const r = isAccTreeEligible(el, ctx);
|
|
91
|
+
if (typeof r === 'boolean') return r;
|
|
92
|
+
return !!(r && r.eligible);
|
|
93
|
+
} catch {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// queryAllSmart (shadow-DOM-aware) instead of plain document.querySelectorAll -- see
|
|
99
|
+
// landmark-one-main-manual.js's identical precedent.
|
|
73
100
|
let nodes = [];
|
|
74
101
|
try {
|
|
75
|
-
nodes =
|
|
102
|
+
nodes = helpers && typeof helpers.queryAllSmart === 'function'
|
|
103
|
+
? helpers.queryAllSmart('h1, [role]')
|
|
104
|
+
: document.querySelectorAll('h1, [role]');
|
|
76
105
|
} catch {
|
|
77
106
|
nodes = [];
|
|
78
107
|
}
|
|
79
108
|
|
|
80
|
-
const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el));
|
|
109
|
+
const hasH1 = Array.from(nodes).some((el) => el && isLevelOneHeading(el) && isExposedToAt(el));
|
|
81
110
|
|
|
82
111
|
if (hasH1) {
|
|
83
112
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
@@ -73,8 +73,7 @@ const meta = {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
function runInPage(ctx) {
|
|
76
|
-
const { document,
|
|
77
|
-
const safeRoot = root || document;
|
|
76
|
+
const { document, helpers, rule } = ctx;
|
|
78
77
|
|
|
79
78
|
// The full set of ARIA attributes marked `global: true` per the WAI-ARIA
|
|
80
79
|
// spec (confirmed against a widely-used reference engine's own
|
|
@@ -91,8 +90,8 @@ function runInPage(ctx) {
|
|
|
91
90
|
const getFocusableInfo = helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
92
91
|
|
|
93
92
|
const nodes = helpers.queryAllSmart
|
|
94
|
-
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]'
|
|
95
|
-
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]'
|
|
93
|
+
? helpers.queryAllSmart('[role="presentation"], [role="none"], img[alt=""]')
|
|
94
|
+
: helpers.queryAll('[role="presentation"], [role="none"], img[alt=""]');
|
|
96
95
|
|
|
97
96
|
const occurrences = [];
|
|
98
97
|
let applicableCount = 0;
|
|
@@ -64,8 +64,7 @@ const meta = {
|
|
|
64
64
|
};
|
|
65
65
|
|
|
66
66
|
function runInPage(ctx) {
|
|
67
|
-
const { document,
|
|
68
|
-
const safeRoot = root || document;
|
|
67
|
+
const { document, helpers, rule } = ctx;
|
|
69
68
|
|
|
70
69
|
function safeComputedStyle(el) {
|
|
71
70
|
try {
|
|
@@ -110,7 +109,7 @@ function runInPage(ctx) {
|
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
const CANDIDATE_SELECTOR = 'div, section, article, aside, main, nav, pre, table, blockquote, ul, ol, textarea';
|
|
113
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR
|
|
112
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(CANDIDATE_SELECTOR) : helpers.queryAll(CANDIDATE_SELECTOR);
|
|
114
113
|
|
|
115
114
|
const occurrences = [];
|
|
116
115
|
let applicableCount = 0;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @check skip-link
|
|
5
5
|
* @atomic true
|
|
6
|
-
* @summary A "skip" link must resolve to a real,
|
|
6
|
+
* @summary A "skip" link must resolve to a real, usable target
|
|
7
7
|
* @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
|
|
8
8
|
* @applicability
|
|
9
9
|
* Applies to <a href="#fragment"> elements whose accessible name
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* implementation-notes) — this only widens the known-convention list.
|
|
19
19
|
* @expectation
|
|
20
20
|
* The link's fragment resolves to a real element in the document
|
|
21
|
-
* (via a matching id, or a legacy <a name="...">)
|
|
22
|
-
*
|
|
21
|
+
* (via a matching id, or a legacy <a name="...">), and that target is
|
|
22
|
+
* currently usable (not hidden from the accessibility tree; and, when
|
|
23
|
+
* browser geometry is available, not zero-area/no-rects). A skip link
|
|
24
|
+
* whose target is missing or effectively unusable does not provide a
|
|
25
|
+
* reliable bypass destination.
|
|
23
26
|
* @implementation-notes
|
|
24
27
|
* - Not WCAG-normative — authored as an advisory, cantTell-capped
|
|
25
28
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
@@ -33,8 +36,8 @@
|
|
|
33
36
|
const id = 'skip-link';
|
|
34
37
|
|
|
35
38
|
const meta = {
|
|
36
|
-
title: 'Skip link must have a resolvable target',
|
|
37
|
-
description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real element in the document.',
|
|
39
|
+
title: 'Skip link must have a resolvable, usable target',
|
|
40
|
+
description: 'Checks that a "skip to ..." link\'s href fragment resolves to a real, currently usable element in the document.',
|
|
38
41
|
i18n: {
|
|
39
42
|
titleKey: 'skipLink_title',
|
|
40
43
|
descriptionKey: 'skipLink_description'
|
|
@@ -51,8 +54,7 @@ const meta = {
|
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
57
|
+
const { document, helpers, rule } = ctx;
|
|
56
58
|
|
|
57
59
|
function normalizeWs(s) {
|
|
58
60
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
@@ -79,7 +81,31 @@ function runInPage(ctx) {
|
|
|
79
81
|
return normalizeWs(el.textContent);
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
function hasReliableGeometrySupport() {
|
|
85
|
+
const probe = document.documentElement || document.body || null;
|
|
86
|
+
if (!probe || !probe.getClientRects || !probe.getBoundingClientRect) return false;
|
|
87
|
+
try {
|
|
88
|
+
const rects = probe.getClientRects();
|
|
89
|
+
const rectCount = rects ? rects.length : 0;
|
|
90
|
+
const r = probe.getBoundingClientRect();
|
|
91
|
+
const w = r && Number.isFinite(r.width) ? r.width : 0;
|
|
92
|
+
const h = r && Number.isFinite(r.height) ? r.height : 0;
|
|
93
|
+
return rectCount > 0 && (w > 0 || h > 0);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toEligibility(info) {
|
|
100
|
+
return {
|
|
101
|
+
eligible: !!(info && info.eligible),
|
|
102
|
+
reasons: info && Array.isArray(info.reasons) ? info.reasons.slice(0) : []
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const geometrySupported = hasReliableGeometrySupport();
|
|
107
|
+
|
|
108
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('a[href]') : helpers.queryAll('a[href]');
|
|
83
109
|
|
|
84
110
|
const occurrences = [];
|
|
85
111
|
let applicableCount = 0;
|
|
@@ -117,7 +143,63 @@ function runInPage(ctx) {
|
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
if (target)
|
|
146
|
+
if (target) {
|
|
147
|
+
const accEligibility = toEligibility(
|
|
148
|
+
helpers.getEligibilityInfo
|
|
149
|
+
? helpers.getEligibilityInfo(target, ctx, { targetSet: 'acc' })
|
|
150
|
+
: (helpers.isAccTreeEligible ? helpers.isAccTreeEligible(target, ctx) : { eligible: true, reasons: [] })
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
let geometryEligibility = null;
|
|
154
|
+
let geometryReasonCode = null;
|
|
155
|
+
if (geometrySupported && helpers.isDomVisibleEligible) {
|
|
156
|
+
geometryEligibility = toEligibility(
|
|
157
|
+
helpers.isDomVisibleEligible(target, ctx, { visibilityMode: 'styleAndGeometry', ignoreOpacity: true })
|
|
158
|
+
);
|
|
159
|
+
if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('noClientRects')) {
|
|
160
|
+
geometryReasonCode = 'NO_CLIENT_RECTS';
|
|
161
|
+
} else if (!geometryEligibility.eligible && geometryEligibility.reasons.includes('zeroArea')) {
|
|
162
|
+
geometryReasonCode = 'ZERO_AREA_TARGET';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const unusableByAcc = !accEligibility.eligible;
|
|
167
|
+
const unusableByGeometry = !!geometryReasonCode;
|
|
168
|
+
if (!unusableByAcc && !unusableByGeometry) continue;
|
|
169
|
+
|
|
170
|
+
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
171
|
+
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
172
|
+
|
|
173
|
+
occurrences.push({
|
|
174
|
+
selector: stableSelector,
|
|
175
|
+
html,
|
|
176
|
+
summary: 'This skip link points to a target that exists but is not currently usable.',
|
|
177
|
+
hint: 'Point this skip link to a target that is exposed and usable as a navigation destination.',
|
|
178
|
+
i18n: {
|
|
179
|
+
summaryKey: 'skipLink_summary_unusableTarget_cantTell',
|
|
180
|
+
hintKey: 'skipLink_hint_unusableTarget_cantTell',
|
|
181
|
+
params: { href }
|
|
182
|
+
},
|
|
183
|
+
data: {
|
|
184
|
+
details: {
|
|
185
|
+
reasonCode: 'SKIP_LINK_TARGET_UNUSABLE',
|
|
186
|
+
href,
|
|
187
|
+
unusableReasonCode: unusableByAcc ? 'ACC_TREE_INELIGIBLE' : geometryReasonCode,
|
|
188
|
+
targetSelector: helpers.buildSelector ? helpers.buildSelector(target) : null,
|
|
189
|
+
geometryCheckEnabled: geometrySupported
|
|
190
|
+
},
|
|
191
|
+
visibilityFilter: {
|
|
192
|
+
targetSet: 'acc',
|
|
193
|
+
accEligible: accEligibility.eligible,
|
|
194
|
+
reasons: accEligibility.reasons
|
|
195
|
+
},
|
|
196
|
+
targetGeometry: geometryEligibility
|
|
197
|
+
? { eligible: geometryEligibility.eligible, reasons: geometryEligibility.reasons }
|
|
198
|
+
: { eligible: null, reasons: [] }
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
121
203
|
|
|
122
204
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
123
205
|
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
|
|
@@ -43,14 +43,13 @@ const meta = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
function runInPage(ctx) {
|
|
46
|
-
const { document,
|
|
47
|
-
const safeRoot = root || document;
|
|
46
|
+
const { document, helpers, rule } = ctx;
|
|
48
47
|
|
|
49
48
|
function normalizeWs(s) {
|
|
50
49
|
return String(s || '').replace(/\s+/g, ' ').trim();
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]'
|
|
52
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table[summary]') : helpers.queryAll('table[summary]');
|
|
54
53
|
|
|
55
54
|
const occurrences = [];
|
|
56
55
|
let applicableCount = 0;
|
|
@@ -51,12 +51,11 @@ const meta = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
54
|
+
const { document, helpers, rule } = ctx;
|
|
56
55
|
|
|
57
56
|
function trim(v) { return (v == null ? '' : String(v)).trim(); }
|
|
58
57
|
|
|
59
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table'
|
|
58
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
|
|
60
59
|
|
|
61
60
|
const occurrences = [];
|
|
62
61
|
let applicableCount = 0;
|
|
@@ -51,10 +51,9 @@ const meta = {
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
function runInPage(ctx) {
|
|
54
|
-
const { document,
|
|
55
|
-
const safeRoot = root || document;
|
|
54
|
+
const { document, helpers, rule } = ctx;
|
|
56
55
|
|
|
57
|
-
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video'
|
|
56
|
+
const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('video') : helpers.queryAll('video');
|
|
58
57
|
|
|
59
58
|
const occurrences = [];
|
|
60
59
|
let applicableCount = 0;
|
package/src/core/dom-helpers.js
CHANGED
|
@@ -115,6 +115,10 @@ function createDomHelpers(opts) {
|
|
|
115
115
|
})();
|
|
116
116
|
// Default on: opt OUT with `includeShadowDom: false`, not opt in.
|
|
117
117
|
const includeShadowDom = !(opts && opts.includeShadowDom === false);
|
|
118
|
+
// Default off: by default, helper queries skip structurally/CSS-hidden
|
|
119
|
+
// subtrees (display:none, [hidden], closed <details>, etc.). Callers can
|
|
120
|
+
// opt out with includeHiddenElements:true.
|
|
121
|
+
const includeHiddenElements = !!(opts && opts.includeHiddenElements === true);
|
|
118
122
|
const excludeSelectors = Array.isArray(opts && opts.excludeSelectors) ? opts.excludeSelectors : [];
|
|
119
123
|
|
|
120
124
|
// Rule-scoped excludes (engineOptions.rules[ruleId].excludeSelectors), set
|
|
@@ -1081,8 +1085,55 @@ function createDomHelpers(opts) {
|
|
|
1081
1085
|
return results;
|
|
1082
1086
|
}
|
|
1083
1087
|
|
|
1088
|
+
const HARD_HIDDEN_REASONS = new Set([
|
|
1089
|
+
'displayNone',
|
|
1090
|
+
'hiddenAttr',
|
|
1091
|
+
'detailsClosed',
|
|
1092
|
+
'templateContent',
|
|
1093
|
+
'nonRenderedElement',
|
|
1094
|
+
'inputHidden',
|
|
1095
|
+
'visibilityHidden',
|
|
1096
|
+
'contentVisibilityHidden'
|
|
1097
|
+
]);
|
|
1098
|
+
|
|
1084
1099
|
function queryAllSmart(sel) {
|
|
1085
|
-
|
|
1100
|
+
let list = includeShadowDom ? queryAllDeep(sel) : queryAll(sel);
|
|
1101
|
+
|
|
1102
|
+
// Global hidden-content policy: skip nodes that are fully excluded from
|
|
1103
|
+
// rendered visibility by default (unless includeHiddenElements:true).
|
|
1104
|
+
if (!includeHiddenElements) {
|
|
1105
|
+
list = list.filter((el) => {
|
|
1106
|
+
try {
|
|
1107
|
+
const vis = isAccTreeEligible(el);
|
|
1108
|
+
if (!vis || vis.eligible !== false) return true;
|
|
1109
|
+
const reasons = Array.isArray(vis.reasons) ? vis.reasons : [];
|
|
1110
|
+
for (const r of reasons) {
|
|
1111
|
+
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// `isAccTreeEligible` can short-circuit on an inert ancestor
|
|
1115
|
+
// before it reaches an outer hard-hidden ancestor (e.g.
|
|
1116
|
+
// display:none wrapper). In that case the node is still
|
|
1117
|
+
// structurally hidden and should be excluded by the default
|
|
1118
|
+
// hidden-content policy.
|
|
1119
|
+
if (reasons.includes('inert')) {
|
|
1120
|
+
const domVis = isDomVisibleEligible(el, null, {
|
|
1121
|
+
visibilityMode: 'styleOnly',
|
|
1122
|
+
disableGeometry: true,
|
|
1123
|
+
ignoreOpacity: true
|
|
1124
|
+
});
|
|
1125
|
+
const domReasons = Array.isArray(domVis && domVis.reasons) ? domVis.reasons : [];
|
|
1126
|
+
for (const r of domReasons) {
|
|
1127
|
+
if (HARD_HIDDEN_REASONS.has(r)) return false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return true;
|
|
1131
|
+
} catch {
|
|
1132
|
+
return true;
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1086
1137
|
return __getEffectiveExcludeSelectors().length ? list.filter((el) => !isExcluded(el)) : list;
|
|
1087
1138
|
}
|
|
1088
1139
|
|
|
@@ -3652,6 +3703,36 @@ function createDomHelpers(opts) {
|
|
|
3652
3703
|
let node = el;
|
|
3653
3704
|
let safety = 0;
|
|
3654
3705
|
|
|
3706
|
+
// Only apply the "stop climbing once we reach a contextSelector-
|
|
3707
|
+
// matched root" shortcut when there's a single (or no) matched
|
|
3708
|
+
// root -- resolveContextRoots() falls back to `[documentElement]`
|
|
3709
|
+
// when no contextSelector is given, so this is the overwhelmingly
|
|
3710
|
+
// common case and behaves exactly as before.
|
|
3711
|
+
//
|
|
3712
|
+
// With MULTIPLE matched roots (multi-region contextSelector
|
|
3713
|
+
// scans), stopping there without recording anything about which
|
|
3714
|
+
// root produced an ambiguous, non-unique selector string for two
|
|
3715
|
+
// structurally-identical regions -- a real, confirmed bug (found
|
|
3716
|
+
// 2026-07-29 via the cross-engine comparisons project): two
|
|
3717
|
+
// wrapper <div>s, each containing two identical ".widget"
|
|
3718
|
+
// sections scanned via `contextSelector: '.widget'`, produced the
|
|
3719
|
+
// *same* selector string ("section:nth-of-type(1) > div > div >
|
|
3720
|
+
// button") for the equivalent button in each wrapper --
|
|
3721
|
+
// resolving to 2 elements instead of 1 when queried, and pointing
|
|
3722
|
+
// at the wrong one for at least one of the two occurrences. The
|
|
3723
|
+
// existing `el.matches(candidate)` safety check below couldn't
|
|
3724
|
+
// catch this: it only verifies THIS element matches the string,
|
|
3725
|
+
// never that the string is unique document-wide.
|
|
3726
|
+
//
|
|
3727
|
+
// Fix: when multiple roots are in play, don't stop early --
|
|
3728
|
+
// keep climbing (same as the always-correct no-contextSelector
|
|
3729
|
+
// path) until finding a genuinely unique anchor or reaching the
|
|
3730
|
+
// true document root, which is always singular. That restores
|
|
3731
|
+
// the invariant the final safety-check comment below relies on,
|
|
3732
|
+
// rather than needing a separate (more expensive) document-wide
|
|
3733
|
+
// uniqueness re-check.
|
|
3734
|
+
const stopAtMatchedRoot = roots.length <= 1;
|
|
3735
|
+
|
|
3655
3736
|
while (node && node.nodeType === 1 && safety++ < 20) {
|
|
3656
3737
|
let anchor = null;
|
|
3657
3738
|
|
|
@@ -3691,7 +3772,7 @@ function createDomHelpers(opts) {
|
|
|
3691
3772
|
parts.unshift(nthOfType(node));
|
|
3692
3773
|
}
|
|
3693
3774
|
|
|
3694
|
-
if (!node.parentElement || roots.includes(node)) break;
|
|
3775
|
+
if (!node.parentElement || (stopAtMatchedRoot && roots.includes(node))) break;
|
|
3695
3776
|
node = node.parentElement;
|
|
3696
3777
|
}
|
|
3697
3778
|
|
|
@@ -3710,7 +3791,12 @@ function createDomHelpers(opts) {
|
|
|
3710
3791
|
// position relative to its own parent via `>` (child, not
|
|
3711
3792
|
// descendant) combinators, so a correctly-matching chain can
|
|
3712
3793
|
// only resolve to one element short of a malformed document
|
|
3713
|
-
// (e.g. two <html> roots)
|
|
3794
|
+
// (e.g. two <html> roots) -- true as long as the walk above
|
|
3795
|
+
// never stops short of a genuinely unique anchor/root, which is
|
|
3796
|
+
// exactly what `stopAtMatchedRoot` now guarantees (see its own
|
|
3797
|
+
// comment above; a multi-root contextSelector scan stopping
|
|
3798
|
+
// early used to violate this invariant silently). Re-deriving
|
|
3799
|
+
// that guarantee via a
|
|
3714
3800
|
// document-wide :nth-of-type scan was measured to cost O(total
|
|
3715
3801
|
// same-tag siblings) per call — pathological on pages with many
|
|
3716
3802
|
// flat, unidentified siblings (e.g. hundreds of unlabeled
|