@surea11y/core 1.5.0 → 1.7.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 +240 -149
- package/README.md +51 -44
- package/docs/ACT_RULE_MAPPING.md +245 -0
- package/docs/API_STABILITY.md +53 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
- package/docs/DESIGN_CHALLENGES.md +367 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +42 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +9 -5
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +63 -36
- package/docs/RULE_CATALOG.md +1928 -169
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +21 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +34 -10
- package/package.json +11 -9
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +74 -10
- package/src/checks/automatic/aria-allowed-role.js +34 -25
- package/src/checks/automatic/aria-braille-equivalent.js +21 -13
- package/src/checks/automatic/aria-conditional-attr.js +22 -15
- package/src/checks/automatic/aria-deprecated-role.js +13 -1
- package/src/checks/automatic/aria-hidden-body.js +3 -3
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +23 -18
- package/src/checks/automatic/aria-prohibited-children.js +136 -43
- package/src/checks/automatic/aria-required-attr.js +119 -24
- package/src/checks/automatic/aria-required-children.js +54 -30
- package/src/checks/automatic/aria-required-parent.js +93 -15
- package/src/checks/automatic/aria-role-name-present.js +37 -23
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -10
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +42 -0
- package/src/checks/automatic/contrast-enhanced.js +33 -1
- package/src/checks/automatic/contrast-minimum.js +33 -1
- package/src/checks/automatic/css-orientation-lock.js +138 -24
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +10 -3
- package/src/checks/automatic/duplicate-id.js +203 -0
- package/src/checks/automatic/embed-text-alternative-present.js +2 -2
- package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
- package/src/checks/automatic/form-control-single-label.js +10 -1
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +68 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +204 -68
- package/src/checks/automatic/link-in-text-block.js +285 -29
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- package/src/checks/automatic/page-title-present.js +21 -3
- package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
- package/src/checks/automatic/progressbar-name-present.js +23 -4
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
- package/src/checks/automatic/svg-text-alternative-present.js +13 -10
- package/src/checks/automatic/tab-name-present.js +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +155 -58
- package/src/checks/automatic/td-has-header.js +24 -23
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- package/src/checks/manual/area-alt-decorative-manual.js +7 -0
- package/src/checks/manual/area-alt-quality-manual.js +6 -0
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
- package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/empty-heading-manual.js +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +563 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- package/src/checks/manual/heading-quality-manual.js +338 -0
- package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
- package/src/checks/manual/image-redundant-alt-manual.js +4 -4
- package/src/checks/manual/img-alt-decorative-manual.js +211 -52
- package/src/checks/manual/img-alt-quality-manual.js +7 -0
- package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
- package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
- package/src/checks/manual/label-title-only-manual.js +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
- package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
- package/src/checks/manual/p-as-heading-manual.js +4 -4
- package/src/checks/manual/page-has-heading-one-manual.js +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- package/src/checks/manual/table-fake-caption-manual.js +2 -2
- package/src/checks/manual/video-caption-manual.js +3 -3
- package/src/checks/manual-review.js +17 -1
- package/src/core.js +8880 -41883
- package/src/earl.js +144 -0
- package/src/report.js +2 -2
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -37882
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- package/bin/surea11y-core.js +0 -20
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.3.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <td>/<th> elements that carry a non-empty headers attribute
|
|
12
|
+
* Applies to <td>/<th> elements that carry a non-empty headers attribute,
|
|
13
|
+
* within a <table> whose semantic role is still table/grid/treegrid --
|
|
14
|
+
* an explicit role of anything else (role="presentation"/"none", but
|
|
15
|
+
* also role="heading" or any other real role) replaces the native table
|
|
16
|
+
* semantics, leaving no table for headers to describe. Matches ACT
|
|
17
|
+
* a25f45's applicability.
|
|
13
18
|
* @expectation
|
|
14
19
|
* Every id token in the headers attribute resolves to an element that:
|
|
15
|
-
* (a) exists, (b) is a <
|
|
16
|
-
*
|
|
20
|
+
* (a) exists, (b) is a cell (<td> or <th>) of the same <table> as the
|
|
21
|
+
* referencing cell, and (c) is not the cell itself. A <td> serving as a
|
|
22
|
+
* header via role="columnheader"/"rowheader" is a valid target, same as
|
|
23
|
+
* a plain <th> -- ACT a25f45 does not require the native tag.
|
|
17
24
|
* @implementation-notes
|
|
18
25
|
* - One occurrence per offending cell (not per bad token), listing every
|
|
19
26
|
* invalid reference.
|
|
@@ -27,7 +34,7 @@ const id = 'table-headers-attr-valid';
|
|
|
27
34
|
const meta = {
|
|
28
35
|
title: 'Table cell "headers" attribute must reference valid header cells',
|
|
29
36
|
description:
|
|
30
|
-
'Checks that each id in a <td>/<th> headers attribute resolves to a <th>
|
|
37
|
+
'Checks that each id in a <td>/<th> headers attribute resolves to a cell (<td> or <th>) within the same table (not missing, not a non-cell element, not itself).',
|
|
31
38
|
i18n: {
|
|
32
39
|
titleKey: 'tableHeadersAttrValid_title',
|
|
33
40
|
descriptionKey: 'tableHeadersAttrValid_description'
|
|
@@ -54,6 +61,23 @@ const meta = {
|
|
|
54
61
|
function runInPage(ctx) {
|
|
55
62
|
const { document, helpers, rule } = ctx;
|
|
56
63
|
|
|
64
|
+
const ariaHelpers = helpers && helpers.aria ? helpers.aria : null;
|
|
65
|
+
|
|
66
|
+
// The role attribute holds a fallback list; the first token naming a real
|
|
67
|
+
// role wins, and unknown tokens are skipped over.
|
|
68
|
+
function getExplicitRole(el) {
|
|
69
|
+
const raw = el && el.getAttribute ? el.getAttribute('role') : null;
|
|
70
|
+
if (!raw) return '';
|
|
71
|
+
const tokens = String(raw).trim().toLowerCase().split(/\s+/);
|
|
72
|
+
for (const token of tokens) {
|
|
73
|
+
if (!token) continue;
|
|
74
|
+
if (token === 'presentation' || token === 'none') return token;
|
|
75
|
+
const known = ariaHelpers ? ariaHelpers.isValidConcreteRole(token) : true;
|
|
76
|
+
if (known) return token;
|
|
77
|
+
}
|
|
78
|
+
return '';
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
const nodes = helpers.queryAllSmart
|
|
58
82
|
? helpers.queryAllSmart('td[headers], th[headers]')
|
|
59
83
|
: helpers.queryAll('td[headers], th[headers]');
|
|
@@ -69,9 +93,19 @@ function runInPage(ctx) {
|
|
|
69
93
|
const ids = raw.split(/\s+/).filter(Boolean);
|
|
70
94
|
if (!ids.length) continue;
|
|
71
95
|
|
|
96
|
+
const table = el.closest ? el.closest('table') : null;
|
|
97
|
+
|
|
98
|
+
// An explicit role on the <table> replaces its native table role. Only
|
|
99
|
+
// the three roles that still describe a table keep the cell's headers
|
|
100
|
+
// attribute meaningful; anything else (presentation/none, heading, ...)
|
|
101
|
+
// takes the whole table out of scope. Unknown tokens name no role, so
|
|
102
|
+
// the native one stands.
|
|
103
|
+
const tableRole = table ? getExplicitRole(table) : '';
|
|
104
|
+
if (tableRole && tableRole !== 'table' && tableRole !== 'grid' && tableRole !== 'treegrid')
|
|
105
|
+
continue;
|
|
106
|
+
|
|
72
107
|
applicableCount += 1;
|
|
73
108
|
|
|
74
|
-
const table = el.closest ? el.closest('table') : null;
|
|
75
109
|
const invalid = [];
|
|
76
110
|
|
|
77
111
|
for (const headerId of ids) {
|
|
@@ -90,8 +124,9 @@ function runInPage(ctx) {
|
|
|
90
124
|
invalid.push({ id: headerId, reason: 'self-reference' });
|
|
91
125
|
continue;
|
|
92
126
|
}
|
|
93
|
-
|
|
94
|
-
|
|
127
|
+
const refTag = ref.tagName ? ref.tagName.toLowerCase() : '';
|
|
128
|
+
if (refTag !== 'th' && refTag !== 'td') {
|
|
129
|
+
invalid.push({ id: headerId, reason: 'not-a-cell' });
|
|
95
130
|
continue;
|
|
96
131
|
}
|
|
97
132
|
if (table && (!ref.closest || ref.closest('table') !== table)) {
|
|
@@ -108,7 +143,7 @@ function runInPage(ctx) {
|
|
|
108
143
|
occurrences.push(
|
|
109
144
|
helpers.reportOccurrence(el, {
|
|
110
145
|
summary: 'This cell’s headers attribute references one or more invalid header cells.',
|
|
111
|
-
hint: 'Update the headers attribute so every id refers to a <th>
|
|
146
|
+
hint: 'Update the headers attribute so every id refers to a cell (<td> or <th>) within the same table.',
|
|
112
147
|
i18n: {
|
|
113
148
|
summaryKey: 'tableHeadersAttrValid_summary_fail',
|
|
114
149
|
hintKey: 'tableHeadersAttrValid_hint_fail',
|
|
@@ -12,26 +12,29 @@
|
|
|
12
12
|
* Applies to <table> elements that keep their table semantics and are included
|
|
13
13
|
* in the accessibility tree, and that contain at least one <th> which is
|
|
14
14
|
* visible, included in the accessibility tree, and not overridden by an
|
|
15
|
-
* explicit role other than rowheader/columnheader.
|
|
15
|
+
* explicit role other than rowheader/columnheader. Also applies to the
|
|
16
|
+
* ARIA-only equivalent: an element with role="grid"/"treegrid" (no
|
|
17
|
+
* native <table> involved) that contains at least one in-scope
|
|
18
|
+
* columnheader/rowheader-role element.
|
|
16
19
|
* @expectation
|
|
17
20
|
* The table also contains at least one <td> somewhere in it.
|
|
18
21
|
* @implementation-notes
|
|
19
|
-
* -
|
|
22
|
+
* - SCOPED (see aria-helpers.js file header for the same
|
|
20
23
|
* conservative-scope rationale used throughout this engine): this rule
|
|
21
24
|
* does NOT implement the full HTML5 header-association algorithm
|
|
22
25
|
* (resolving which specific data cells a given <th scope="row"|"col">
|
|
23
26
|
* describes, accounting for colspan/rowspan and default-scope
|
|
24
27
|
* inference). That algorithm is one of the more error-prone parts of
|
|
25
28
|
* the HTML spec to reimplement correctly, and a wrong positional match
|
|
26
|
-
* would risk a false `fail
|
|
29
|
+
* would risk a false `fail`, unacceptable for this engine's fail-
|
|
27
30
|
* integrity bar.
|
|
28
31
|
* - Instead, this rule only catches the single unambiguous case: a table
|
|
29
32
|
* that has <th> elements but ZERO <td> elements anywhere. In that case
|
|
30
|
-
* every <th> in the table trivially describes no data cell
|
|
33
|
+
* every <th> in the table trivially describes no data cell, no
|
|
31
34
|
* positional analysis is needed to know that. A table that has at
|
|
32
35
|
* least one <td> somewhere is not evaluated further by this rule, even
|
|
33
36
|
* if some particular <th> in it doesn't actually describe any cell
|
|
34
|
-
* (false negative, not a false positive
|
|
37
|
+
* (false negative, not a false positive, acceptable under this
|
|
35
38
|
* engine's philosophy).
|
|
36
39
|
* - Applicability IS gated, because the zero-<td> case above turns any
|
|
37
40
|
* over-broad scope straight into a false fail: a layout table marked
|
|
@@ -174,6 +177,59 @@ function runInPage(ctx) {
|
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
// ARIA-only equivalent: a role="grid"/"treegrid" container with no
|
|
181
|
+
// backing <table> at all. Same trivial "zero data cells anywhere" check,
|
|
182
|
+
// just keyed off ARIA roles instead of native tags -- a genuine
|
|
183
|
+
// columnheader/rowheader with zero gridcell/cell-role elements anywhere
|
|
184
|
+
// in the container is exactly as unambiguous as a <th>-only <table>.
|
|
185
|
+
const grids = helpers.queryAllSmart
|
|
186
|
+
? helpers.queryAllSmart('[role="grid"], [role="treegrid"]')
|
|
187
|
+
: helpers.queryAll('[role="grid"], [role="treegrid"]');
|
|
188
|
+
|
|
189
|
+
for (const grid of grids) {
|
|
190
|
+
if (!grid || !grid.querySelectorAll) continue;
|
|
191
|
+
if (grid.tagName && grid.tagName.toLowerCase() === 'table') continue; // already handled above
|
|
192
|
+
if (!isIncludedInTree(grid)) continue;
|
|
193
|
+
|
|
194
|
+
let headerNodes;
|
|
195
|
+
try {
|
|
196
|
+
headerNodes = grid.querySelectorAll('[role="columnheader"], [role="rowheader"]');
|
|
197
|
+
} catch {
|
|
198
|
+
headerNodes = [];
|
|
199
|
+
}
|
|
200
|
+
const headers = Array.from(headerNodes).filter(isHeaderCellInScope);
|
|
201
|
+
if (!headers.length) continue;
|
|
202
|
+
|
|
203
|
+
applicableCount += 1;
|
|
204
|
+
|
|
205
|
+
let hasDataCell;
|
|
206
|
+
try {
|
|
207
|
+
hasDataCell = grid.querySelectorAll('[role="gridcell"], [role="cell"]').length > 0;
|
|
208
|
+
} catch {
|
|
209
|
+
hasDataCell = false;
|
|
210
|
+
}
|
|
211
|
+
if (hasDataCell) continue;
|
|
212
|
+
|
|
213
|
+
for (const th of headers) {
|
|
214
|
+
if (!th) continue;
|
|
215
|
+
|
|
216
|
+
occurrences.push(
|
|
217
|
+
helpers.reportOccurrence(th, {
|
|
218
|
+
summary: 'This table has header cells but no data cells for them to describe.',
|
|
219
|
+
hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
|
|
220
|
+
i18n: {
|
|
221
|
+
summaryKey: 'tableThHasDataCells_summary_fail',
|
|
222
|
+
hintKey: 'tableThHasDataCells_hint_fail',
|
|
223
|
+
params: {}
|
|
224
|
+
},
|
|
225
|
+
data: {
|
|
226
|
+
details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
177
233
|
if (applicableCount === 0) {
|
|
178
234
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
179
235
|
}
|
|
@@ -8,6 +8,24 @@
|
|
|
8
8
|
* @summary Pointer-operable targets should be at least 24×24 CSS px (or meet an exception)
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 2.5.8
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to <button>, <summary>, <a href>, <area href>, <input>,
|
|
13
|
+
* <select>, <textarea> and elements with role="button"/"link" that are
|
|
14
|
+
* pointer-reachable: rendered, not suppressed by pointer-events:none, and
|
|
15
|
+
* with a measurable box of non-zero size. Accessibility-tree exclusion isn't
|
|
16
|
+
* a filter here: an aria-hidden control is still a target a pointer can hit.
|
|
17
|
+
* <area> is matched but never actually evaluated, for the reason given below.
|
|
18
|
+
* @expectation
|
|
19
|
+
* Each target is at least 24 by 24 CSS pixels, or meets one of the SC
|
|
20
|
+
* 2.5.8 exceptions this rule can establish from geometry: spacing (a
|
|
21
|
+
* 24px-diameter circle centred on the target reaches no unrelated target),
|
|
22
|
+
* the inline exception for a link inside a run of text, or user-agent
|
|
23
|
+
* sizing (an unstyled native checkbox or radio, detected by appearance not
|
|
24
|
+
* having been reset to none). An undersized target too close to a
|
|
25
|
+
* neighbour fails. Where an exception may apply but geometry cannot
|
|
26
|
+
* confirm it (two inline links in one run of text, or a target inside an
|
|
27
|
+
* SVG, canvas or image map that may be essential), the result is cantTell
|
|
28
|
+
* rather than a guess.
|
|
11
29
|
*
|
|
12
30
|
* Notes (engine intent):
|
|
13
31
|
* - This rule is DOM-based and measures pointer hit regions available to sighted pointer users.
|
|
@@ -19,42 +37,43 @@
|
|
|
19
37
|
* - Spacing: a 24px-diameter circle centered on an undersized target must not
|
|
20
38
|
* intersect another (unrelated) target's box or another undersized
|
|
21
39
|
* target's own circle. Two passes: a fast center-distance check (exact for
|
|
22
|
-
* undersized-vs-undersized
|
|
40
|
+
* undersized-vs-undersized, a reasonable proxy otherwise) and a 16-point
|
|
23
41
|
* perimeter sample via elementFromPoint as a more precise fallback for
|
|
24
42
|
* cases the distance check under-detects (e.g. a small target adjacent to
|
|
25
43
|
* a large, elongated neighbor). Ancestor/descendant relationships between
|
|
26
|
-
* the target and the "other" element are never treated as a conflict
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
44
|
+
* the target and the "other" element are never treated as a conflict (see
|
|
45
|
+
* isRelated): a nested-interactive shape, a small control inside its own
|
|
46
|
+
* wrapping link/button, is one visual region, not two independent targets.
|
|
47
|
+
* That pattern is nested-interactive-controls-absent's concern, not a
|
|
48
|
+
* spacing one.
|
|
31
49
|
* - Inline: a link inside a text-block container passes outright
|
|
32
50
|
* (isInlineTextExceptionTarget). An inline link whose only spacing conflict
|
|
33
51
|
* is another inline link in the same run is reported as cantTell
|
|
34
|
-
* (isInlineLinkTarget)
|
|
35
|
-
* can't confirm that.
|
|
52
|
+
* (isInlineLinkTarget), since the inline exception may cover it but
|
|
53
|
+
* geometry can't confirm that.
|
|
36
54
|
* - User Agent Control: an unstyled native checkbox/radio, detected via
|
|
37
|
-
* `appearance` not being reset to `none` (see isUserAgentSizedControl)
|
|
38
|
-
*
|
|
55
|
+
* `appearance` not being reset to `none` (see isUserAgentSizedControl).
|
|
56
|
+
* Scoped narrowly to checkbox/radio specifically, not every form control,
|
|
39
57
|
* since those are the only types with unambiguous native rendering.
|
|
40
58
|
* - Essential/Equivalent: only a narrow, high-confidence subset is asserted
|
|
41
|
-
* (SVG/canvas/map-embedded controls
|
|
59
|
+
* (SVG/canvas/map-embedded controls, see isPlausiblyEssentialOrEquivalent);
|
|
42
60
|
* anything else defers to cantTell rather than guessing "essential" from a
|
|
43
61
|
* layout container.
|
|
44
62
|
*
|
|
45
|
-
* Known,
|
|
63
|
+
* Known gap, left unimplemented on purpose: `<area>` (image-map hotspot)
|
|
46
64
|
* elements are not evaluated at all. `area[href]` is in CANDIDATE_SELECTOR
|
|
47
|
-
* for forward-compatibility, but it's currently a no-op
|
|
65
|
+
* for forward-compatibility, but it's currently a no-op. `<area>` has no
|
|
48
66
|
* CSS box of its own (`display: none` by the HTML spec's default UA
|
|
49
|
-
* stylesheet
|
|
50
|
-
* always reports zero geometry and
|
|
51
|
-
* `display:none` check rejects it before any
|
|
52
|
-
* real `<area>` hit-region is computed by the
|
|
53
|
-
* `coords` attributes against the associated
|
|
54
|
-
* an entirely different measurement path than
|
|
55
|
-
* Implementing that properly (parsing `coords`,
|
|
56
|
-
* `<img>` via its `usemap`, accounting for the image's
|
|
57
|
-
* size) is a separate, larger feature, not attempted in
|
|
67
|
+
* stylesheet, confirmed against the spec rather than a jsdom quirk), so
|
|
68
|
+
* `getBoundingClientRect()` always reports zero geometry and
|
|
69
|
+
* `isPointerReachable`'s existing `display:none` check rejects it before any
|
|
70
|
+
* size/exception logic runs. A real `<area>` hit-region is computed by the
|
|
71
|
+
* browser from its `shape`/`coords` attributes against the associated
|
|
72
|
+
* `<img>`'s *rendered* size, an entirely different measurement path than
|
|
73
|
+
* every other candidate here. Implementing that properly (parsing `coords`,
|
|
74
|
+
* resolving the owning `<img>` via its `usemap`, accounting for the image's
|
|
75
|
+
* CSS-scaled render size) is a separate, larger feature, not attempted in
|
|
76
|
+
* this pass.
|
|
58
77
|
*
|
|
59
78
|
* This is an automatic, deterministic approximation intended to be:
|
|
60
79
|
* - strict on clear failures,
|
|
@@ -64,7 +83,8 @@
|
|
|
64
83
|
const id = 'target-size-minimum';
|
|
65
84
|
|
|
66
85
|
const meta = {
|
|
67
|
-
title:
|
|
86
|
+
title:
|
|
87
|
+
'Pointer targets must be at least 24x24px large, or leave sufficient distance to other targets',
|
|
68
88
|
description:
|
|
69
89
|
'Checks that pointer-operable targets have an effective hit region of at least 24 by 24 CSS pixels, or meet an allowed exception (e.g. sufficient spacing).',
|
|
70
90
|
i18n: {
|
|
@@ -273,14 +293,29 @@ function runInPage(ctx) {
|
|
|
273
293
|
}
|
|
274
294
|
}
|
|
275
295
|
|
|
296
|
+
// Per-run memoization: getComputedStyle is a real, non-trivial cost in an
|
|
297
|
+
// actual browser (unlike jsdom, which no-ops it), and hasSpacingConflict
|
|
298
|
+
// below calls getStyle on the SAME candidate element repeatedly -- once
|
|
299
|
+
// per undersized target it's compared against, via
|
|
300
|
+
// isInlineTextExceptionTarget/isInlineLinkTarget -- so with U undersized
|
|
301
|
+
// targets and N total candidates this was an uncached O(U * N)
|
|
302
|
+
// getComputedStyle call count. The DOM is read-only for the rest of this
|
|
303
|
+
// rule's run (no writes between reads), so caching per element here is
|
|
304
|
+
// safe -- style cannot change mid-run.
|
|
305
|
+
const __styleCache = new WeakMap();
|
|
276
306
|
function getStyle(el) {
|
|
307
|
+
if (__styleCache.has(el)) return __styleCache.get(el);
|
|
308
|
+
let cs;
|
|
277
309
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
310
|
+
cs =
|
|
311
|
+
document && document.defaultView && document.defaultView.getComputedStyle
|
|
312
|
+
? document.defaultView.getComputedStyle(el)
|
|
313
|
+
: null;
|
|
281
314
|
} catch {
|
|
282
|
-
|
|
315
|
+
cs = null;
|
|
283
316
|
}
|
|
317
|
+
__styleCache.set(el, cs);
|
|
318
|
+
return cs;
|
|
284
319
|
}
|
|
285
320
|
|
|
286
321
|
function isPointerReachable(el) {
|
|
@@ -354,11 +389,11 @@ function runInPage(ctx) {
|
|
|
354
389
|
// element, or either one is an ancestor of the other. A nested-interactive
|
|
355
390
|
// pattern (e.g. a small <button> inside a wrapping <a href>, or vice
|
|
356
391
|
// versa) is a single visual/interactive region, not two independently
|
|
357
|
-
// placed targets
|
|
392
|
+
// placed targets. The spacing exception's "does the circle intersect
|
|
358
393
|
// ANOTHER target" language is about separate targets, not an element and
|
|
359
394
|
// its own container. (Nested interactive controls are their own,
|
|
360
|
-
// separately-flagged anti-pattern
|
|
361
|
-
//
|
|
395
|
+
// separately-flagged anti-pattern, nested-interactive-controls-absent,
|
|
396
|
+
// not a target-size spacing concern.)
|
|
362
397
|
function isRelated(a, b) {
|
|
363
398
|
try {
|
|
364
399
|
if (!a || !b) return false;
|
|
@@ -411,14 +446,54 @@ function runInPage(ctx) {
|
|
|
411
446
|
|
|
412
447
|
const undersized = items.filter((it) => it.rect.width < MIN || it.rect.height < MIN);
|
|
413
448
|
|
|
449
|
+
// Spatial grid over ALL items, cell size = MIN (24px), so hasSpacingConflict's
|
|
450
|
+
// proximity check below doesn't have to compare every undersized target
|
|
451
|
+
// against every other item -- an O(items^2) cost that dominates real-browser
|
|
452
|
+
// (not jsdom -- see this rule's own perf note further down) runtime on a
|
|
453
|
+
// page with many small targets. A point can only be within MIN of another
|
|
454
|
+
// point that shares its grid cell or one of the 8 adjacent cells: cell size
|
|
455
|
+
// equals the search radius, so two points in cells 2+ apart on either axis
|
|
456
|
+
// are, on that axis alone, already >= MIN apart. Restricting the candidate
|
|
457
|
+
// set to that 3x3 neighborhood is therefore never a false negative -- the
|
|
458
|
+
// exact same dist() < MIN check still runs on every candidate it returns,
|
|
459
|
+
// just skipping candidates that are geometrically guaranteed too far away.
|
|
460
|
+
// This changes performance only, never which targets conflict.
|
|
461
|
+
const grid = new Map(); // "cx,cy" -> item[]
|
|
462
|
+
function cellKeyFor(cx, cy) {
|
|
463
|
+
return cx + ',' + cy;
|
|
464
|
+
}
|
|
465
|
+
for (const it of items) {
|
|
466
|
+
const cx = Math.floor(it.center.cx / MIN);
|
|
467
|
+
const cy = Math.floor(it.center.cy / MIN);
|
|
468
|
+
const key = cellKeyFor(cx, cy);
|
|
469
|
+
let bucket = grid.get(key);
|
|
470
|
+
if (!bucket) {
|
|
471
|
+
bucket = [];
|
|
472
|
+
grid.set(key, bucket);
|
|
473
|
+
}
|
|
474
|
+
bucket.push(it);
|
|
475
|
+
}
|
|
476
|
+
function nearbyItems(center) {
|
|
477
|
+
const cx = Math.floor(center.cx / MIN);
|
|
478
|
+
const cy = Math.floor(center.cy / MIN);
|
|
479
|
+
const out = [];
|
|
480
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
481
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
482
|
+
const bucket = grid.get(cellKeyFor(cx + dx, cy + dy));
|
|
483
|
+
if (bucket) for (const it of bucket) out.push(it);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
|
|
414
489
|
// --- spacing/occlusion evaluation ---
|
|
415
490
|
function hasSpacingConflict(target) {
|
|
416
491
|
// 0) Pure geometry: deterministic center-distance check against ANY
|
|
417
492
|
// nearby target, not just other undersized ones. Per WCAG 2.5.8, the
|
|
418
|
-
// spacing exception depends on proximity to any adjacent target
|
|
493
|
+
// spacing exception depends on proximity to any adjacent target, so an
|
|
419
494
|
// undersized target sitting flush against an adequately-sized one still
|
|
420
495
|
// fails the exception, which an undersized-only comparison would miss.
|
|
421
|
-
for (const other of
|
|
496
|
+
for (const other of nearbyItems(target.center)) {
|
|
422
497
|
if (!other || !other.el || isRelated(target.el, other.el)) continue;
|
|
423
498
|
|
|
424
499
|
// Ignore inline-text exception targets when evaluating spacing conflicts.
|
|
@@ -439,8 +514,8 @@ function runInPage(ctx) {
|
|
|
439
514
|
// as a confident conflict. Perimeter sampling is an approximation
|
|
440
515
|
// (rounded corners, border-radius, and sub-pixel geometry can shift a
|
|
441
516
|
// sample point in or out of a neighboring element), so a result that
|
|
442
|
-
// merely reaches HIT_THRESHOLD is not asserted as a deterministic
|
|
443
|
-
//
|
|
517
|
+
// merely reaches HIT_THRESHOLD is not asserted as a deterministic fail.
|
|
518
|
+
// See the ambiguous band below.
|
|
444
519
|
const CONFIDENT_THRESHOLD = 5;
|
|
445
520
|
let hitCount = 0;
|
|
446
521
|
let firstConflictEl = null;
|
|
@@ -486,20 +561,20 @@ function runInPage(ctx) {
|
|
|
486
561
|
|
|
487
562
|
// WCAG 2.5.8 "User Agent Control" exception: the target's size requirement
|
|
488
563
|
// does not apply at all when its size is determined by the user agent and
|
|
489
|
-
// not modified by the author
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
// input[type=checkbox]/[type=radio] specifically
|
|
493
|
-
// types with a universally-recognized, unambiguous native rendering
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
564
|
+
// not modified by the author. The canonical example is an unstyled native
|
|
565
|
+
// checkbox/radio (browsers render these well under 24px by default, and
|
|
566
|
+
// that's not the author's choice). Scoped narrowly to
|
|
567
|
+
// input[type=checkbox]/[type=radio] specifically, the only form-control
|
|
568
|
+
// types with a universally-recognized, unambiguous native rendering, and
|
|
569
|
+
// not extended to select/range/color/file, whose "default" sizing varies
|
|
570
|
+
// enough across browsers/OSes that a wrong exemption there risks masking a
|
|
571
|
+
// real author-introduced undersized target.
|
|
497
572
|
//
|
|
498
573
|
// Detection signal: `appearance` (or the legacy `-webkit-appearance`)
|
|
499
574
|
// computed as `none` is the near-universal first step of custom
|
|
500
|
-
// checkbox/radio styling across every CSS framework/design system
|
|
501
|
-
//
|
|
502
|
-
//
|
|
575
|
+
// checkbox/radio styling across every CSS framework/design system. If the
|
|
576
|
+
// author hasn't reset it, the browser is still rendering its own default
|
|
577
|
+
// control chrome, so the size is UA-determined rather than authored.
|
|
503
578
|
function isUserAgentSizedControl(el) {
|
|
504
579
|
try {
|
|
505
580
|
if (!el || el.nodeType !== 1) return false;
|
|
@@ -545,8 +620,8 @@ function runInPage(ctx) {
|
|
|
545
620
|
// Image map targets are often constrained by the underlying image.
|
|
546
621
|
// Currently unreachable in practice: <area> never becomes a
|
|
547
622
|
// measurable candidate at all (see the file header's "Known,
|
|
548
|
-
//
|
|
549
|
-
//
|
|
623
|
+
// unimplemented gap" note). Kept for forward compatibility if that
|
|
624
|
+
// gap is closed later.
|
|
550
625
|
if (tag === 'area') return true;
|
|
551
626
|
|
|
552
627
|
// Graphics / spatial interaction regions are commonly essential by design.
|
|
@@ -570,7 +645,7 @@ function runInPage(ctx) {
|
|
|
570
645
|
|
|
571
646
|
// User Agent Control exception: size isn't the author's choice, so the
|
|
572
647
|
// size requirement (and therefore any spacing conflict stemming from
|
|
573
|
-
// it) doesn't apply at all
|
|
648
|
+
// it) doesn't apply at all. Skip straight to pass, no need to even
|
|
574
649
|
// evaluate spacing.
|
|
575
650
|
if (isUserAgentSizedControl(it.el)) {
|
|
576
651
|
continue;
|
|
@@ -579,12 +654,10 @@ function runInPage(ctx) {
|
|
|
579
654
|
const info = hasSpacingConflict(it);
|
|
580
655
|
|
|
581
656
|
if (!info.conflict && info.confident === false) {
|
|
582
|
-
// Ambiguous perimeter-sampling result near the decision threshold
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
// fail (see helpers.resolveTieredOutcome's header comment). Now
|
|
587
|
-
// reported as its own cantTell-tier occurrence instead.
|
|
657
|
+
// Ambiguous perimeter-sampling result near the decision threshold: report
|
|
658
|
+
// it as its own cantTell-tier occurrence for this target so it isn't lost
|
|
659
|
+
// once any other target on the page has a confident fail (see
|
|
660
|
+
// helpers.resolveTieredOutcome's header comment).
|
|
588
661
|
cantTellOccurrences.push(
|
|
589
662
|
helpers.reportOccurrence(it.el, {
|
|
590
663
|
occurrenceOutcome: 'cantTell',
|
|
@@ -596,6 +669,14 @@ function runInPage(ctx) {
|
|
|
596
669
|
hintKey: 'targetSizeMinimum_hint_cantTell_ambiguousSpacing',
|
|
597
670
|
params: {}
|
|
598
671
|
},
|
|
672
|
+
uncertainty: {
|
|
673
|
+
code: 'not-computable',
|
|
674
|
+
needed: 'A reliable measurement of the spacing between this target and its neighbour.',
|
|
675
|
+
evidence: {
|
|
676
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
677
|
+
conflictHitCount: info.hitCount
|
|
678
|
+
}
|
|
679
|
+
},
|
|
599
680
|
data: {
|
|
600
681
|
details: {
|
|
601
682
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -612,19 +693,27 @@ function runInPage(ctx) {
|
|
|
612
693
|
if (info.conflict) {
|
|
613
694
|
if (isPlausiblyEssentialOrEquivalent(it.el)) {
|
|
614
695
|
// Confident spacing conflict, but the target may be exempt as part
|
|
615
|
-
// of an essential graphic/image-map region
|
|
616
|
-
//
|
|
696
|
+
// of an essential graphic/image-map region, so report it as
|
|
697
|
+
// cantTell rather than dropping it.
|
|
617
698
|
cantTellOccurrences.push(
|
|
618
699
|
helpers.reportOccurrence(it.el, {
|
|
619
700
|
occurrenceOutcome: 'cantTell',
|
|
620
701
|
summary:
|
|
621
702
|
'Target is too small and too close to another target, but may be exempt as part of an essential graphic or image-map region.',
|
|
622
|
-
hint: 'Verify whether this target’s size is
|
|
703
|
+
hint: 'Verify whether this target’s size is essential to its function (e.g. part of an SVG/canvas/image map); if not, increase target size or spacing.',
|
|
623
704
|
i18n: {
|
|
624
705
|
summaryKey: 'targetSizeMinimum_summary_cantTell_plausiblyEssential',
|
|
625
706
|
hintKey: 'targetSizeMinimum_hint_cantTell_plausiblyEssential',
|
|
626
707
|
params: {}
|
|
627
708
|
},
|
|
709
|
+
uncertainty: {
|
|
710
|
+
code: 'judgement-required',
|
|
711
|
+
needed: 'Whether this target’s size is essential, which WCAG exempts.',
|
|
712
|
+
evidence: {
|
|
713
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
714
|
+
conflictHitCount: info.hitCount
|
|
715
|
+
}
|
|
716
|
+
},
|
|
628
717
|
data: {
|
|
629
718
|
details: {
|
|
630
719
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -653,6 +742,14 @@ function runInPage(ctx) {
|
|
|
653
742
|
hintKey: 'targetSizeMinimum_hint_cantTell_inlineLinkRun',
|
|
654
743
|
params: {}
|
|
655
744
|
},
|
|
745
|
+
uncertainty: {
|
|
746
|
+
code: 'judgement-required',
|
|
747
|
+
needed: 'Whether this target is a link in a sentence, which WCAG exempts.',
|
|
748
|
+
evidence: {
|
|
749
|
+
measured: { width: it.rect.width, height: it.rect.height },
|
|
750
|
+
conflictHitCount: info.hitCount
|
|
751
|
+
}
|
|
752
|
+
},
|
|
656
753
|
data: {
|
|
657
754
|
details: {
|
|
658
755
|
measured: { width: it.rect.width, height: it.rect.height },
|
|
@@ -691,7 +788,7 @@ function runInPage(ctx) {
|
|
|
691
788
|
|
|
692
789
|
// See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
|
|
693
790
|
// a fail-tier finding never silently discards cantTell-tier findings from
|
|
694
|
-
// the same run
|
|
791
|
+
// the same run. Both are returned together when the outcome is 'fail'.
|
|
695
792
|
const resolved = helpers.resolveTieredOutcome(
|
|
696
793
|
failOccurrences,
|
|
697
794
|
cantTellOccurrences,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @applicability
|
|
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
15
|
* NO `colspan`/`rowspan` anywhere in the table.
|
|
16
16
|
* @expectation
|
|
17
17
|
* Every `<td>` has an associated header, via one of:
|
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
* earlier row, OR
|
|
23
23
|
* - an implicit row header: some `<th>` earlier in the same row.
|
|
24
24
|
* @implementation-notes
|
|
25
|
-
* - Closes the gap `table-th-has-data-cells`
|
|
25
|
+
* - Closes the gap `table-th-has-data-cells` deferred (see
|
|
26
26
|
* that rule's own implementation notes): this is the fuller positional
|
|
27
|
-
* header-association algorithm, but still intentionally scoped
|
|
27
|
+
* header-association algorithm, but still intentionally scoped,
|
|
28
28
|
* tables with any `colspan`/`rowspan` are skipped entirely (marked
|
|
29
29
|
* `notApplicable`) rather than risk a wrong column-index computation
|
|
30
30
|
* producing a false `fail`.
|
|
31
31
|
* - The `headers`-attribute branch does not itself validate that the
|
|
32
|
-
* referenced ids exist or point at `<th>` elements
|
|
32
|
+
* referenced ids exist or point at `<th>` elements, that's already
|
|
33
33
|
* `table-headers-attr-valid`'s job.
|
|
34
34
|
*/
|
|
35
35
|
|
|
@@ -38,7 +38,7 @@ const id = 'td-has-header';
|
|
|
38
38
|
const meta = {
|
|
39
39
|
title: 'Data cells in large tables must have an associated header',
|
|
40
40
|
description:
|
|
41
|
-
'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header
|
|
41
|
+
'Checks that every <td> in a large, simple (no colspan/rowspan) table has an associated header, via a headers attribute, an implicit column <th> above it, or an implicit row <th> to its left.',
|
|
42
42
|
i18n: {
|
|
43
43
|
titleKey: 'tdHasHeader_title',
|
|
44
44
|
descriptionKey: 'tdHasHeader_description'
|
|
@@ -118,27 +118,28 @@ function runInPage(ctx) {
|
|
|
118
118
|
return !!(cell && cell.tagName && cell.tagName.toLowerCase() === 'th' && isEligible(cell));
|
|
119
119
|
}
|
|
120
120
|
|
|
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
|
-
}
|
|
121
|
+
// "Was there a <th> above this cell's column" and "was there a <th>
|
|
122
|
+
// earlier in this cell's row" are both prefix questions over a scan
|
|
123
|
+
// already in progress (rows top to bottom, cells left to right within a
|
|
124
|
+
// row), so each is tracked incrementally instead of rescanning the
|
|
125
|
+
// rows/columns already passed for every cell: colHasHeaderAbove[c]
|
|
126
|
+
// carries forward across rows, rowHasHeaderBefore resets at the start
|
|
127
|
+
// of each row. One pass over every cell, not one rescan per cell.
|
|
128
|
+
const colHasHeaderAbove = new Array(maxCols).fill(false);
|
|
136
129
|
|
|
137
130
|
for (let r = 0; r < rowCells.length; r++) {
|
|
138
131
|
const cells = rowCells[r];
|
|
132
|
+
let rowHasHeaderBefore = false;
|
|
133
|
+
|
|
139
134
|
for (let c = 0; c < cells.length; c++) {
|
|
140
135
|
const cell = cells[c];
|
|
141
|
-
if (!cell
|
|
136
|
+
if (!cell) continue;
|
|
137
|
+
|
|
138
|
+
if (isHeaderCell(cell)) {
|
|
139
|
+
colHasHeaderAbove[c] = true;
|
|
140
|
+
rowHasHeaderBefore = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
142
143
|
|
|
143
144
|
// An aria-hidden data cell isn't exposed to AT either, so it has
|
|
144
145
|
// no need for an accessible header association.
|
|
@@ -147,8 +148,8 @@ function runInPage(ctx) {
|
|
|
147
148
|
const headersAttr = trim(cell.getAttribute('headers'));
|
|
148
149
|
if (headersAttr) continue;
|
|
149
150
|
|
|
150
|
-
if (
|
|
151
|
-
if (
|
|
151
|
+
if (colHasHeaderAbove[c]) continue;
|
|
152
|
+
if (rowHasHeaderBefore) continue;
|
|
152
153
|
|
|
153
154
|
occurrences.push(
|
|
154
155
|
helpers.reportOccurrence(cell, {
|