@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
@@ -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
 
@@ -293,14 +282,29 @@ function runInPage(ctx) {
293
282
  }
294
283
  }
295
284
 
285
+ // Per-run memoization: getComputedStyle is a real, non-trivial cost in an
286
+ // actual browser (unlike jsdom, which no-ops it), and hasSpacingConflict
287
+ // below calls getStyle on the SAME candidate element repeatedly -- once
288
+ // per undersized target it's compared against, via
289
+ // isInlineTextExceptionTarget/isInlineLinkTarget -- so with U undersized
290
+ // targets and N total candidates this was an uncached O(U * N)
291
+ // getComputedStyle call count. The DOM is read-only for the rest of this
292
+ // rule's run (no writes between reads), so caching per element here is
293
+ // safe -- style cannot change mid-run.
294
+ const __styleCache = new WeakMap();
296
295
  function getStyle(el) {
296
+ if (__styleCache.has(el)) return __styleCache.get(el);
297
+ let cs;
297
298
  try {
298
- return document && document.defaultView && document.defaultView.getComputedStyle
299
- ? document.defaultView.getComputedStyle(el)
300
- : null;
299
+ cs =
300
+ document && document.defaultView && document.defaultView.getComputedStyle
301
+ ? document.defaultView.getComputedStyle(el)
302
+ : null;
301
303
  } catch {
302
- return null;
304
+ cs = null;
303
305
  }
306
+ __styleCache.set(el, cs);
307
+ return cs;
304
308
  }
305
309
 
