@surea11y/core 1.6.0 → 1.8.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.
Files changed (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Links must have an accessible name
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 2.4.4, 4.1.2
11
11
  * @applicability
12
12
  * Applies to <a href>, <area href> and elements with role="link" that are
13
13
  * included in the accessibility tree. An <a> without an href is not a link
@@ -33,9 +33,18 @@ const meta = {
33
33
  descriptionKey: 'linkNamePresent_description'
34
34
  },
35
35
  helpUrl: null,
36
- tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
37
- wcagSc: ['4.1.2'],
36
+ tags: ['wcag2a', 'wcag244', 'wcag412', 'navigation', 'atomic', 'automatic', 'links', 'name'],
37
+ wcagSc: ['2.4.4', '4.1.2'],
38
+ // 2.4.4 as well as 4.1.2, as ACT rule c487ae maps it: a link with no name
39
+ // has no purpose to determine, in context or otherwise.
38
40
  normativeMappings: [
41
+ {
42
+ standard: 'WCAG',
43
+ version: '2.2',
44
+ requirement: '2.4.4',
45
+ title: 'Link Purpose (In Context)',
46
+ conformanceLevel: 'A'
47
+ },
39
48
  {
40
49
  standard: 'WCAG',
41
50
  version: '2.2',
@@ -48,8 +57,7 @@ const meta = {
48
57
  category: 'robust',
49
58
  type: 'automatic',
50
59
  defaultConfidence: 'high',
51
- // IMPORTANT: update your facets registry to include "link-name-present" under 4.1.2
52
- coverage: { facetsBySc: { '4.1.2': ['link-name-present'] } }
60
+ coverage: { facetsBySc: { '2.4.4': ['link-name-present'], '4.1.2': ['link-name-present'] } }
53
61
  };
54
62
 
55
63
  function runInPage(ctx) {
@@ -10,7 +10,10 @@
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to <ul>/<ol> elements that have at least one direct element
13
- * child.
13
+ * child and whose role is list: no role attribute, role="list", or a role
14
+ * attribute naming no concrete ARIA role. A <ul>/<ol> given another role
15
+ * (listbox, menubar, tablist, none, ...) is not a list, so its children
16
+ * follow that role's rules instead.
14
17
  * @expectation
15
18
  * Every direct element child is <li>, <script>, or <template>. UNLESS it
16
19
  * has an explicit `role` attribute, in which case the explicit role wins
@@ -96,9 +99,23 @@ function runInPage(ctx) {
96
99
  const occurrences = [];
97
100
  let applicableCount = 0;
98
101
 
102
+ // The first role token that names a concrete ARIA role, or '' when none
103
+ // does (the element keeps its native list role).
104
+ const aria = helpers && helpers.aria;
105
+ function resolvedExplicitRole(el) {
106
+ const tokens = String((el.getAttribute && el.getAttribute('role')) || '')
107
+ .toLowerCase()
108
+ .split(/\s+/)
109
+ .filter(Boolean);
110
+ if (!aria || typeof aria.isValidConcreteRole !== 'function') return tokens[0] || '';
111
+ return tokens.find((t) => aria.isValidConcreteRole(t)) || '';
112
+ }
113
+
99
114
  for (const el of nodes) {
100
115
  if (!el || !el.children) continue;
101
116
  if (!el.children.length) continue;
117
+ const listRole = resolvedExplicitRole(el);
118
+ if (listRole && listRole !== 'list') continue;
102
119
 
103
120
  applicableCount += 1;
104
121
 
@@ -149,21 +149,6 @@ function runInPage(ctx) {
149
149
  }
150
150
  }
151
151
 
152
- function buildLabelForMap(doc) {
153
- const map = new Map(); // id -> label element (first)
154
- try {
155
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
156
- for (let i = 0; i < labels.length; i += 1) {
157
- const lab = labels[i];
158
- if (!lab || !lab.getAttribute) continue;
159
- const f = normalizeWs(lab.getAttribute('for'));
160
- if (!f) continue;
161
- if (!map.has(f)) map.set(f, lab);
162
- }
163
- } catch {}
164
- return map;
165
- }
166
-
167
152
  const occurrences = [];
168
153
  let applicableCount = 0;
169
154
 
@@ -172,42 +157,27 @@ function runInPage(ctx) {
172
157
  ? helpers.queryAllSmart(selector)
173
158
  : helpers.queryAll(selector);
174
159
 
175
- // Precompute label[for] map for listbox elements that are labelable
176
- // native form controls (e.g. <select multiple role="listbox">).
177
- const labelForMap = buildLabelForMap(document);
178
-
160
+ // Delegates to the shared, spec-guarded lookup (dom-helpers.js's
161
+ // getAssociatedLabelElements): a <label> -- wrapping or via `for` --
162
+ // only ever associates with a genuinely labelable element, so this
163
+ // correctly returns nothing for role-only widgets a <label> merely sits
164
+ // next to, and the real thing for a labelable element like
165
+ // <select multiple role="listbox">.
179
166
  function getNativeLabelText(el) {
167
+ if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
168
+ let labels;
180
169
  try {
181
- if ('labels' in el && el.labels && el.labels.length) {
182
- const parts = [];
183
- const max = Math.min(4, el.labels.length);
184
- for (let i = 0; i < max; i += 1) {
185
- const lab = el.labels[i];
186
- const t = lab ? getLabelText(lab) : '';
187
- if (t) parts.push(t);
188
- }
189
- const joined = normalizeWs(parts.join(' '));
190
- if (joined) return joined;
191
- }
192
- } catch {}
193
- try {
194
- if (el.closest) {
195
- const wrap = el.closest('label');
196
- if (wrap) {
197
- const t = getLabelText(wrap);
198
- if (t) return t;
199
- }
200
- }
201
- } catch {}
202
- try {
203
- const idAttr = getAttr(el, 'id');
204
- if (idAttr && labelForMap.has(idAttr)) {
205
- const lab = labelForMap.get(idAttr);
206
- const t = lab ? getLabelText(lab) : '';
207
- if (t) return t;
208
- }
209
- } catch {}
210
- return '';
170
+ labels = helpers.getAssociatedLabelElements(el) || [];
171
+ } catch {
172
+ labels = [];
173
+ }
174
+ const parts = [];
175
+ const max = Math.min(4, labels.length);
176
+ for (let i = 0; i < max; i += 1) {
177
+ const t = getLabelText(labels[i]);
178
+ if (t) parts.push(t);
179
+ }
180
+ return normalizeWs(parts.join(' '));
211
181
  }
212
182
 
213
183
  function hasName(el) {
@@ -11,7 +11,8 @@
11
11
  * @applicability
12
12
  * Applies to <li> elements that have a parent element.
13
13
  * @expectation
14
- * The parent is <ul>/<ol> with no role override, or an element with an
14
+ * The parent is <ul>/<ol>/<menu> with no role override (all three have
15
+ * the implicit role list), or an element with an
15
16
  * explicit role of "list", "presentation", or "none". An <li> used
16
17
  * outside a real list container (e.g. as a generic flex/grid item under
17
18
  * a <div>) is not exposed as a list item to assistive technologies.
@@ -47,7 +48,7 @@ const id = 'listitem-parent-valid';
47
48
  const meta = {
48
49
  title: 'List items must be inside a list container',
49
50
  description:
50
- 'Checks that <li> elements are contained by <ul>, <ol>, or an element with role="list".',
51
+ 'Checks that <li> elements are contained by <ul>, <ol>, <menu>, or an element with role="list".',
51
52
  i18n: {
52
53
  titleKey: 'listitemParentValid_title',
53
54
  descriptionKey: 'listitemParentValid_description'
@@ -111,7 +112,7 @@ function runInPage(ctx) {
111
112
  // direction, see the header comment.
112
113
  valid = explicitRole === 'list' || explicitRole === 'presentation' || explicitRole === 'none';
113
114
  } else {
114
- valid = parentTag === 'ul' || parentTag === 'ol';
115
+ valid = parentTag === 'ul' || parentTag === 'ol' || parentTag === 'menu';
115
116
  }
116
117
 
117
118
  if (valid) continue;
@@ -5,7 +5,7 @@
5
5
  /**
6
6
  * @check meta-refresh-no-exceptions
7
7
  * @atomic true
8
- * @summary At the AAA level, a meta refresh must not be used at all
8
+ * @summary At the AAA level, a meta refresh must not use a positive delay, however long
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.4, 3.2.5
11
11
  * @applicability
@@ -37,9 +37,9 @@
37
37
  const id = 'meta-refresh-no-exceptions';
38
38
 
39
39
  const meta = {
40
- title: 'Page must not use a meta refresh at all (AAA)',
40
+ title: 'Page must not use a timed meta refresh (AAA)',
41
41
  description:
42
- 'Checks that <meta http-equiv="refresh"> is not present at all, regardless of delay. This is the stricter AAA-level counterpart of the A-level positive-delay-only check.',
42
+ 'Checks that <meta http-equiv="refresh"> does not set a positive delay, however long; an immediate redirect (delay 0) passes. This is the stricter AAA-level counterpart of the A-level check, which exempts delays over 20 hours.',
43
43
  i18n: {
44
44
  titleKey: 'metaRefreshNoExceptions_title',
45
45
  descriptionKey: 'metaRefreshNoExceptions_description'
@@ -16,8 +16,11 @@
16
16
  * The document has a <title> element, and document.title with whitespace
17
17
  * collapsed is non-empty. The element is looked for anywhere in the
18
18
  * document, not only inside <head>: a <title> the parser leaves outside
19
- * <head> is still the document title in every browser. Whether that title
20
- * describes the page is page-title-patterns' question.
19
+ * <head> is still the document title in every browser. Only an
20
+ * HTML-namespace <title> counts; the <title> of an inline <svg> names the
21
+ * graphic, so a page whose only <title> is inside an <svg> is missing its
22
+ * title element. Whether that title describes the page is
23
+ * page-title-patterns' question.
21
24
  */
22
25
 
23
26
  const id = 'page-title-present';
@@ -66,8 +69,17 @@ function runInPage(ctx) {
66
69
  // after <head> has closed is not re-parented into <head>, yet the browser
67
70
  // (and document.title, used below) still recognizes it as the document's
68
71
  // title, matching that here avoids a false "missing title" report for a
69
- // <title> that legitimately ended up outside <head>.
70
- const titleEl = document.querySelector('title');
72
+ // <title> that legitimately ended up outside <head>. Only an HTML-namespace
73
+ // <title> counts: an inline <svg><title> names the graphic, not the page,
74
+ // and document.title ignores it.
75
+ const HTML_NS = 'http://www.w3.org/1999/xhtml';
76
+ let titleEl = null;
77
+ for (const t of Array.from(document.getElementsByTagName('title'))) {
78
+ if (!t.namespaceURI || t.namespaceURI === HTML_NS) {
79
+ titleEl = t;
80
+ break;
81
+ }
82
+ }
71
83
  const titleText = (document.title || '').replace(/\s+/g, ' ').trim();
72
84
 
73
85
  const missingTitleEl = !titleEl;
@@ -18,7 +18,8 @@
18
18
  * aria-labelledby that resolves to non-empty text, or from title.
19
19
  * role="progressbar" is name-from-author-only, so subtree text is never
20
20
  * accepted: text sitting inside a custom progressbar widget is not
21
- * reliably exposed as its name.
21
+ * reliably exposed as its name. On a labelable element
22
+ * (<progress role="progressbar">) an associated <label> counts as well.
22
23
  */
23
24
 
24
25
  const id = 'progressbar-name-present';
@@ -124,6 +125,15 @@ function runInPage(ctx) {
124
125
  const title = getAttr(el, 'title');
125
126
  if (title) return { ok: true, method: 'title' };
126
127
 
128
+ // An associated <label> on a labelable host
129
+ // (<progress role="progressbar">, <meter role="progressbar">).
130
+ if (helpers.getNativeHostNameInfo) {
131
+ try {
132
+ const host = helpers.getNativeHostNameInfo(el, ctx);
133
+ if (host && host.present) return { ok: true, method: host.mechanism };
134
+ } catch {}
135
+ }
136
+
127
137
  // role="progressbar" is name-from-author-only per WAI-ARIA: aria-label,
128
138
  // aria-labelledby, or title, no content-based naming method at all. It
129
139
  // must NOT fall back to subtree content: e.g. a <ul role="progressbar">
@@ -17,9 +17,10 @@
17
17
  * aria-hidden-focus and duplicate-id-aria own those separately). Per ACT
18
18
  * 7d6734, this reaches any element carrying one of these roles, not only
19
19
  * the <svg> root, e.g. a <circle role="graphics-symbol"> nested inside a
20
- * plain <svg>. The <svg> root itself is left to svg-text-alternative-present's
21
- * own (already ACT-clean) title/aria-name handling; the <img> tag is
22
- * excluded here since it has its own dedicated rule.
20
+ * plain <svg>. Every <svg> element itself is left to
21
+ * svg-text-alternative-present's own (already ACT-clean) title/aria-name
22
+ * handling, so an unnamed <svg role="img"> is reported once, there; the
23
+ * <img> tag is excluded here since it has its own dedicated rule.
23
24
  * @expectation
24
25
  * Each applicable element has an accessible text alternative:
25
26
  * - aria-label with a non-empty value; OR
@@ -95,8 +96,11 @@ function runInPage(ctx) {
95
96
  };
96
97
 
97
98
  const imgElements = (() => {
98
- // do not consider element "img" because it has its own rule
99
- const sel = '[role="img" i]:not(img), [role="graphics-symbol" i], [role="graphics-document" i]';
99
+ // <img> and <svg> are left out: each has its own rule
100
+ // (img-alt-present, svg-text-alternative-present), and counting an
101
+ // unnamed <svg role="img"> here too would report it twice.
102
+ const sel =
103
+ '[role="img" i]:not(img):not(svg), [role="graphics-symbol" i]:not(svg), [role="graphics-document" i]:not(svg)';
100
104
  try {
101
105
  return Array.from((queryAllSmart ? queryAllSmart(sel) : queryAll(sel)) || []);
102
106
  } catch {
@@ -20,6 +20,9 @@
20
20
  * accepted: text sitting inside a custom searchbox widget is not reliably
21
21
  * exposed as its name. On a labelable element (<input role="searchbox">)
22
22
  * an associated <label> counts as well.
23
+ * On a text-like <input> or a <textarea>, the placeholder counts last
24
+ * (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
25
+ * question, not a missing name.
23
26
  */
24
27
 
25
28
  const id = 'searchbox-name-present';
@@ -149,21 +152,6 @@ function runInPage(ctx) {
149
152
  }
150
153
  }
151
154
 
152
- function buildLabelForMap(doc) {
153
- const map = new Map(); // id -> label element (first)
154
- try {
155
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
156
- for (let i = 0; i < labels.length; i += 1) {
157
- const lab = labels[i];
158
- if (!lab || !lab.getAttribute) continue;
159
- const f = normalizeWs(lab.getAttribute('for'));
160
- if (!f) continue;
161
- if (!map.has(f)) map.set(f, lab);
162
- }
163
- } catch {}
164
- return map;
165
- }
166
-
167
155
  const occurrences = [];
168
156
  let applicableCount = 0;
169
157
 
@@ -172,42 +160,27 @@ function runInPage(ctx) {
172
160
  ? helpers.queryAllSmart(selector)
173
161
  : helpers.queryAll(selector);
174
162
 
175
- // Precompute label[for] map for searchbox elements that are labelable
176
- // native form controls (e.g. <input role="searchbox">).
177
- const labelForMap = buildLabelForMap(document);
178
-
163
+ // Delegates to the shared, spec-guarded lookup (dom-helpers.js's
164
+ // getAssociatedLabelElements): a <label> -- wrapping or via `for` --
165
+ // only ever associates with a genuinely labelable element, so this
166
+ // correctly returns nothing for role-only widgets a <label> merely sits
167
+ // next to, and the real thing for a labelable element like
168
+ // <input role="searchbox">.
179
169
  function getNativeLabelText(el) {
170
+ if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
171
+ let labels;
180
172
  try {
181
- if ('labels' in el && el.labels && el.labels.length) {
182
- const parts = [];
183
- const max = Math.min(4, el.labels.length);
184
- for (let i = 0; i < max; i += 1) {
185
- const lab = el.labels[i];
186
- const t = lab ? getLabelText(lab) : '';
187
- if (t) parts.push(t);
188
- }
189
- const joined = normalizeWs(parts.join(' '));
190
- if (joined) return joined;
191
- }
192
- } catch {}
193
- try {
194
- if (el.closest) {
195
- const wrap = el.closest('label');
196
- if (wrap) {
197
- const t = getLabelText(wrap);
198
- if (t) return t;
199
- }
200
- }
201
- } catch {}
202
- try {
203
- const idAttr = getAttr(el, 'id');
204
- if (idAttr && labelForMap.has(idAttr)) {
205
- const lab = labelForMap.get(idAttr);
206
- const t = lab ? getLabelText(lab) : '';
207
- if (t) return t;
208
- }
209
- } catch {}
210
- return '';
173
+ labels = helpers.getAssociatedLabelElements(el) || [];
174
+ } catch {
175
+ labels = [];
176
+ }
177
+ const parts = [];
178
+ const max = Math.min(4, labels.length);
179
+ for (let i = 0; i < max; i += 1) {
180
+ const t = getLabelText(labels[i]);
181
+ if (t) parts.push(t);
182
+ }
183
+ return normalizeWs(parts.join(' '));
211
184
  }
212
185
 
213
186
  function hasName(el) {
@@ -224,6 +197,16 @@ function runInPage(ctx) {
224
197
  const lab = getNativeLabelText(el);
225
198
  if (lab) return { ok: true, method: 'label' };
226
199
 
200
+ // HTML-AAM's last name source for a text-like <input> or a <textarea>,
201
+ // after title: the placeholder. A placeholder-only label is a 3.3.2
202
+ // question, not a missing name.
203
+ if (helpers.getNativeHostNameInfo) {
204
+ try {
205
+ const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
206
+ if (host && host.present) return { ok: true, method: host.mechanism };
207
+ } catch {}
208
+ }
209
+
227
210
  // role="searchbox" is name-from-author-only per WAI-ARIA: it must NOT
228
211
  // fall back to subtree content.
229
212
  return { ok: false, method: 'none' };
@@ -5,29 +5,34 @@
5
5
  /**
6
6
  * @check server-side-image-map-absent
7
7
  * @atomic true
8
- * @summary <img> must not use a server-side image map (ismap)
8
+ * @summary A server-side image map (ismap) needs a keyboard-operable alternative
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.1.1
11
11
  * @applicability
12
- * Applies to <img> elements that carry an ismap attribute.
12
+ * Applies to any scan scope. An <img ismap> inside an <a href> is a
13
+ * server-side image map: the browser sends the click coordinates to the
14
+ * link's URL, which has no keyboard-operable equivalent and exposes no
15
+ * individual regions to assistive technology.
13
16
  * @expectation
14
- * The image does not use ismap at all. Server-side image maps depend on
15
- * the browser sending click coordinates to the server, which has no
16
- * keyboard-operable equivalent, there is no way to determine or expose
17
- * individual clickable regions to assistive technology or keyboard
18
- * users. Client-side image maps (<map>/<area>, each with real href/alt)
19
- * are the accessible alternative and are not flagged by this rule.
17
+ * Each server-side image map is reported as cantTell: 2.1.1 is met when
18
+ * the same destinations are also offered as links a keyboard can reach,
19
+ * which the rule cannot verify.
20
+ * Client-side image maps (<map>/<area>) are not flagged.
21
+ * A scope with no <img ismap> passes. One whose only <img ismap> elements
22
+ * are outside a link is notApplicable: ismap does nothing there, so there
23
+ * is no server-side image map (the misplaced attribute is invalid HTML,
24
+ * a matter for the validator).
20
25
  * @implementation-notes
21
- * - Presence of ismap is itself the violation (there is no automatable way
22
- * to verify a "usable alternative" exists elsewhere on the page).
26
+ * - There is no automatable way to verify that a usable alternative exists
27
+ * elsewhere on the page, so the rule never fails.
23
28
  */
24
29
 
25
30
  const id = 'server-side-image-map-absent';
26
31
 
27
32
  const meta = {
28
- title: 'Images must not use a server-side image map',
33
+ title: 'Server-side image maps must have a keyboard-operable alternative',
29
34
  description:
30
- 'Checks that <img> elements do not carry the ismap attribute (server-side image maps have no keyboard-operable equivalent).',
35
+ 'Asks, for each <img ismap> inside a link, whether the page offers the same destinations as links a keyboard can reach, since a server-side image map has no keyboard-operable regions of its own.',
31
36
  i18n: {
32
37
  titleKey: 'serverSideImageMapAbsent_title',
33
38
  descriptionKey: 'serverSideImageMapAbsent_description'
@@ -59,23 +64,36 @@ function runInPage(ctx) {
59
64
  : helpers.queryAll('img[ismap]');
60
65
 
61
66
  const occurrences = [];
62
- let applicableCount = 0;
63
67
 
64
68
  for (const el of nodes) {
65
69
  if (!el) continue;
66
70
 
67
- applicableCount += 1;
71
+ // ismap does something only on an image inside a hyperlink.
72
+ let link;
73
+ try {
74
+ link = el.closest ? el.closest('a[href]') : null;
75
+ } catch {
76
+ link = null;
77
+ }
78
+ if (!link) continue;
68
79
 
69
80
  occurrences.push(
70
81
  helpers.reportOccurrence(el, {
71
82
  summary:
72
- 'This image uses a server-side image map, which has no keyboard-operable equivalent.',
73
- hint: 'Replace the server-side image map (ismap) with a client-side image map (<map>/<area>) or separate accessible links/buttons.',
83
+ 'This image is a server-side image map (ismap inside a link), whose regions cannot be reached from the keyboard.',
84
+ hint: 'Check that the page offers the same destinations as separate links. Better: replace the server-side image map with a client-side image map (<map>/<area>) or separate links/buttons.',
85
+ occurrenceOutcome: 'cantTell',
74
86
  i18n: {
75
- summaryKey: 'serverSideImageMapAbsent_summary_fail',
76
- hintKey: 'serverSideImageMapAbsent_hint_fail',
87
+ summaryKey: 'serverSideImageMapAbsent_summary_cantTell',
88
+ hintKey: 'serverSideImageMapAbsent_hint_cantTell',
77
89
  params: {}
78
90
  },
91
+ uncertainty: {
92
+ code: 'equivalence-unknown',
93
+ needed:
94
+ 'Whether the destinations of this image map are also offered as keyboard-operable links.',
95
+ evidence: { href: link.getAttribute('href') }
96
+ },
79
97
  data: {
80
98
  details: { reasonCode: 'SERVER_SIDE_IMAGE_MAP' }
81
99
  }
@@ -83,18 +101,20 @@ function runInPage(ctx) {
83
101
  );
84
102
  }
85
103
 
86
- if (applicableCount === 0) {
87
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
104
+ // No <img ismap> at all is the passing case (see @expectation above);
105
+ // ismap only outside links means there is no server-side image map.
106
+ if (!nodes.length) {
107
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
88
108
  }
89
- if (occurrences.length) {
90
- return {
91
- ruleId: rule.ruleId,
92
- outcome: 'fail',
93
- severity: rule.defaultSeverity || 'serious',
94
- occurrences
95
- };
109
+ if (!occurrences.length) {
110
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
96
111
  }
97
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
112
+ return {
113
+ ruleId: rule.ruleId,
114
+ outcome: 'cantTell',
115
+ severity: rule.defaultSeverity || 'serious',
116
+ occurrences
117
+ };
98
118
  }
99
119
 
100
120
  module.exports = { id, meta, runInPage };
@@ -21,6 +21,9 @@
21
21
  * accepted: text sitting inside a custom slider widget is not reliably
22
22
  * exposed as its name. On a labelable element (<input role="slider">) an
23
23
  * associated <label> counts as well.
24
+ * On a text-like <input> or a <textarea>, the placeholder counts last
25
+ * (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
26
+ * question, not a missing name.
24
27
  */
25
28
 
26
29
  const id = 'slider-name-present';
@@ -72,21 +75,6 @@ function runInPage(ctx) {
72
75
  }
73
76
  }
74
77
 
75
- function buildLabelForMap(doc) {
76
- const map = new Map(); // id -> label element (first)
77
- try {
78
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
79
- for (let i = 0; i < labels.length; i += 1) {
80
- const lab = labels[i];
81
- if (!lab || !lab.getAttribute) continue;
82
- const f = normalizeWs(lab.getAttribute('for'));
83
- if (!f) continue;
84
- if (!map.has(f)) map.set(f, lab);
85
- }
86
- } catch {}
87
- return map;
88
- }
89
-
90
78
  function getConservativeSubtreeText(document, container) {
91
79
  // "Name from content", recurses into descendants and uses each one's
92
80
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
@@ -176,41 +164,27 @@ function runInPage(ctx) {
176
164
  ? helpers.queryAllSmart(selector)
177
165
  : helpers.queryAll(selector);
178
166
 
179
- // Precompute label[for] map for native range inputs.
180
- const labelForMap = buildLabelForMap(document);
181
-
167
+ // Delegates to the shared, spec-guarded lookup (dom-helpers.js's
168
+ // getAssociatedLabelElements): a <label> -- wrapping or via `for` --
169
+ // only ever associates with a genuinely labelable element, so this
170
+ // correctly returns nothing for role-only widgets a <label> merely sits
171
+ // next to, and the real thing for a labelable element like
172
+ // <input type="range">.
182
173
  function getNativeLabelText(el) {
174
+ if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
175
+ let labels;
183
176
  try {
184
- if ('labels' in el && el.labels && el.labels.length) {
185
- const parts = [];
186
- const max = Math.min(4, el.labels.length);
187
- for (let i = 0; i < max; i += 1) {
188
- const lab = el.labels[i];
189
- const t = lab ? getLabelText(lab) : '';
190
- if (t) parts.push(t);
191
- }
192
- const joined = normalizeWs(parts.join(' '));
193
- if (joined) return joined;
194
- }
195
- } catch {}
196
- try {
197
- if (el.closest) {
198
- const wrap = el.closest('label');
199
- if (wrap) {
200
- const t = getLabelText(wrap);
201
- if (t) return t;
202
- }
203
- }
204
- } catch {}
205
- try {
206
- const idAttr = getAttr(el, 'id');
207
- if (idAttr && labelForMap.has(idAttr)) {
208
- const lab = labelForMap.get(idAttr);
209
- const t = lab ? getLabelText(lab) : '';
210
- if (t) return t;
211
- }
212
- } catch {}
213
- return '';
177
+ labels = helpers.getAssociatedLabelElements(el) || [];
178
+ } catch {
179
+ labels = [];
180
+ }
181
+ const parts = [];
182
+ const max = Math.min(4, labels.length);
183
+ for (let i = 0; i < max; i += 1) {
184
+ const t = getLabelText(labels[i]);
185
+ if (t) parts.push(t);
186
+ }
187
+ return normalizeWs(parts.join(' '));
214
188
  }
215
189
 
216
190
  function evaluate(el, kind) {
@@ -223,10 +197,22 @@ function runInPage(ctx) {
223
197
  const title = getAttr(el, 'title');
224
198
  if (title) return { ok: true, method: 'title' };
225
199
 
226
- if (kind === 'native-slider') {
227
- const lab = getNativeLabelText(el);
228
- if (lab) return { ok: true, method: 'label' };
229
- return { ok: false, method: 'none' };
200
+ // An associated <label> names any labelable host, whether it is a
201
+ // native <input type="range"> or carries role="slider"
202
+ // (<input type="number" role="slider">); getAssociatedLabelElements
203
+ // returns nothing for a non-labelable one.
204
+ const lab = getNativeLabelText(el);
205
+ if (lab) return { ok: true, method: 'label' };
206
+ if (kind === 'native-slider') return { ok: false, method: 'none' };
207
+
208
+ // HTML-AAM's last name source for a text-like <input> or a <textarea>,
209
+ // after title: the placeholder. A placeholder-only label is a 3.3.2
210
+ // question, not a missing name.
211
+ if (helpers.getNativeHostNameInfo) {
212
+ try {
213
+ const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
214
+ if (host && host.present) return { ok: true, method: host.mechanism };
215
+ } catch {}
230
216
  }
231
217
 
232
218
  // role="slider" is name-from-author-only per WAI-ARIA (unlike e.g.