@surea11y/core 1.7.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 (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -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.
@@ -20,6 +20,9 @@
20
20
  * accepted: text sitting inside a custom spinbutton widget is not reliably
21
21
  * exposed as its name. On a labelable element (<input role="spinbutton">)
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 = 'spinbutton-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 spinbutton elements that are labelable
176
- // native form controls (e.g. <input role="spinbutton">).
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="spinbutton">.
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="spinbutton" 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' };
@@ -124,17 +124,6 @@ const meta = {
124
124
  }
125
125
  };
126
126
 
127
- // ---- constants (deterministic, CSS pixels) ----
128
- const MIN_SIZE = 24;
129
- const MIN_RADIUS = 12; // spacing exception circle radius (24px diameter)
130
-
131
- // Grid sampling configuration (kept small for perf; only used for undersized targets)
132
- const GRID_POINTS = 25; // 5x5
133
- const GRID_N = 5;
134
-
135
- // Circle sampling configuration (perimeter)
136
- const CIRCLE_SAMPLES = 16; // deterministic, fixed
137
-
138
127
  function runInPage(ctx) {
139
128
  'use strict';
140
129
 
@@ -12,15 +12,23 @@
12
12
  * `<table>` elements with at least 4 rows and at least 4 columns
13
13
  * (a "large" table, where implicit row/column header association is
14
14
  * useful; small tables are usually self-evident), and with
15
- * NO `colspan`/`rowspan` anywhere in the table.
15
+ * NO `colspan`/`rowspan` anywhere in the table. A table whose role (first
16
+ * token) is anything but table, grid or treegrid, such as a layout table
17
+ * with role="presentation", is left out: it has no data cells.
16
18
  * @expectation
17
- * Every `<td>` has an associated header, via one of:
19
+ * Every non-empty `<td>` has an associated header, via one of:
18
20
  * - a non-empty `headers` attribute (trusted here; whether it
19
21
  * resolves to real `<th>` ids is `table-headers-attr-valid`'s
20
22
  * concern, not this rule's), OR
21
- * - an implicit column header: some `<th>` in the same column, in an
23
+ * - an implicit column header: a header cell in the same column, in an
22
24
  * earlier row, OR
23
- * - an implicit row header: some `<th>` earlier in the same row.
25
+ * - an implicit row header: a header cell earlier in the same row.
26
+ * A header cell is a `<th>` with no other role, or any cell with
27
+ * role="columnheader" or role="rowheader" (such a `<td>` is a header,
28
+ * not a data cell). A `<td>` with no text and no content that could carry
29
+ * a name (an image, a control, an element with an ARIA label) holds no
30
+ * data, so it needs no header; the empty corner cell above row headers is
31
+ * the usual case.
24
32
  * @implementation-notes
25
33
  * - Closes the gap `table-th-has-data-cells` deferred (see
26
34
  * that rule's own implementation notes): this is the fuller positional
@@ -84,6 +92,25 @@ function runInPage(ctx) {
84
92
  }
85
93
  }
86
94
 
95
+ const TABLE_ROLES = ['table', 'grid', 'treegrid'];
96
+
97
+ function firstRole(el) {
98
+ return trim(el.getAttribute('role')).toLowerCase().split(/\s+/)[0];
99
+ }
100
+
101
+ // Content that can carry a name or a value even without text.
102
+ const NAMED_CONTENT =
103
+ 'img, svg, canvas, input, select, textarea, button, object, embed, video, audio, iframe, meter, progress, [role], [aria-label], [aria-labelledby], [title]';
104
+
105
+ function isEmptyCell(cell) {
106
+ if (trim(cell.textContent)) return false;
107
+ try {
108
+ return !cell.querySelector(NAMED_CONTENT);
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
87
114
  const tables = helpers.queryAllSmart ? helpers.queryAllSmart('table') : helpers.queryAll('table');
88
115
 
89
116
  const occurrences = [];
@@ -92,6 +119,9 @@ function runInPage(ctx) {
92
119
  for (const table of tables) {
93
120
  if (!table || !table.rows) continue;
94
121
 
122
+ const tableRole = firstRole(table);
123
+ if (tableRole && !TABLE_ROLES.includes(tableRole)) continue;
124
+
95
125
  const rows = Array.from(table.rows);
96
126
  if (rows.length < MIN_SIZE) continue;
97
127
 
@@ -115,7 +145,10 @@ function runInPage(ctx) {
115
145
  // another cell's row/column header, even though it's still structurally
116
146
  // a <th>.
117
147
  function isHeaderCell(cell) {
118
- return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th' && isEligible(cell));
148
+ if (!cell || !cell.tagName || !isEligible(cell)) return false;
149
+ const role = firstRole(cell);
150
+ if (role === 'columnheader' || role === 'rowheader') return true;
151
+ return cell.tagName.toLowerCase() === 'th' && !role;
119
152
  }
120
153
 
121
154
  // "Was there a <th> above this cell's column" and "was there a <th>
@@ -148,6 +181,9 @@ function runInPage(ctx) {
148
181
  const headersAttr = trim(cell.getAttribute('headers'));
149
182
  if (headersAttr) continue;
150
183
 
184
+ // An empty cell holds no data to associate with a header.
185
+ if (isEmptyCell(cell)) continue;
186
+
151
187
  if (colHasHeaderAbove[c]) continue;
152
188
  if (rowHasHeaderBefore) continue;
153
189