@surea11y/core 1.4.0 → 1.4.1
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 +41 -0
- package/bin/surea11y-core.js +0 -0
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/package.json +3 -1
- package/src/checks/automatic/aria-allowed-attr.js +648 -83
- package/src/checks/automatic/aria-deprecated-role.js +105 -37
- package/src/checks/automatic/aria-roles-valid.js +31 -6
- package/src/checks/automatic/autocomplete-valid.js +37 -1
- package/src/checks/automatic/avoid-inline-spacing.js +179 -19
- package/src/checks/automatic/binary-control-name-present.js +11 -3
- package/src/checks/automatic/button-name-present.js +50 -17
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +8 -1
- package/src/checks/automatic/dialog-name-present.js +8 -1
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +107 -39
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +82 -8
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-name-present.js +50 -17
- package/src/checks/automatic/listbox-name-present.js +8 -1
- package/src/checks/automatic/menuitem-name-present.js +8 -1
- package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
- package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
- package/src/checks/automatic/meter-name-present.js +8 -1
- package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +8 -1
- package/src/checks/automatic/progressbar-name-present.js +8 -1
- package/src/checks/automatic/searchbox-name-present.js +8 -1
- package/src/checks/automatic/slider-name-present.js +11 -2
- package/src/checks/automatic/spinbutton-name-present.js +8 -1
- package/src/checks/automatic/summary-name-present.js +8 -1
- package/src/checks/automatic/tab-name-present.js +8 -1
- package/src/checks/automatic/table-th-has-data-cells.js +67 -6
- package/src/checks/automatic/textbox-name-present.js +8 -1
- package/src/checks/automatic/tooltip-name-present.js +8 -1
- package/src/checks/automatic/treeitem-name-present.js +8 -1
- package/src/checks/automatic/valid-lang.js +16 -3
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
- package/src/core.js +4853 -964
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +2256 -433
package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js}
RENAMED
|
@@ -21,30 +21,57 @@
|
|
|
21
21
|
* before it (nav, header, repeated blocks) in one step;
|
|
22
22
|
* (b) a working same-page anchor link — technique G1/G123: an
|
|
23
23
|
* <a href="#id"> (or legacy <a name="id">) whose target resolves to
|
|
24
|
-
* a real element
|
|
25
|
-
* required to be positioned before a <nav> or
|
|
26
|
-
* order-first — see implementation notes;
|
|
24
|
+
* a real element in the link's own tree (light DOM or the same shadow
|
|
25
|
+
* root). Deliberately NOT required to be positioned before a <nav> or
|
|
26
|
+
* be keyboard-focus-order-first — see implementation notes;
|
|
27
27
|
* (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
|
|
28
28
|
* H69: heading navigation is itself a standards-recognized bypass
|
|
29
29
|
* mechanism (e.g. a screen reader's "jump by heading" command).
|
|
30
30
|
* @implementation-notes
|
|
31
|
+
* - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
|
|
32
|
+
* `fail`). When a recognized mechanism is found the page has nothing to
|
|
33
|
+
* review here → `notApplicable` (matching page-has-heading-one-manual /
|
|
34
|
+
* skip-link-manual's "nothing to flag" convention). When none is found we
|
|
35
|
+
* return `cantTell` — "we could not detect a bypass mechanism, please
|
|
36
|
+
* verify" — rather than a hard `fail`. The absence of a *detectable*
|
|
37
|
+
* mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
|
|
38
|
+
* several reasons the engine cannot resolve from a single static snapshot:
|
|
39
|
+
* • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
|
|
40
|
+
* content "repeated on multiple Web pages"; whether any block is
|
|
41
|
+
* actually repeated across the site is not knowable from one document,
|
|
42
|
+
* so a page that legitimately needs no bypass mechanism would be
|
|
43
|
+
* indistinguishable from one that omits a required one.
|
|
44
|
+
* • Transient accessibility-tree state. When a modal dialog is open the
|
|
45
|
+
* rest of the page is routinely made `inert` or `aria-hidden="true"`,
|
|
46
|
+
* so the page's real <main>/headings are (correctly) filtered out by
|
|
47
|
+
* isAccTreeEligible for the duration of that state and only the dialog
|
|
48
|
+
* is exposed — a snapshot taken then would see "no mechanism" though
|
|
49
|
+
* the page has one once the dialog closes. The same applies to content
|
|
50
|
+
* that is display:none until revealed by script (tabs, accordions, an
|
|
51
|
+
* unmounted SPA view).
|
|
52
|
+
* Both cases would produce false positives under a hard `fail`, which this
|
|
53
|
+
* engine reserves for high-confidence violations; `cantTell` routes them to
|
|
54
|
+
* human review instead. This mirrors how other tools treat 2.4.1 (e.g. axe
|
|
55
|
+
* marks the no-mechanism case "needs review" via reviewOnFail rather than
|
|
56
|
+
* failing it), and why no ACT rule hard-fails 2.4.1 by presence alone.
|
|
31
57
|
* - This rule intentionally checks presence, not position, for the
|
|
32
58
|
* same-page-anchor condition (b): a full bypass algorithm is heuristic
|
|
33
59
|
* (see ROADMAP.md's Tier 1a note on why this rule was
|
|
34
60
|
* deferred from the rest of that batch), and getting DOM-order /
|
|
35
61
|
* keyboard-focus-order positioning exactly right without introducing
|
|
36
62
|
* false positives is materially harder than the rest of Tier 1a. Being
|
|
37
|
-
* lenient about condition (b) can only
|
|
38
|
-
* a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
39
|
-
* "back to top" link) — never a
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
63
|
+
* lenient about condition (b) can only make us *miss* a review prompt
|
|
64
|
+
* (a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
65
|
+
* "back to top" link) — never raise a spurious one.
|
|
66
|
+
* - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
|
|
67
|
+
* shadow-DOM-aware (when the run enables includeShadowDom) and applies the
|
|
68
|
+
* engine's hidden-content policy. The same-page-anchor target is resolved
|
|
69
|
+
* in the link's own root (`getRootNode()` — the document, or the shadow
|
|
70
|
+
* root the link lives in) before falling back to the document, so a skip
|
|
71
|
+
* link encapsulated in a web component is credited the same as one in the
|
|
72
|
+
* light DOM. (Previously the anchor path used raw
|
|
73
|
+
* `document.querySelectorAll`/`getElementById`, which never pierced shadow
|
|
74
|
+
* roots — a genuine gap now closed.)
|
|
48
75
|
*/
|
|
49
76
|
|
|
50
77
|
const id = 'bypass-blocks-present';
|
|
@@ -58,7 +85,7 @@ const meta = {
|
|
|
58
85
|
descriptionKey: 'bypassBlocksPresent_description'
|
|
59
86
|
},
|
|
60
87
|
helpUrl: null,
|
|
61
|
-
tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', '
|
|
88
|
+
tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'manual'],
|
|
62
89
|
wcagSc: ['2.4.1'],
|
|
63
90
|
normativeMappings: [
|
|
64
91
|
{
|
|
@@ -69,9 +96,9 @@ const meta = {
|
|
|
69
96
|
conformanceLevel: 'A'
|
|
70
97
|
}
|
|
71
98
|
],
|
|
72
|
-
defaultSeverity: '
|
|
99
|
+
defaultSeverity: 'moderate',
|
|
73
100
|
category: 'operable',
|
|
74
|
-
type: '
|
|
101
|
+
type: 'manual',
|
|
75
102
|
defaultConfidence: 'medium',
|
|
76
103
|
coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
|
|
77
104
|
};
|
|
@@ -122,7 +149,8 @@ function runInPage(ctx) {
|
|
|
122
149
|
// (e.g. a page whose only <h1> sits inside a display:none ancestor,
|
|
123
150
|
// unreachable by sighted and screen reader users alike). A fully
|
|
124
151
|
// non-rendered <main>/heading must not be credited here, since that would
|
|
125
|
-
// wrongly
|
|
152
|
+
// wrongly treat a page with zero currently-exposed bypass mechanisms as
|
|
153
|
+
// having one.
|
|
126
154
|
function hasMainLandmark() {
|
|
127
155
|
for (const el of queryAll('main, [role="main"]')) {
|
|
128
156
|
if (el && isExposedToAt(el)) return true;
|
|
@@ -130,10 +158,41 @@ function runInPage(ctx) {
|
|
|
130
158
|
return false;
|
|
131
159
|
}
|
|
132
160
|
|
|
161
|
+
// Resolve a fragment id (or legacy <a name>) inside a specific root node
|
|
162
|
+
// (a Document or a ShadowRoot). Both expose getElementById; querySelector
|
|
163
|
+
// is used for the legacy anchor-name fallback.
|
|
164
|
+
function resolveInRoot(root, fragment) {
|
|
165
|
+
if (!root) return null;
|
|
166
|
+
let target;
|
|
167
|
+
try {
|
|
168
|
+
target = typeof root.getElementById === 'function' ? root.getElementById(fragment) : null;
|
|
169
|
+
} catch {
|
|
170
|
+
target = null;
|
|
171
|
+
}
|
|
172
|
+
if (target) return target;
|
|
173
|
+
try {
|
|
174
|
+
target =
|
|
175
|
+
typeof root.querySelector === 'function'
|
|
176
|
+
? root.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]')
|
|
177
|
+
: null;
|
|
178
|
+
} catch {
|
|
179
|
+
target = null;
|
|
180
|
+
}
|
|
181
|
+
return target;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Shadow-DOM-aware: gather anchors via queryAllSmart (pierces shadow roots
|
|
185
|
+
// when includeShadowDom is enabled, and drops hard-hidden links), and
|
|
186
|
+
// resolve each fragment in the link's own root before falling back to the
|
|
187
|
+
// document. This credits a skip link encapsulated in a web component the
|
|
188
|
+
// same way as one authored in the light DOM.
|
|
133
189
|
function hasWorkingAnchorLink() {
|
|
134
190
|
let links;
|
|
135
191
|
try {
|
|
136
|
-
links =
|
|
192
|
+
links =
|
|
193
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
194
|
+
? helpers.queryAllSmart('a[href]')
|
|
195
|
+
: document.querySelectorAll('a[href]');
|
|
137
196
|
} catch {
|
|
138
197
|
links = [];
|
|
139
198
|
}
|
|
@@ -150,18 +209,19 @@ function runInPage(ctx) {
|
|
|
150
209
|
fragment = fragment.trim();
|
|
151
210
|
if (!fragment) continue;
|
|
152
211
|
|
|
153
|
-
let
|
|
212
|
+
let root = document;
|
|
154
213
|
try {
|
|
155
|
-
|
|
214
|
+
if (typeof a.getRootNode === 'function') {
|
|
215
|
+
const r = a.getRootNode();
|
|
216
|
+
if (r) root = r;
|
|
217
|
+
}
|
|
156
218
|
} catch {
|
|
157
|
-
|
|
219
|
+
root = document;
|
|
158
220
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
target = null;
|
|
164
|
-
}
|
|
221
|
+
|
|
222
|
+
let target = resolveInRoot(root, fragment);
|
|
223
|
+
if (!target && root !== document) {
|
|
224
|
+
target = resolveInRoot(document, fragment);
|
|
165
225
|
}
|
|
166
226
|
if (target) return true;
|
|
167
227
|
}
|
|
@@ -179,8 +239,9 @@ function runInPage(ctx) {
|
|
|
179
239
|
const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
|
|
180
240
|
const heading = mainLandmark || anchorLink ? false : hasHeading();
|
|
181
241
|
|
|
242
|
+
// A recognized mechanism is present -> nothing to review on this page.
|
|
182
243
|
if (mainLandmark || anchorLink || heading) {
|
|
183
|
-
return { ruleId: rule.ruleId, outcome: '
|
|
244
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
184
245
|
}
|
|
185
246
|
|
|
186
247
|
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
@@ -192,11 +253,12 @@ function runInPage(ctx) {
|
|
|
192
253
|
{
|
|
193
254
|
selector: stableSelector,
|
|
194
255
|
html,
|
|
195
|
-
summary:
|
|
196
|
-
|
|
256
|
+
summary:
|
|
257
|
+
'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
|
|
258
|
+
hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
|
|
197
259
|
i18n: {
|
|
198
|
-
summaryKey: '
|
|
199
|
-
hintKey: '
|
|
260
|
+
summaryKey: 'bypassBlocksPresent_summary_cantTell',
|
|
261
|
+
hintKey: 'bypassBlocksPresent_hint_cantTell',
|
|
200
262
|
params: {}
|
|
201
263
|
},
|
|
202
264
|
data: {
|
|
@@ -208,8 +270,8 @@ function runInPage(ctx) {
|
|
|
208
270
|
|
|
209
271
|
return {
|
|
210
272
|
ruleId: rule.ruleId,
|
|
211
|
-
outcome: '
|
|
212
|
-
severity: rule.defaultSeverity || '
|
|
273
|
+
outcome: 'cantTell',
|
|
274
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
213
275
|
occurrences
|
|
214
276
|
};
|
|
215
277
|
}
|
|
@@ -71,6 +71,9 @@ function runInPage(ctx) {
|
|
|
71
71
|
const isAccTreeEligible =
|
|
72
72
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
73
73
|
|
|
74
|
+
const getAriaNameInfo =
|
|
75
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
76
|
+
|
|
74
77
|
const getFocusableInfo =
|
|
75
78
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
76
79
|
|
|
@@ -107,6 +110,26 @@ function runInPage(ctx) {
|
|
|
107
110
|
return !focusable;
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
// alt="" plus a name from aria-label/aria-labelledby/title is the judgement
|
|
114
|
+
// call this rule reviews. alt="" with no other source leaves the control
|
|
115
|
+
// unnamed, which input-image-alt-present fails outright.
|
|
116
|
+
function hasNameFromOtherSource(el) {
|
|
117
|
+
if (getAriaNameInfo) {
|
|
118
|
+
try {
|
|
119
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
120
|
+
if (aria && aria.present && String(aria.value || '').trim()) return true;
|
|
121
|
+
} catch {
|
|
122
|
+
// fall through to title
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const title = el.getAttribute('title');
|
|
127
|
+
return title != null && String(title).trim() !== '';
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
const els = (() => {
|
|
111
134
|
try {
|
|
112
135
|
return Array.from(
|
|
@@ -143,6 +166,7 @@ function runInPage(ctx) {
|
|
|
143
166
|
|
|
144
167
|
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
145
168
|
if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
|
|
169
|
+
if (!hasNameFromOtherSource(el)) continue;
|
|
146
170
|
|
|
147
171
|
applicableCount += 1;
|
|
148
172
|
|
|
@@ -28,19 +28,13 @@
|
|
|
28
28
|
* HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
|
|
29
29
|
* main→main, nav→navigation, aside→complementary, section/form→
|
|
30
30
|
* region/form only when accessibly named).
|
|
31
|
-
* - Candidate selection (`isBannerCandidate`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* sectioning
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* landmark, that same nesting would make it stop counting as a banner
|
|
39
|
-
* candidate in the first place, so the rule could never flag the one
|
|
40
|
-
* case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
|
|
41
|
-
* asymmetric: it still uses the full suppression-aware
|
|
42
|
-
* `getLandmarkRole` for each ancestor, since an ancestor genuinely
|
|
43
|
-
* needs its own real role to count as blocking.
|
|
31
|
+
* - Candidate selection (`isBannerCandidate`) requires the element to really
|
|
32
|
+
* carry the banner role, via the suppression-aware `getLandmarkRole`. The
|
|
33
|
+
* two ancestor sets differ, so this does not make the rule vacuous: the
|
|
34
|
+
* suppression set is the sectioning tags plus `<main>`, while the blocking
|
|
35
|
+
* set is any landmark role, so a `<header>` inside `role="region"`,
|
|
36
|
+
* `<form>` or `<footer>` is still caught, and an explicit `role="banner"`
|
|
37
|
+
* is a candidate wherever it sits.
|
|
44
38
|
*/
|
|
45
39
|
|
|
46
40
|
const id = 'landmark-banner-is-top-level';
|
|
@@ -145,16 +139,11 @@ function runInPage(ctx) {
|
|
|
145
139
|
return getImplicitLandmarkRole(el);
|
|
146
140
|
}
|
|
147
141
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// of whether sectioning-ancestor nesting would currently suppress its
|
|
152
|
-
// implicit role; an explicit role="banner" is always a candidate too.
|
|
142
|
+
// A candidate must actually have the banner role. Per HTML-AAM a <header>
|
|
143
|
+
// descended from article/aside/main/nav/section is not a banner at all, so
|
|
144
|
+
// flagging it as a nested banner reports a landmark that does not exist.
|
|
153
145
|
function isBannerCandidate(el) {
|
|
154
|
-
|
|
155
|
-
const explicit = getExplicitRoleToken(el);
|
|
156
|
-
if (explicit) return explicit === 'banner';
|
|
157
|
-
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
146
|
+
return getLandmarkRole(el) === 'banner';
|
|
158
147
|
}
|
|
159
148
|
|
|
160
149
|
function hasLandmarkAncestor(el) {
|
|
@@ -22,12 +22,9 @@
|
|
|
22
22
|
* `type: 'manual'` rule; see landmark-banner-is-top-level's
|
|
23
23
|
* header comment for the shared rationale/precedent (this rule mirrors
|
|
24
24
|
* its structure with contentinfo/footer in place of banner/header).
|
|
25
|
-
* - Candidate selection (`isContentinfoCandidate`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* self-defeating-candidate-selection reasoning as
|
|
29
|
-
* landmark-banner-is-top-level (see that file's header comment for the
|
|
30
|
-
* full root cause).
|
|
25
|
+
* - Candidate selection (`isContentinfoCandidate`) requires the element to
|
|
26
|
+
* really carry the contentinfo role, via the suppression-aware
|
|
27
|
+
* `getLandmarkRole` — same reasoning as landmark-banner-is-top-level.
|
|
31
28
|
*/
|
|
32
29
|
|
|
33
30
|
const id = 'landmark-contentinfo-is-top-level';
|
|
@@ -135,11 +132,11 @@ function runInPage(ctx) {
|
|
|
135
132
|
// a candidate purely by tag + absence of any role attribute, independent
|
|
136
133
|
// of whether sectioning-ancestor nesting would currently suppress its
|
|
137
134
|
// implicit role; an explicit role="contentinfo" is always a candidate too.
|
|
135
|
+
// A candidate must actually have the contentinfo role — a <footer> inside
|
|
136
|
+
// article/aside/main/nav/section is not one, so flagging it as nested
|
|
137
|
+
// would report a landmark that does not exist.
|
|
138
138
|
function isContentinfoCandidate(el) {
|
|
139
|
-
|
|
140
|
-
const explicit = getExplicitRoleToken(el);
|
|
141
|
-
if (explicit) return explicit === 'contentinfo';
|
|
142
|
-
return !!(el.tagName && el.tagName.toLowerCase() === 'footer');
|
|
139
|
+
return getLandmarkRole(el) === 'contentinfo';
|
|
143
140
|
}
|
|
144
141
|
|
|
145
142
|
function hasLandmarkAncestor(el) {
|