@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/surea11y-core.js +0 -0
  3. package/docs/ARIA_DEPRECATION.md +95 -0
  4. package/docs/RULE_CATALOG.md +6 -6
  5. package/package.json +3 -1
  6. package/src/checks/automatic/aria-allowed-attr.js +648 -83
  7. package/src/checks/automatic/aria-deprecated-role.js +105 -37
  8. package/src/checks/automatic/aria-roles-valid.js +31 -6
  9. package/src/checks/automatic/autocomplete-valid.js +37 -1
  10. package/src/checks/automatic/avoid-inline-spacing.js +179 -19
  11. package/src/checks/automatic/binary-control-name-present.js +11 -3
  12. package/src/checks/automatic/button-name-present.js +50 -17
  13. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  14. package/src/checks/automatic/combobox-name-present.js +8 -1
  15. package/src/checks/automatic/dialog-name-present.js +8 -1
  16. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  17. package/src/checks/automatic/form-control-single-label.js +107 -39
  18. package/src/checks/automatic/img-alt-present.js +16 -9
  19. package/src/checks/automatic/input-image-alt-present.js +99 -48
  20. package/src/checks/automatic/label-in-name.js +82 -8
  21. package/src/checks/automatic/language-page-present.js +5 -1
  22. package/src/checks/automatic/link-name-present.js +50 -17
  23. package/src/checks/automatic/listbox-name-present.js +8 -1
  24. package/src/checks/automatic/menuitem-name-present.js +8 -1
  25. package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
  26. package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
  27. package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
  28. package/src/checks/automatic/meter-name-present.js +8 -1
  29. package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
  30. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  31. package/src/checks/automatic/option-name-present.js +8 -1
  32. package/src/checks/automatic/progressbar-name-present.js +8 -1
  33. package/src/checks/automatic/searchbox-name-present.js +8 -1
  34. package/src/checks/automatic/slider-name-present.js +11 -2
  35. package/src/checks/automatic/spinbutton-name-present.js +8 -1
  36. package/src/checks/automatic/summary-name-present.js +8 -1
  37. package/src/checks/automatic/tab-name-present.js +8 -1
  38. package/src/checks/automatic/table-th-has-data-cells.js +67 -6
  39. package/src/checks/automatic/textbox-name-present.js +8 -1
  40. package/src/checks/automatic/tooltip-name-present.js +8 -1
  41. package/src/checks/automatic/treeitem-name-present.js +8 -1
  42. package/src/checks/automatic/valid-lang.js +16 -3
  43. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
  44. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  45. package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
  46. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
  47. package/src/core.js +4853 -964
  48. package/src/sarif.js +2 -2
  49. package/surea11y.browser.js +2256 -433
@@ -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 anywhere in the document. Deliberately NOT
25
- * required to be positioned before a <nav> or be keyboard-focus-
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 produce a false NEGATIVE (missing
38
- * a page whose only anchor link isn't a real skip mechanism, e.g. a
39
- * "back to top" link) — never a false positive — which matches this
40
- * engine's non-negotiable "fail is reserved for high-confidence
41
- * violations" policy. A future revision can tighten (b) once a
42
- * positional heuristic has been validated against real pages without
43
- * regressions.
44
- * - `fail` therefore means: no main landmark, no resolvable same-page
45
- * anchor link anywhere, and no heading anywhere on the page. That is a
46
- * strong, low-ambiguity signal that the page truly has zero recognized
47
- * bypass mechanism.
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', 'automatic'],
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: 'serious',
99
+ defaultSeverity: 'moderate',
73
100
  category: 'operable',
74
- type: 'automatic',
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 return `pass` for a page with zero actual bypass mechanisms.
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 = document.querySelectorAll('a[href]');
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 target;
212
+ let root = document;
154
213
  try {
155
- target = document.getElementById(fragment);
214
+ if (typeof a.getRootNode === 'function') {
215
+ const r = a.getRootNode();
216
+ if (r) root = r;
217
+ }
156
218
  } catch {
157
- target = null;
219
+ root = document;
158
220
  }
159
- if (!target) {
160
- try {
161
- target = document.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]');
162
- } catch {
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: 'pass', severity: 'minor', occurrences: [] };
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: 'This page has no recognized way to bypass repeated blocks of content.',
196
- hint: 'Add 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.',
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: 'bypassBlocksPresent_summary_fail',
199
- hintKey: 'bypassBlocksPresent_hint_fail',
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: 'fail',
212
- severity: rule.defaultSeverity || 'serious',
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` below) is deliberately
32
- * unconditional — a `<header>`/`role="banner"` counts as a candidate
33
- * regardless of nesting — rather than reusing the same HTML-AAM
34
- * sectioning-ancestor suppression (`getImplicitLandmarkRole`'s
35
- * `hasSectioningAncestor` gate) that the violation check itself relies
36
- * on. Gating candidate selection on that suppression would be
37
- * self-defeating: the moment a `<header>` is nested inside another
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
- // Candidate selection is deliberately NOT the same as getLandmarkRole()
149
- // === 'banner' — see the fix note above. A <header> is a
150
- // candidate purely by tag + absence of any role attribute, independent
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
- if (!el || !el.getAttribute) return false;
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` below) is deliberately
26
- * unconditional, instead of reusing the sectioning-ancestor suppression
27
- * that the violation check itself depends on — same
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
- if (!el || !el.getAttribute) return false;
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) {