@surea11y/core 1.4.1 → 1.6.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 +212 -128
- package/README.md +46 -9
- package/docs/ACT_RULE_MAPPING.md +243 -0
- package/docs/API_STABILITY.md +4 -4
- package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
- package/docs/DESIGN_CHALLENGES.md +301 -0
- package/docs/ENGINE_OPTIONS.md +30 -14
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +29 -7
- package/docs/LIMITATIONS.md +6 -4
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +3 -1
- package/docs/RULE_AUTHORING.md +104 -23
- package/docs/RULE_CATALOG.md +1878 -169
- package/docs/RULE_TAXONOMY.md +2 -2
- package/docs/TROUBLESHOOTING.md +4 -4
- package/docs/WCAG_CONFORMANCE.md +25 -9
- package/package.json +8 -7
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +95 -40
- package/src/checks/automatic/aria-allowed-role.js +16 -18
- package/src/checks/automatic/aria-braille-equivalent.js +19 -21
- package/src/checks/automatic/aria-conditional-attr.js +22 -24
- package/src/checks/automatic/aria-deprecated-role.js +63 -50
- package/src/checks/automatic/aria-hidden-body.js +4 -11
- package/src/checks/automatic/aria-hidden-focus.js +104 -23
- package/src/checks/automatic/aria-prohibited-attr.js +71 -72
- package/src/checks/automatic/aria-prohibited-children.js +154 -61
- package/src/checks/automatic/aria-required-attr.js +74 -29
- package/src/checks/automatic/aria-required-children.js +38 -34
- package/src/checks/automatic/aria-required-parent.js +78 -29
- package/src/checks/automatic/aria-role-name-present.js +36 -22
- package/src/checks/automatic/aria-roles-valid.js +37 -23
- package/src/checks/automatic/aria-valid-attr-value.js +33 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -18
- package/src/checks/automatic/autocomplete-valid.js +17 -19
- package/src/checks/automatic/avoid-inline-spacing.js +14 -16
- package/src/checks/automatic/binary-control-name-present.js +46 -26
- package/src/checks/automatic/button-name-present.js +115 -34
- package/src/checks/automatic/combobox-name-present.js +40 -22
- package/src/checks/automatic/contrast-computable.js +32 -0
- package/src/checks/automatic/contrast-enhanced.js +21 -1
- package/src/checks/automatic/contrast-minimum.js +21 -1
- package/src/checks/automatic/css-orientation-lock.js +118 -41
- package/src/checks/automatic/definition-list-children-valid.js +25 -29
- package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
- package/src/checks/automatic/dialog-name-present.js +36 -20
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +50 -40
- package/src/checks/automatic/duplicate-id.js +198 -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 +39 -41
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +92 -38
- package/src/checks/automatic/iframe-name-present.js +53 -21
- package/src/checks/automatic/iframe-title-unique.js +19 -24
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +198 -49
- package/src/checks/automatic/link-in-text-block.js +29 -31
- package/src/checks/automatic/link-name-present.js +47 -31
- package/src/checks/automatic/list-children-valid.js +21 -23
- package/src/checks/automatic/listbox-name-present.js +42 -24
- package/src/checks/automatic/listitem-parent-valid.js +18 -21
- package/src/checks/automatic/menuitem-name-present.js +36 -20
- package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
- package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
- package/src/checks/automatic/meter-name-present.js +38 -21
- package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
- package/src/checks/automatic/option-name-present.js +39 -22
- 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 +41 -24
- package/src/checks/automatic/role-img-alt-present.js +64 -16
- package/src/checks/automatic/searchbox-name-present.js +46 -24
- package/src/checks/automatic/server-side-image-map-absent.js +16 -19
- package/src/checks/automatic/slider-name-present.js +42 -23
- package/src/checks/automatic/spinbutton-name-present.js +46 -24
- package/src/checks/automatic/summary-name-present.js +34 -20
- 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 +37 -20
- package/src/checks/automatic/table-headers-attr-valid.js +57 -24
- package/src/checks/automatic/table-th-has-data-cells.js +76 -24
- package/src/checks/automatic/target-size-minimum.js +172 -131
- package/src/checks/automatic/td-has-header.js +20 -25
- package/src/checks/automatic/textbox-name-present.js +42 -24
- package/src/checks/automatic/tooltip-name-present.js +37 -20
- package/src/checks/automatic/treeitem-name-present.js +39 -22
- package/src/checks/automatic/valid-lang.js +107 -24
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +21 -22
- 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 +23 -26
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
- 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 +73 -28
- package/src/checks/manual/empty-table-header-manual.js +33 -36
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +453 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
- package/src/checks/manual/heading-order-manual.js +20 -25
- 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 +18 -21
- 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 +19 -21
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
- package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
- package/src/checks/manual/landmark-one-main-manual.js +8 -15
- package/src/checks/manual/landmark-unique-manual.js +31 -36
- package/src/checks/manual/link-name-quality-manual.js +162 -35
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +16 -19
- package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
- 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 +8 -15
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
- package/src/checks/manual/region-manual.js +32 -25
- package/src/checks/manual/scope-attr-valid-manual.js +16 -19
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +44 -52
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +16 -19
- package/src/checks/manual/table-duplicate-name-manual.js +16 -19
- 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 +13086 -5169
- package/src/report.js +16 -2
- package/surea11y.browser.js +5665 -4219
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
- package/bin/surea11y-core.js +0 -20
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check summary-name-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary <summary> elements must have an accessible name
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to <summary> elements included in the accessibility tree,
|
|
13
|
+
* wherever they appear, a <summary> outside a <details> is still matched.
|
|
14
|
+
* @expectation
|
|
15
|
+
* The element has a non-empty accessible name from aria-label, from an
|
|
16
|
+
* aria-labelledby that resolves to non-empty text, from title, or from its
|
|
17
|
+
* own subtree text, where a descendant's own name (an <img alt>,
|
|
18
|
+
* aria-label or title) counts as that descendant's contribution.
|
|
19
|
+
*/
|
|
20
|
+
|
|
5
21
|
const id = 'summary-name-present';
|
|
6
22
|
|
|
7
23
|
const meta = {
|
|
@@ -51,7 +67,7 @@ function runInPage(ctx) {
|
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
function getConservativeSubtreeText(document, container) {
|
|
54
|
-
// "Name from content"
|
|
70
|
+
// "Name from content", recurses into descendants and uses each one's
|
|
55
71
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
56
72
|
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
57
73
|
// header comment in src/core/dom-helpers.js for the full rationale
|
|
@@ -70,7 +86,7 @@ function runInPage(ctx) {
|
|
|
70
86
|
const raw = getAttr(el, 'aria-labelledby');
|
|
71
87
|
if (!raw) return '';
|
|
72
88
|
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
73
|
-
// name-from-content of the referenced element
|
|
89
|
+
// name-from-content of the referenced element, see dialog-name-
|
|
74
90
|
// present.js's identical fix for the full rationale (an <iframe>
|
|
75
91
|
// aria-labelledby target's only name source is its title attribute,
|
|
76
92
|
// which name-from-content alone can never see).
|
|
@@ -144,24 +160,22 @@ function runInPage(ctx) {
|
|
|
144
160
|
}
|
|
145
161
|
})()
|
|
146
162
|
: null;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
});
|
|
163
|
+
|
|
164
|
+
occurrences.push(
|
|
165
|
+
helpers.reportOccurrence(el, {
|
|
166
|
+
summary: 'This summary has no accessible name.',
|
|
167
|
+
hint: 'Provide summary text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
|
|
168
|
+
i18n: {
|
|
169
|
+
summaryKey: 'summaryNamePresent_summary_fail',
|
|
170
|
+
hintKey: 'summaryNamePresent_hint_fail',
|
|
171
|
+
params: {}
|
|
172
|
+
},
|
|
173
|
+
data: {
|
|
174
|
+
details: { reasonCode: 'name_missing', controlType: 'summary', methodTried: res.method },
|
|
175
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
);
|
|
165
179
|
}
|
|
166
180
|
|
|
167
181
|
if (applicableCount === 0) {
|
|
@@ -109,7 +109,7 @@ function runInPage(ctx) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// <desc> counts when it is the first child, or the second child
|
|
112
|
-
// immediately following a <title
|
|
112
|
+
// immediately following a <title>, the standard <title>+<desc> pairing.
|
|
113
113
|
// A <desc> appearing later than that is not reliably read by AT.
|
|
114
114
|
function descText(el) {
|
|
115
115
|
try {
|
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* "Intended to be conveyed" is approximated deterministically by at least one of:
|
|
14
14
|
* - role="img", role="graphics-symbol", or role="graphics-document"
|
|
15
15
|
* on the SVG root element itself (the WAI-ARIA Graphics Module
|
|
16
|
-
* roles, alongside img).
|
|
17
|
-
* role="graphics-symbol" descendants
|
|
18
|
-
* check's scope is the <svg> root
|
|
19
|
-
*
|
|
16
|
+
* roles, alongside img). Does NOT extend to arbitrary
|
|
17
|
+
* role="img"/"graphics-symbol"/"graphics-document" descendants
|
|
18
|
+
* nested inside an <svg>. This check's scope is the <svg> root
|
|
19
|
+
* only; role-img-text-alternative-present covers those same three
|
|
20
|
+
* roles on any other element, including nested SVG shapes (ACT
|
|
21
|
+
* 7d6734's own failed example: a bare `<svg>` root with a nested
|
|
22
|
+
* `<circle role="graphics-symbol">`).
|
|
20
23
|
* - aria-label / aria-labelledby present
|
|
21
|
-
* - <title> or <desc> present (desc alone is an applicability signal only
|
|
24
|
+
* - <title> or <desc> present (desc alone is an applicability signal only, see @expectation)
|
|
22
25
|
* - focusable/tabbable (e.g., tabindex, native focusability)
|
|
23
26
|
*
|
|
24
27
|
* Images with role="presentation" or role="none" are excluded only when they are not focusable.
|
|
@@ -28,7 +31,7 @@
|
|
|
28
31
|
* Each applicable <svg> element provides a text alternative via:
|
|
29
32
|
* - non-empty <title> text, OR
|
|
30
33
|
* - an ARIA name (aria-label / aria-labelledby).
|
|
31
|
-
* A <desc> element alone does NOT satisfy this
|
|
34
|
+
* A <desc> element alone does NOT satisfy this, per the SVG Accessibility
|
|
32
35
|
* API Mappings spec §7.1, <desc> only ever contributes to the accessible
|
|
33
36
|
* DESCRIPTION, never the accessible NAME. An <svg> with only a
|
|
34
37
|
* <desc> and no <title>/ARIA name is still "applicable" (desc signals
|
|
@@ -40,7 +43,7 @@ const id = 'svg-text-alternative-present';
|
|
|
40
43
|
const meta = {
|
|
41
44
|
title: '<svg> must provide a text alternative',
|
|
42
45
|
description:
|
|
43
|
-
'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count
|
|
46
|
+
'Checks that inline <svg> elements provide a text alternative via a <title> element or an ARIA name (a <desc> element alone does not count).',
|
|
44
47
|
i18n: {
|
|
45
48
|
titleKey: 'svg_textAltPresent_title',
|
|
46
49
|
descriptionKey: 'svg_textAltPresent_description'
|
|
@@ -124,7 +127,7 @@ function runInPage(ctx) {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
// <desc> counts when it is the first child, or the second child
|
|
127
|
-
// immediately following a <title
|
|
130
|
+
// immediately following a <title>, the standard <title>+<desc> pairing
|
|
128
131
|
// (e.g. <svg><title>...</title><desc>...</desc>...</svg>). A <desc>
|
|
129
132
|
// appearing later than that is not reliably read by AT.
|
|
130
133
|
function nonEmptyDescText(svg) {
|
|
@@ -277,7 +280,7 @@ function runInPage(ctx) {
|
|
|
277
280
|
}
|
|
278
281
|
|
|
279
282
|
// Per SVG-AAM §7.1: <desc> contributes only to the accessible
|
|
280
|
-
// DESCRIPTION, never the accessible NAME
|
|
283
|
+
// DESCRIPTION, never the accessible NAME, so descText does not
|
|
281
284
|
// count here even though it does count toward applicability above.
|
|
282
285
|
const ok = !!titleText || hasAriaName;
|
|
283
286
|
if (ok) continue;
|
|
@@ -293,7 +296,7 @@ function runInPage(ctx) {
|
|
|
293
296
|
|
|
294
297
|
const baseOccurrence = {
|
|
295
298
|
summary: 'Missing text alternative for <svg>.',
|
|
296
|
-
hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby)
|
|
299
|
+
hint: 'Provide a <title> element with text, or an ARIA name (aria-label/aria-labelledby); a <desc> element alone does not provide an accessible name.',
|
|
297
300
|
i18n: {
|
|
298
301
|
summaryKey: 'svg_textAltPresent_summary_fail',
|
|
299
302
|
hintKey: 'svg_textAltPresent_hint_fail',
|
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check tab-name-present
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary Elements with role="tab" must have an accessible name
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 4.1.2
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to elements carrying role="tab" (the attribute must name that
|
|
13
|
+
* role alone, not a fallback list) that are included in the accessibility
|
|
14
|
+
* tree. An element with the matching implicit role but no role attribute
|
|
15
|
+
* is out of scope.
|
|
16
|
+
* @expectation
|
|
17
|
+
* The element has a non-empty accessible name from aria-label, from an
|
|
18
|
+
* aria-labelledby that resolves to non-empty text, from title, or,
|
|
19
|
+
* role="tab" being name-from-content, from its own subtree text, where a
|
|
20
|
+
* descendant's own name (an <img alt>, aria-label or title) counts as that
|
|
21
|
+
* descendant's contribution rather than only its text nodes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
5
24
|
const id = 'tab-name-present';
|
|
6
25
|
|
|
7
26
|
const meta = {
|
|
@@ -51,7 +70,7 @@ function runInPage(ctx) {
|
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
function getConservativeSubtreeText(document, container) {
|
|
54
|
-
// "Name from content"
|
|
73
|
+
// "Name from content", recurses into descendants and uses each one's
|
|
55
74
|
// own accessible name (img alt, aria-label/aria-labelledby, title) when
|
|
56
75
|
// it has one, not just literal text nodes. See getContentNameInfo's
|
|
57
76
|
// header comment in src/core/dom-helpers.js for the full rationale
|
|
@@ -70,7 +89,7 @@ function runInPage(ctx) {
|
|
|
70
89
|
const raw = getAttr(el, 'aria-labelledby');
|
|
71
90
|
if (!raw) return '';
|
|
72
91
|
// Delegates to the shared getTextFromIdRefs helper instead of computing
|
|
73
|
-
// name-from-content of the referenced element
|
|
92
|
+
// name-from-content of the referenced element, see dialog-name-
|
|
74
93
|
// present.js's identical fix for the full rationale (an <iframe>
|
|
75
94
|
// aria-labelledby target's only name source is its title attribute,
|
|
76
95
|
// which name-from-content alone can never see).
|
|
@@ -148,24 +167,22 @@ function runInPage(ctx) {
|
|
|
148
167
|
}
|
|
149
168
|
})()
|
|
150
169
|
: null;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
});
|
|
170
|
+
|
|
171
|
+
occurrences.push(
|
|
172
|
+
helpers.reportOccurrence(el, {
|
|
173
|
+
summary: 'This tab has no accessible name.',
|
|
174
|
+
hint: 'Provide tab text that is not hidden from assistive technologies, or provide aria-label or aria-labelledby.',
|
|
175
|
+
i18n: {
|
|
176
|
+
summaryKey: 'tabNamePresent_summary_fail',
|
|
177
|
+
hintKey: 'tabNamePresent_hint_fail',
|
|
178
|
+
params: {}
|
|
179
|
+
},
|
|
180
|
+
data: {
|
|
181
|
+
details: { reasonCode: 'name_missing', controlType: 'tab', methodTried: res.method },
|
|
182
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
);
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
if (applicableCount === 0) {
|
|
@@ -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)) {
|
|
@@ -104,23 +139,21 @@ function runInPage(ctx) {
|
|
|
104
139
|
const dedupedInvalidIds = [...new Set(invalid.map((i) => i.id))];
|
|
105
140
|
|
|
106
141
|
const tag = el.tagName.toLowerCase();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
});
|
|
142
|
+
|
|
143
|
+
occurrences.push(
|
|
144
|
+
helpers.reportOccurrence(el, {
|
|
145
|
+
summary: 'This cell’s headers attribute references one or more invalid header cells.',
|
|
146
|
+
hint: 'Update the headers attribute so every id refers to a cell (<td> or <th>) within the same table.',
|
|
147
|
+
i18n: {
|
|
148
|
+
summaryKey: 'tableHeadersAttrValid_summary_fail',
|
|
149
|
+
hintKey: 'tableHeadersAttrValid_hint_fail',
|
|
150
|
+
params: { element: tag, invalidIds: dedupedInvalidIds.join(', ') }
|
|
151
|
+
},
|
|
152
|
+
data: {
|
|
153
|
+
details: { reasonCode: 'TABLE_HEADERS_ATTR_INVALID', element: tag, invalid }
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
);
|
|
124
157
|
}
|
|
125
158
|
|
|
126
159
|
if (applicableCount === 0) {
|
|
@@ -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
|
|
@@ -156,25 +159,74 @@ function runInPage(ctx) {
|
|
|
156
159
|
|
|
157
160
|
for (const th of headers) {
|
|
158
161
|
if (!th) continue;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
162
|
+
|
|
163
|
+
occurrences.push(
|
|
164
|
+
helpers.reportOccurrence(th, {
|
|
165
|
+
summary: 'This table has header cells but no data cells for them to describe.',
|
|
166
|
+
hint: 'Add data cells (<td>) to the table, or remove the header cells if the table has no data.',
|
|
167
|
+
i18n: {
|
|
168
|
+
summaryKey: 'tableThHasDataCells_summary_fail',
|
|
169
|
+
hintKey: 'tableThHasDataCells_hint_fail',
|
|
170
|
+
params: {}
|
|
171
|
+
},
|
|
172
|
+
data: {
|
|
173
|
+
details: { reasonCode: 'TABLE_TH_NO_DATA_CELLS' }
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
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
|
+
);
|
|
178
230
|
}
|
|
179
231
|
}
|
|
180
232
|
|