@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.
- package/CHANGELOG.md +140 -0
- package/README.md +179 -90
- package/docs/ACT_RULE_MAPPING.md +10 -8
- package/docs/API_STABILITY.md +67 -6
- package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
- package/docs/CI_INTEGRATIONS.md +43 -0
- package/docs/DESIGN_CHALLENGES.md +162 -2
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +109 -5
- package/docs/I18N.md +62 -20
- package/docs/INTEGRATION.md +4 -2
- package/docs/JUNIT.md +73 -0
- package/docs/LIMITATIONS.md +4 -1
- package/docs/OUTPUT_SCHEMA.md +62 -11
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +7 -2
- package/docs/RULE_AUTHORING.md +83 -17
- package/docs/RULE_CATALOG.md +212 -139
- package/docs/RULE_EXAMPLES.md +2189 -0
- package/docs/RULE_HELPERS.md +390 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +23 -3
- package/docs/WCAG_CONFORMANCE.md +64 -3
- package/package.json +41 -12
- package/profiles/index.js +14 -0
- package/src/checks/automatic/area-alt-present.js +87 -31
- package/src/checks/automatic/aria-allowed-attr.js +6 -0
- package/src/checks/automatic/aria-allowed-role.js +32 -23
- package/src/checks/automatic/aria-braille-equivalent.js +43 -17
- package/src/checks/automatic/aria-conditional-attr.js +17 -10
- package/src/checks/automatic/aria-deprecated-role.js +12 -0
- package/src/checks/automatic/aria-hidden-body.js +1 -1
- package/src/checks/automatic/aria-hidden-focus.js +74 -18
- package/src/checks/automatic/aria-prohibited-attr.js +22 -4
- package/src/checks/automatic/aria-prohibited-children.js +6 -6
- package/src/checks/automatic/aria-required-attr.js +88 -12
- package/src/checks/automatic/aria-required-children.js +33 -16
- package/src/checks/automatic/aria-required-parent.js +32 -6
- package/src/checks/automatic/aria-role-name-present.js +20 -3
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -24
- package/src/checks/automatic/aria-valid-attr.js +14 -9
- package/src/checks/automatic/autocomplete-valid.js +152 -26
- package/src/checks/automatic/avoid-inline-spacing.js +207 -15
- package/src/checks/automatic/button-name-present.js +2 -1
- package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
- package/src/checks/automatic/combobox-name-present.js +34 -51
- package/src/checks/automatic/contrast-computable.js +45 -4
- package/src/checks/automatic/contrast-enhanced.js +16 -4
- package/src/checks/automatic/contrast-minimum.js +57 -11
- package/src/checks/automatic/css-orientation-lock.js +171 -12
- package/src/checks/automatic/definition-list-children-valid.js +67 -23
- package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
- package/src/checks/automatic/dialog-name-present.js +28 -9
- package/src/checks/automatic/duplicate-id-aria.js +5 -0
- package/src/checks/automatic/duplicate-id.js +19 -10
- package/src/checks/automatic/form-control-single-label.js +9 -0
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +12 -4
- package/src/checks/automatic/iframe-title-unique.js +36 -81
- package/src/checks/automatic/input-image-alt-present.js +32 -20
- package/src/checks/automatic/label-in-name.js +78 -69
- package/src/checks/automatic/language-page-present.js +12 -6
- package/src/checks/automatic/link-in-text-block.js +512 -44
- package/src/checks/automatic/link-name-present.js +13 -5
- package/src/checks/automatic/list-children-valid.js +18 -1
- package/src/checks/automatic/listbox-name-present.js +19 -49
- package/src/checks/automatic/listitem-parent-valid.js +4 -3
- package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
- package/src/checks/automatic/page-title-present.js +16 -4
- package/src/checks/automatic/progressbar-name-present.js +11 -1
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
- package/src/checks/automatic/searchbox-name-present.js +32 -49
- package/src/checks/automatic/server-side-image-map-absent.js +48 -28
- package/src/checks/automatic/slider-name-present.js +38 -52
- package/src/checks/automatic/spinbutton-name-present.js +32 -49
- package/src/checks/automatic/target-size-minimum.js +84 -16
- package/src/checks/automatic/td-has-header.js +60 -23
- package/src/checks/automatic/text-spacing-content-loss.js +548 -0
- package/src/checks/automatic/textbox-name-present.js +32 -49
- package/src/checks/automatic/valid-lang.js +15 -10
- package/src/checks/manual/area-alt-quality-manual.js +113 -31
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
- package/src/checks/manual/css-hidden-focus.js +215 -7
- package/src/checks/manual/form-control-label-quality-manual.js +243 -29
- package/src/checks/manual/heading-order-manual.js +9 -1
- package/src/checks/manual/heading-quality-manual.js +143 -9
- package/src/checks/manual/img-alt-decorative-manual.js +6 -3
- package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/link-name-quality-manual.js +130 -4
- package/src/checks/manual/media-transcript-present-manual.js +65 -8
- package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
- package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
- package/src/checks/manual/p-as-heading-manual.js +89 -44
- package/src/checks/manual/page-title-patterns-manual.js +77 -8
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/skip-link-manual.js +42 -14
- package/src/checks/manual/table-fake-caption-manual.js +32 -1
- package/src/checks/manual/video-caption-manual.js +47 -24
- package/src/checks/manual-review.js +0 -4
- package/src/core.js +18061 -46194
- package/src/coverage/en301549-map.js +187 -0
- package/src/coverage/standards.js +279 -0
- package/src/coverage/wcag-facets.js +1119 -0
- package/src/coverage/wcag-version-map.js +101 -0
- package/src/earl.js +144 -0
- package/src/en301549.js +33 -0
- package/src/junit.js +321 -0
- package/src/profile-kit.js +163 -0
- package/src/report.js +343 -74
- package/src/sarif.js +56 -5
- package/src/wcag.js +105 -0
- package/surea11y.browser.js +11 -41039
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/surea11y.i18n.ja.js +3 -0
- 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
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
cs =
|
|
300
|
+
document && document.defaultView && document.defaultView.getComputedStyle
|
|
301
|
+
? document.defaultView.getComputedStyle(el)
|
|
302
|
+
: null;
|
|
301
303
|
} catch {
|
|
302
|
-
|
|
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
|
|
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:
|
|
23
|
+
* - an implicit column header: a header cell in the same column, in an
|
|
22
24
|
* earlier row, OR
|
|
23
|
-
* - an implicit row header:
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
151
|
-
if (
|
|
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, {
|