306
310
  function isPointerReachable(el) {
@@ -431,6 +435,46 @@ function runInPage(ctx) {
431
435
 
432
436
  const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
433
437
 
438
+ // Spatial grid over ALL items, cell size = MIN (24px), so hasSpacingConflict's
439
+ // proximity check below doesn't have to compare every undersized target
440
+ // against every other item -- an O(items^2) cost that dominates real-browser
441
+ // (not jsdom -- see this rule's own perf note further down) runtime on a
442
+ // page with many small targets. A point can only be within MIN of another
443
+ // point that shares its grid cell or one of the 8 adjacent cells: cell size
444
+ // equals the search radius, so two points in cells 2+ apart on either axis
445
+ // are, on that axis alone, already >= MIN apart. Restricting the candidate
446
+ // set to that 3x3 neighborhood is therefore never a false negative -- the
447
+ // exact same dist() < MIN check still runs on every candidate it returns,
448
+ // just skipping candidates that are geometrically guaranteed too far away.
449
+ // This changes performance only, never which targets conflict.
450
+ const grid = new Map(); // "cx,cy" -> item[]
451
+ function cellKeyFor(cx, cy) {
452
+ return cx + ',' + cy;
453
+ }
454
+ for (const it of items) {
455
+ const cx = Math.floor(it.center.cx / MIN);
456
+ const cy = Math.floor(it.center.cy / MIN);
457
+ const key = cellKeyFor(cx, cy);
458
+ let bucket = grid.get(key);
459
+ if (!bucket) {
460
+ bucket = [];
461
+ grid.set(key, bucket);
462
+ }
463
+ bucket.push(it);
464
+ }
465
+ function nearbyItems(center) {
466
+ const cx = Math.floor(center.cx / MIN);
467
+ const cy = Math.floor(center.cy / MIN);
468
+ const out = [];
469
+ for (let dx = -1; dx <= 1; dx++) {
470
+ for (let dy = -1; dy <= 1; dy++) {
471
+ const bucket = grid.get(cellKeyFor(cx + dx, cy + dy));
472
+ if (bucket) for (const it of bucket) out.push(it);
473
+ }
474
+ }
475
+ return out;
476
+ }
477
+
434
478
  // --- spacing/occlusion evaluation ---
435
479
  function hasSpacingConflict(target) {
436
480
  // 0) Pure geometry: deterministic center-distance check against ANY
@@ -438,7 +482,7 @@ function runInPage(ctx) {
438
482
  // spacing exception depends on proximity to any adjacent target, so an
439
483
  // undersized target sitting flush against an adequately-sized one still
440
484
  // fails the exception, which an undersized-only comparison would miss.
441
- for (const other of items) {
485
+ for (const other of nearbyItems(target.center)) {
442
486
  if (!other || !other.el || isRelated(target.el, other.el)) continue;
443
487
 
444
488
  // Ignore inline-text exception targets when evaluating spacing conflicts.
@@ -614,6 +658,14 @@ function runInPage(ctx) {
614
658
  hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
615
659
  params: {}
616
660
  },
661
+ uncertainty: {
662
+ code: 'not-computable',
663
+ needed: 'A reliable measurement of the spacing between this target and its neighbour.',
664
+ evidence: {
665
+ measured: { width: it.rect.width, height: it.rect.height },
666
+ conflictHitCount: info.hitCount
667
+ }
668
+ },
617
669
  data: {
618
670
  details: {
619
671
  measured: { width: it.rect.width, height: it.rect.height },
@@ -643,6 +695,14 @@ function runInPage(ctx) {
643
695
  hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
644
696
  params: {}
645
697
  },
698
+ uncertainty: {
699
+ code: 'judgement-required',
700
+ needed: 'Whether this target’s size is essential, which WCAG exempts.',
701
+ evidence: {
702
+ measured: { width: it.rect.width, height: it.rect.height },
703
+ conflictHitCount: info.hitCount
704
+ }
705
+ },
646
706
  data: {
647
707
  details: {
648
708
  measured: { width: it.rect.width, height: it.rect.height },
@@ -671,6 +731,14 @@ function runInPage(ctx) {
671
731
  hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
672
732
  params: {}
673
733
  },
734
+ uncertainty: {
735
+ code: 'judgement-required',
736
+ needed: 'Whether this target is a link in a sentence, which WCAG exempts.',
737
+ evidence: {
738
+ measured: { width: it.rect.width, height: it.rect.height },
739
+ conflictHitCount: info.hitCount
740
+ }
741
+ },
674
742
  data: {
675
743
  details: {
676
744
  measured: { width: it.rect.width, height: it.rect.height },
@@ -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,30 +145,34 @@ 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
- function hasColumnHeaderAbove(r, c) {
122
- for (let ri = 0; ri < r; ri++) {
123
- const cell = rowCells[ri] && rowCells[ri][c];
124
- if (isHeaderCell(cell)) return true;
125
- }
126
- return false;
127
- }
128
-
129
- function hasRowHeaderBefore(r, c) {
130
- const cells = rowCells[r] || [];
131
- for (let ci = 0; ci < c; ci++) {
132
- if (isHeaderCell(cells[ci])) return true;
133
- }
134
- return false;
135
- }
154
+ // "Was there a <th> above this cell's column" and "was there a <th>
155
+ // earlier in this cell's row" are both prefix questions over a scan
156
+ // already in progress (rows top to bottom, cells left to right within a
157
+ // row), so each is tracked incrementally instead of rescanning the
158
+ // rows/columns already passed for every cell: colHasHeaderAbove[c]
159
+ // carries forward across rows, rowHasHeaderBefore resets at the start
160
+ // of each row. One pass over every cell, not one rescan per cell.
161
+ const colHasHeaderAbove = new Array(maxCols).fill(false);
136
162
 
137
163
  for (let r = 0; r < rowCells.length; r++) {
138
164
  const cells = rowCells[r];
165
+ let rowHasHeaderBefore = false;
166
+
139
167
  for (let c = 0; c < cells.length; c++) {
140
168
  const cell = cells[c];
141
- if (!cell || isHeaderCell(cell)) continue;
169
+ if (!cell) continue;
170
+
171
+ if (isHeaderCell(cell)) {
172
+ colHasHeaderAbove[c] = true;
173
+ rowHasHeaderBefore = true;
174
+ continue;
175
+ }
142
176
 
143
177
  // An aria-hidden data cell isn't exposed to AT either, so it has
144
178
  // no need for an accessible header association.
@@ -147,8 +181,11 @@ function runInPage(ctx) {
147
181
  const headersAttr = trim(cell.getAttribute('headers'));
148
182
  if (headersAttr) continue;
149
183
 
150
- if (hasColumnHeaderAbove(r, c)) continue;
151
- if (hasRowHeaderBefore(r, c)) continue;
184
+ // An empty cell holds no data to associate with a header.
185
+ if (isEmptyCell(cell)) continue;
186
+
187
+ if (colHasHeaderAbove[c]) continue;
188
+ if (rowHasHeaderBefore) continue;
152
189
 
153
190
  occurrences.push(
154
191
  helpers.reportOccurrence(cell, {