@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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to any element whose role attribute's first (used) token is a
|
|
13
13
|
* valid, non-abstract ARIA role that authors should not explicitly
|
|
14
|
-
* declare
|
|
14
|
+
* declare, either because WAI-ARIA has deprecated it (e.g. "directory",
|
|
15
15
|
* superseded by role="list") or because it is reserved for user-agent-
|
|
16
16
|
* internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
|
|
17
17
|
* NOT use in content).
|
|
@@ -81,8 +81,20 @@ function runInPage(ctx) {
|
|
|
81
81
|
if (typeof helpers.isDomVisibleEligible === 'function') {
|
|
82
82
|
if (!helpers.isDomVisibleEligible(el, ctx)) return true;
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
// Walk the composed tree, not parentElement: that stops at a shadow
|
|
85
|
+
// root, so a host carrying aria-hidden or inert would never be seen
|
|
86
|
+
// from inside its own shadow content.
|
|
87
|
+
const up =
|
|
88
|
+
typeof helpers.composedParent === 'function'
|
|
89
|
+
? helpers.composedParent
|
|
90
|
+
: (n) => n.parentElement;
|
|
91
|
+
|
|
92
|
+
// A shadow root has no getAttribute, so skip past it rather than
|
|
93
|
+
// stopping: the host one step further up is the node that matters.
|
|
94
|
+
for (let n = el; n; n = up(n)) {
|
|
95
|
+
if (!n.getAttribute) continue;
|
|
85
96
|
if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
|
|
97
|
+
if (n.hasAttribute && n.hasAttribute('inert')) return true;
|
|
86
98
|
}
|
|
87
99
|
} catch {
|
|
88
100
|
return false;
|
|
@@ -113,63 +125,64 @@ function runInPage(ctx) {
|
|
|
113
125
|
ariaHelpers.isAuthorProhibitedRole(role);
|
|
114
126
|
if (!deprecated && !discouraged && !prohibited) continue;
|
|
115
127
|
|
|
116
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
117
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
118
128
|
const guidance = ariaHelpers.getDeprecatedRoleGuidance
|
|
119
129
|
? ariaHelpers.getDeprecatedRoleGuidance(role)
|
|
120
|
-
:
|
|
130
|
+
: {
|
|
131
|
+
key: 'ariaDeprecatedRole_guidance_default',
|
|
132
|
+
text: 'Replace the deprecated role with its recommended replacement.'
|
|
133
|
+
};
|
|
121
134
|
|
|
122
135
|
if (prohibited) {
|
|
123
136
|
// Author MUST NOT: the usage is non-conforming, not merely discouraged.
|
|
124
|
-
failOccurrences.push(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
137
|
+
failOccurrences.push(
|
|
138
|
+
helpers.reportOccurrence(el, {
|
|
139
|
+
summary: `This element uses role="${role}", which authors must not explicitly declare.`,
|
|
140
|
+
hint: guidance.text,
|
|
141
|
+
occurrenceOutcome: 'fail',
|
|
142
|
+
i18n: {
|
|
143
|
+
summaryKey: 'ariaDeprecatedRole_summary_fail',
|
|
144
|
+
hintKey: guidance.key,
|
|
145
|
+
params: { role }
|
|
146
|
+
},
|
|
147
|
+
data: {
|
|
148
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance: guidance.text }
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
);
|
|
139
152
|
} else if (discouraged) {
|
|
140
153
|
// Reserved for user-agent-internal use, at SHOULD NOT strength.
|
|
141
|
-
cantTellOccurrences.push(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
154
|
+
cantTellOccurrences.push(
|
|
155
|
+
helpers.reportOccurrence(el, {
|
|
156
|
+
summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
|
|
157
|
+
hint: guidance.text,
|
|
158
|
+
occurrenceOutcome: 'cantTell',
|
|
159
|
+
i18n: {
|
|
160
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
|
|
161
|
+
hintKey: guidance.key,
|
|
162
|
+
params: { role }
|
|
163
|
+
},
|
|
164
|
+
data: {
|
|
165
|
+
details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
);
|
|
156
169
|
} else {
|
|
157
170
|
// Deprecated but still valid: surfaced for the author to decide.
|
|
158
|
-
cantTellOccurrences.push(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
171
|
+
cantTellOccurrences.push(
|
|
172
|
+
helpers.reportOccurrence(el, {
|
|
173
|
+
summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
|
|
174
|
+
hint: guidance.text,
|
|
175
|
+
occurrenceOutcome: 'cantTell',
|
|
176
|
+
i18n: {
|
|
177
|
+
summaryKey: 'ariaDeprecatedRole_summary_cantTell',
|
|
178
|
+
hintKey: guidance.key,
|
|
179
|
+
params: { role }
|
|
180
|
+
},
|
|
181
|
+
data: {
|
|
182
|
+
details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
);
|
|
173
186
|
}
|
|
174
187
|
}
|
|
175
188
|
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
* @sc 1.3.1, 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Always applicable to any HTML document with a <body> element,
|
|
13
|
-
* independent of contextSelector/root scoping
|
|
13
|
+
* independent of contextSelector/root scoping, this is a whole-
|
|
14
14
|
* document concern, matching page-title-present's pattern of
|
|
15
15
|
* evaluating document.body directly rather than the scoped root.
|
|
16
16
|
* @expectation
|
|
17
17
|
* <body> does not have aria-hidden="true". Hiding the document body
|
|
18
18
|
* removes the entire page's content and structure from the
|
|
19
|
-
* accessibility tree at once
|
|
19
|
+
* accessibility tree at once, both 1.3.1 (Info and Relationships: the
|
|
20
20
|
* page's structure becomes entirely non-determinable) and 4.1.2 (Name,
|
|
21
21
|
* Role, Value: nothing in the document exposes a role/name/value any
|
|
22
22
|
* longer) apply.
|
|
@@ -86,15 +86,8 @@ function runInPage(ctx) {
|
|
|
86
86
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
90
|
-
const html = helpers.getOuterHtmlSnippet
|
|
91
|
-
? helpers.getOuterHtmlSnippet(body)
|
|
92
|
-
: (body.outerHTML || '').slice(0, 200);
|
|
93
|
-
|
|
94
89
|
const occurrences = [
|
|
95
|
-
{
|
|
96
|
-
selector: stableSelector,
|
|
97
|
-
html,
|
|
90
|
+
helpers.reportOccurrence(body, {
|
|
98
91
|
summary:
|
|
99
92
|
'The document body has aria-hidden="true", which hides the entire page from assistive technologies.',
|
|
100
93
|
hint: 'Remove aria-hidden from <body>. Hide specific elements instead, if that was the intent.',
|
|
@@ -106,7 +99,7 @@ function runInPage(ctx) {
|
|
|
106
99
|
data: {
|
|
107
100
|
details: { reasonCode: 'ARIA_HIDDEN_BODY' }
|
|
108
101
|
}
|
|
109
|
-
}
|
|
102
|
+
})
|
|
110
103
|
];
|
|
111
104
|
|
|
112
105
|
return {
|
|
@@ -109,7 +109,7 @@ function runInPage(ctx) {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// Flat-tree ancestor walk (assignedSlot wins over parentNode, then shadow
|
|
112
|
-
// host)
|
|
112
|
+
// host), shared with every other rule via ctx.helpers.composedParent
|
|
113
113
|
// (src/core/dom-helpers.js), not reimplemented here, so a fix to the one
|
|
114
114
|
// canonical definition can't drift out of sync with this rule's copy.
|
|
115
115
|
const composedParent =
|
|
@@ -146,6 +146,54 @@ function runInPage(ctx) {
|
|
|
146
146
|
return false;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// An open modal is expected to trap focus, so a focusable background behind
|
|
150
|
+
// it may be unreachable; that can't be proven statically, so the finding is
|
|
151
|
+
// downgraded to cantTell instead of fail. A modal that is display:none is
|
|
152
|
+
// not open, so it does not count.
|
|
153
|
+
function isRenderedForModal(node) {
|
|
154
|
+
if (!node) return false;
|
|
155
|
+
if (!isDomVisibleEligible) return true;
|
|
156
|
+
try {
|
|
157
|
+
const vis = isDomVisibleEligible(node, ctx, {
|
|
158
|
+
visibilityMode: 'styleOnly',
|
|
159
|
+
disableGeometry: true
|
|
160
|
+
});
|
|
161
|
+
if (vis && vis.eligible === false) return false;
|
|
162
|
+
} catch {
|
|
163
|
+
// treat as rendered
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Native <dialog>, aria-modal="true", or a dialog/alertdialog role, so
|
|
169
|
+
// libraries that leave aria-modal off (e.g. Angular Material defaults to
|
|
170
|
+
// aria-modal="false") still count as an open modal.
|
|
171
|
+
function collectOpenModalCandidates() {
|
|
172
|
+
const nodes = qAll('dialog[open],[aria-modal="true"],[role="dialog"],[role="alertdialog"]');
|
|
173
|
+
const out = [];
|
|
174
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
175
|
+
const n = nodes[i];
|
|
176
|
+
if (!n || !n.getAttribute) continue;
|
|
177
|
+
if (!isRenderedForModal(n)) continue;
|
|
178
|
+
out.push(n);
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Only a modal in a separate subtree is the "hidden background behind an
|
|
184
|
+
// open dialog" case. A modal inside the aria-hidden subtree (or an
|
|
185
|
+
// aria-hidden root inside the modal) is a genuine defect, so keep it a fail.
|
|
186
|
+
function hasSeparateOpenModal(rootEl, candidates) {
|
|
187
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
188
|
+
const m = candidates[i];
|
|
189
|
+
if (!m || m === rootEl) continue;
|
|
190
|
+
if (isWithinComposedSubtree(m, rootEl)) continue;
|
|
191
|
+
if (isWithinComposedSubtree(rootEl, m)) continue;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
149
197
|
function getDeepActiveElement() {
|
|
150
198
|
let cur = document && document.activeElement ? document.activeElement : null;
|
|
151
199
|
let guard = 0;
|
|
@@ -485,8 +533,8 @@ function runInPage(ctx) {
|
|
|
485
533
|
if (isDisabledFormControl(el)) return false;
|
|
486
534
|
|
|
487
535
|
// An explicit negative tabindex removes the element from the keyboard
|
|
488
|
-
// tab sequence entirely, regardless of tag
|
|
489
|
-
// recommended technique for safely hiding focusable content behind
|
|
536
|
+
// tab sequence entirely, regardless of tag. It's the standard,
|
|
537
|
+
// WAI-recommended technique for safely hiding focusable content behind
|
|
490
538
|
// aria-hidden (e.g. <button tabindex="-1"> / <a tabindex="-1"> inside
|
|
491
539
|
// an aria-hidden container). This cares about tabbability, not raw
|
|
492
540
|
// focusability. Such an element is still programmatically focusable
|
|
@@ -636,7 +684,7 @@ function runInPage(ctx) {
|
|
|
636
684
|
// Cheap check first: a plain ancestor-attribute walk with no CSS
|
|
637
685
|
// computation, vs. isActuallyFocusable's getComputedStyle-per-ancestor
|
|
638
686
|
// cost. Both conditions are required (AND), so checking whichever is
|
|
639
|
-
// cheaper first cannot change which elements end up in the bucket
|
|
687
|
+
// cheaper first cannot change which elements end up in the bucket,
|
|
640
688
|
// it only skips the expensive check for the (typically vast) majority
|
|
641
689
|
// of focusable candidates that were never inside an aria-hidden root
|
|
642
690
|
// in the first place. On pages with many focusable candidates and a
|
|
@@ -713,6 +761,8 @@ function runInPage(ctx) {
|
|
|
713
761
|
entry.rootIsFocusable = isActuallyFocusable(el);
|
|
714
762
|
}
|
|
715
763
|
|
|
764
|
+
const modalCandidates = collectOpenModalCandidates();
|
|
765
|
+
|
|
716
766
|
// 3) Report one occurrence per aria-hidden root that contains focusable content.
|
|
717
767
|
const failOccurrences = [];
|
|
718
768
|
const uncertainOccurrences = [];
|
|
@@ -765,24 +815,56 @@ function runInPage(ctx) {
|
|
|
765
815
|
: `aria-hidden ${tagName} is focusable (${totalFocusable} focusable element(s)).`
|
|
766
816
|
: `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s).`;
|
|
767
817
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
818
|
+
// A modal takes precedence over the redirect probe; skip the probe when a
|
|
819
|
+
// modal already explains the background.
|
|
820
|
+
const modalOpenOutside = modalCandidates.length
|
|
821
|
+
? hasSeparateOpenModal(el, modalCandidates)
|
|
822
|
+
: false;
|
|
823
|
+
|
|
824
|
+
const runtimeProbe = modalOpenOutside ? null : probeImmediateFocusRedirect(entry);
|
|
825
|
+
const downgradedByRedirect = !!(runtimeProbe && runtimeProbe.redirected);
|
|
826
|
+
const downgradedByModal = modalOpenOutside;
|
|
827
|
+
const downgradedToCantTell = downgradedByModal || downgradedByRedirect;
|
|
828
|
+
|
|
829
|
+
const cantTellRedirectSummary = `aria-hidden ${tagName} received focus but focus moved immediately to another element. Verify sentinel/focus-trap behavior.`;
|
|
830
|
+
const cantTellModalSummary = `aria-hidden ${tagName} contains ${totalFocusable} focusable element(s) while a modal dialog is open. If the modal keeps keyboard focus trapped they may be unreachable; verify focus cannot land on them.`;
|
|
831
|
+
|
|
832
|
+
let occSummary;
|
|
833
|
+
let occHint;
|
|
834
|
+
let occSummaryKey;
|
|
835
|
+
let occHintKey;
|
|
836
|
+
let occReasonCode;
|
|
837
|
+
|
|
838
|
+
if (downgradedByModal) {
|
|
839
|
+
occSummary = cantTellModalSummary;
|
|
840
|
+
occHint =
|
|
841
|
+
'A modal dialog appears to be open. Prefer making the background inert (or a native <dialog> opened with showModal()) so it leaves the tab order, then verify keyboard focus stays within the dialog.';
|
|
842
|
+
occSummaryKey = 'ariaHidden_focus_summary_cantTell_modal';
|
|
843
|
+
occHintKey = 'ariaHidden_focus_hint_cantTell_modal';
|
|
844
|
+
occReasonCode = 'ariaHiddenFocusable_modalOpen_needsReview';
|
|
845
|
+
} else if (downgradedByRedirect) {
|
|
846
|
+
occSummary = cantTellRedirectSummary;
|
|
847
|
+
occHint =
|
|
848
|
+
'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.';
|
|
849
|
+
occSummaryKey = 'ariaHidden_focus_summary_cantTell_redirect';
|
|
850
|
+
occHintKey = 'ariaHidden_focus_hint_cantTell_redirect';
|
|
851
|
+
occReasonCode = 'ariaHiddenFocusable_runtimeRedirect_needsReview';
|
|
852
|
+
} else {
|
|
853
|
+
occSummary = summaryText;
|
|
854
|
+
occHint =
|
|
855
|
+
'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.';
|
|
856
|
+
occSummaryKey = summaryKey;
|
|
857
|
+
occHintKey = 'ariaHidden_focus_hint_fail';
|
|
858
|
+
occReasonCode = reasonCode;
|
|
859
|
+
}
|
|
772
860
|
|
|
773
861
|
const baseOccurrence = {
|
|
774
|
-
summary:
|
|
775
|
-
hint:
|
|
776
|
-
? 'Verify this is an intentional focus sentinel/focus-trap handoff and that keyboard users never remain on hidden focus targets.'
|
|
777
|
-
: 'Remove focusability from descendants or remove aria-hidden; ensure focus and accessibility trees stay aligned.',
|
|
862
|
+
summary: occSummary,
|
|
863
|
+
hint: occHint,
|
|
778
864
|
occurrenceOutcome: downgradedToCantTell ? 'cantTell' : 'fail',
|
|
779
865
|
i18n: {
|
|
780
|
-
summaryKey:
|
|
781
|
-
|
|
782
|
-
: summaryKey,
|
|
783
|
-
hintKey: downgradedToCantTell
|
|
784
|
-
? 'ariaHidden_focus_hint_cantTell_redirect'
|
|
785
|
-
: 'ariaHidden_focus_hint_fail',
|
|
866
|
+
summaryKey: occSummaryKey,
|
|
867
|
+
hintKey: occHintKey,
|
|
786
868
|
params: {
|
|
787
869
|
element: tagName,
|
|
788
870
|
focusableCount: String(totalFocusable),
|
|
@@ -795,15 +877,14 @@ function runInPage(ctx) {
|
|
|
795
877
|
},
|
|
796
878
|
data: {
|
|
797
879
|
details: {
|
|
798
|
-
reasonCode:
|
|
799
|
-
? 'ariaHiddenFocusable_runtimeRedirect_needsReview'
|
|
800
|
-
: reasonCode,
|
|
880
|
+
reasonCode: occReasonCode,
|
|
801
881
|
metrics: {
|
|
802
882
|
focusableTotal: totalFocusable,
|
|
803
883
|
focusableDescendants: descendantFocusable,
|
|
804
884
|
rootIsFocusable: selfFocusable,
|
|
805
885
|
offendersCaptured: entry.offenders.length,
|
|
806
|
-
visibilityHints: hintsArr.slice(0)
|
|
886
|
+
visibilityHints: hintsArr.slice(0),
|
|
887
|
+
modalOpen: !!downgradedByModal
|
|
807
888
|
},
|
|
808
889
|
runtimeProbe: runtimeProbe || null,
|
|
809
890
|
offenders: entry.offenders.slice(0)
|
|
@@ -822,7 +903,7 @@ function runInPage(ctx) {
|
|
|
822
903
|
|
|
823
904
|
// See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
|
|
824
905
|
// a fail-tier finding never silently discards cantTell-tier findings from
|
|
825
|
-
// the same run
|
|
906
|
+
// the same run, both are returned together when the outcome is 'fail'.
|
|
826
907
|
const resolved = helpers.resolveTieredOutcome(
|
|
827
908
|
failOccurrences,
|
|
828
909
|
uncertainOccurrences,
|
|
@@ -14,20 +14,20 @@
|
|
|
14
14
|
* list for naming attributes (pure text-semantics / non-naming
|
|
15
15
|
* structural roles: caption, code, deletion, emphasis, generic,
|
|
16
16
|
* insertion, mark, none, paragraph, presentation, strong, subscript,
|
|
17
|
-
* suggestion, superscript, time), and (b) elements with no role at all
|
|
17
|
+
* suggestion, superscript, time), and (b) elements with no role at all:
|
|
18
18
|
* a curated set of native HTML tags verified to carry no implicit role
|
|
19
19
|
* (see ROLELESS_NATIVE_TAGS below), or any autonomous custom element (a
|
|
20
20
|
* hyphenated, author-defined tag per the Custom Elements spec; see
|
|
21
|
-
* isRolelessCustomElementTag below)
|
|
21
|
+
* isRolelessCustomElementTag below). In both cases, only elements that
|
|
22
22
|
* also carry aria-label or aria-labelledby.
|
|
23
23
|
* @expectation
|
|
24
24
|
* Prohibited attributes must not be present on (a); for (b), the naming
|
|
25
25
|
* attribute is at best unreliable (nothing accessible-name-aware to hang
|
|
26
|
-
* it off) and at worst silently ignored by assistive technology
|
|
26
|
+
* it off) and at worst silently ignored by assistive technology. See the
|
|
27
27
|
* roleless-branch implementation note below for the confidence split
|
|
28
28
|
* this produces.
|
|
29
29
|
* @implementation-notes
|
|
30
|
-
* -
|
|
30
|
+
* - Scoped to the single, well-established prohibition class
|
|
31
31
|
* (naming attributes on pure text-semantics roles) rather than an
|
|
32
32
|
* exhaustive per-role prohibited-attribute table; see
|
|
33
33
|
* src/core/aria-helpers.js file header for this engine's confidence-
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
* narrower, unambiguous naming-prohibition case fires as a hard,
|
|
42
42
|
* WCAG-normative fail instead, matching this engine's "one rule = one
|
|
43
43
|
* normative decision" pattern.
|
|
44
|
-
* -
|
|
44
|
+
* - Excludes `definition`/`term` on purpose, despite both appearing on
|
|
45
45
|
* MDN's aria-label "not supported" list: both support name from author
|
|
46
46
|
* (`nameFrom: ['author']` for definition, `['author', 'contents']` for
|
|
47
47
|
* term), and the W3C spec's §5.2.8.4 "Roles Supporting Name From Author"
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
* property, while engine-level hidden-subtree filtering still applies
|
|
51
51
|
* unless engineOptions.includeHiddenElements is true.
|
|
52
52
|
* - Second, independent branch: naming attributes on ROLELESS elements (no
|
|
53
|
-
* explicit role="", no implicit/native role either)
|
|
53
|
+
* explicit role="", no implicit/native role either), e.g. icon-only
|
|
54
54
|
* `<span aria-label="...">` tiles with no other accessible-name source.
|
|
55
|
-
* ROLELESS_NATIVE_TAGS below is a curated,
|
|
55
|
+
* ROLELESS_NATIVE_TAGS below is a curated, intentionally conservative
|
|
56
56
|
* list of native tags confirmed to carry no implicit role (common
|
|
57
57
|
* text-level tags like `<p>`/`<strong>`/`<em>`/`<code>`/`<mark>`/`<time>`
|
|
58
58
|
* resolve to role `null`, same as a bare `<div>`/`<span>`);
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
* already produces a non-empty accessible name from its content (via
|
|
66
66
|
* `helpers.getContentNameInfo`, same as
|
|
67
67
|
* link-name-present/button-name-present), the naming attribute might
|
|
68
|
-
* just be a redundant/intentional override
|
|
68
|
+
* just be a redundant/intentional override, so it's reported as `cantTell`, not
|
|
69
69
|
* a hard fail. Only a roleless element with no other accessible-name
|
|
70
70
|
* source at all is a confident, deterministic `fail`. The
|
|
71
71
|
* widget-ancestor exemption (skip when the closest real ancestor role is
|
|
@@ -119,8 +119,8 @@ function runInPage(ctx) {
|
|
|
119
119
|
// Roles whose WAI-ARIA 1.2 definition lists a "Prohibited ARIA States and
|
|
120
120
|
// Properties" entry for naming attributes (these roles must never carry an
|
|
121
121
|
// accessible name). Declared inside runInPage (rather than at module
|
|
122
|
-
// scope) because the build inlines only this function's own source text
|
|
123
|
-
//
|
|
122
|
+
// scope) because the build inlines only this function's own source text.
|
|
123
|
+
// See scripts/build-core.js header ("runInPage MUST be self-contained").
|
|
124
124
|
const ROLES_PROHIBITING_NAME = new Set([
|
|
125
125
|
'caption',
|
|
126
126
|
'code',
|
|
@@ -167,25 +167,22 @@ function runInPage(ctx) {
|
|
|
167
167
|
|
|
168
168
|
if (!present.length) continue;
|
|
169
169
|
|
|
170
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
171
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
172
|
-
|
|
173
170
|
for (const attr of present) {
|
|
174
|
-
failOccurrences.push(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
171
|
+
failOccurrences.push(
|
|
172
|
+
helpers.reportOccurrence(el, {
|
|
173
|
+
occurrenceOutcome: 'fail',
|
|
174
|
+
summary: 'This attribute is prohibited on this element’s role.',
|
|
175
|
+
hint: 'Remove this attribute; this role must not carry an accessible name.',
|
|
176
|
+
i18n: {
|
|
177
|
+
summaryKey: 'ariaProhibitedAttr_summary_fail',
|
|
178
|
+
hintKey: 'ariaProhibitedAttr_hint_fail',
|
|
179
|
+
params: { attr, role }
|
|
180
|
+
},
|
|
181
|
+
data: {
|
|
182
|
+
details: { reasonCode: 'ARIA_ATTR_PROHIBITED', attr, role }
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
);
|
|
189
186
|
}
|
|
190
187
|
}
|
|
191
188
|
|
|
@@ -193,7 +190,7 @@ function runInPage(ctx) {
|
|
|
193
190
|
// and how ROLELESS_NATIVE_TAGS/WIDGET_TYPE_ROLES were derived) ---
|
|
194
191
|
|
|
195
192
|
// Small, curated set of native tags verified to carry no explicit or
|
|
196
|
-
// implicit ARIA role.
|
|
193
|
+
// implicit ARIA role. Excludes <section>/<form>/<a>, which are
|
|
197
194
|
// conditionally roleless too, but already handled with more nuance
|
|
198
195
|
// elsewhere in this engine (see header comment).
|
|
199
196
|
const ROLELESS_NATIVE_TAGS = new Set([
|
|
@@ -271,7 +268,7 @@ function runInPage(ctx) {
|
|
|
271
268
|
};
|
|
272
269
|
|
|
273
270
|
// Nearest ancestor's real role (explicit-if-valid, else native/implicit),
|
|
274
|
-
// skipping roleless/presentation/none ancestors
|
|
271
|
+
// skipping roleless/presentation/none ancestors. Used only to check
|
|
275
272
|
// whether that role is a "widget"-type one (the roleless-branch
|
|
276
273
|
// exemption). Not the same helper as aria-required-parent's containment
|
|
277
274
|
// walk: this one also accepts non-required-context roles.
|
|
@@ -299,7 +296,7 @@ function runInPage(ctx) {
|
|
|
299
296
|
|
|
300
297
|
// A small, spec-reserved set of hyphenated tag names that are NOT
|
|
301
298
|
// autonomous custom elements despite containing a hyphen (legacy SVG/
|
|
302
|
-
// MathML tags predating the Custom Elements spec)
|
|
299
|
+
// MathML tags predating the Custom Elements spec). See
|
|
303
300
|
// https://html.spec.whatwg.org/#valid-custom-element-name's own
|
|
304
301
|
// exclusion list. Excluded so this doesn't misclassify them as
|
|
305
302
|
// always-roleless the same way a real custom element is.
|
|
@@ -339,8 +336,8 @@ function runInPage(ctx) {
|
|
|
339
336
|
const tag = String(el.tagName || '').toLowerCase();
|
|
340
337
|
if (!ROLELESS_NATIVE_TAGS.has(tag) && !isRolelessCustomElementTag(tag)) continue;
|
|
341
338
|
const explicitRole = ariaHelpers.getExplicitRole(el);
|
|
342
|
-
if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role
|
|
343
|
-
if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all
|
|
339
|
+
if (explicitRole && ariaHelpers.isValidConcreteRole(explicitRole)) continue; // has a real, recognized role: Tier 1's concern (if in ROLES_PROHIBITING_NAME) or a role this rule has no opinion on. An INVALID role token (e.g. a typo) is ignored per spec, same as no role attribute at all, and must still fall through to this branch.
|
|
340
|
+
if (ariaHelpers.getNativeRoleForElement(el)) continue; // has a real implicit role after all, not this branch's concern
|
|
344
341
|
|
|
345
342
|
const present = [];
|
|
346
343
|
for (const attr of PROHIBITED_NAMING_ATTRS) {
|
|
@@ -352,7 +349,7 @@ function runInPage(ctx) {
|
|
|
352
349
|
applicableCount += 1;
|
|
353
350
|
|
|
354
351
|
const ancestorRole = getNearestAncestorRole(el);
|
|
355
|
-
if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget
|
|
352
|
+
if (ancestorRole && WIDGET_TYPE_ROLES.has(ancestorRole)) continue; // roleless helper node inside a real widget, not flagged
|
|
356
353
|
|
|
357
354
|
const nameInfo = helpers.getContentNameInfo ? helpers.getContentNameInfo(el, ctx) : null;
|
|
358
355
|
const hasContentFallback = !!(
|
|
@@ -361,47 +358,49 @@ function runInPage(ctx) {
|
|
|
361
358
|
String(nameInfo.value || '').trim() !== ''
|
|
362
359
|
);
|
|
363
360
|
|
|
364
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
365
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
366
|
-
|
|
367
361
|
for (const attr of present) {
|
|
368
362
|
if (hasContentFallback) {
|
|
369
|
-
cantTellOccurrences.push(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
363
|
+
cantTellOccurrences.push(
|
|
364
|
+
helpers.reportOccurrence(el, {
|
|
365
|
+
occurrenceOutcome: 'cantTell',
|
|
366
|
+
summary: `This ${tag} has no role, so ${attr} may not be exposed as its accessible name by assistive technology, but the element's own content already provides one.`,
|
|
367
|
+
hint: 'Verify whether the existing text content already serves as this element’s label; if so the naming attribute is redundant, otherwise give the element a role that supports naming (e.g. role="img").',
|
|
368
|
+
i18n: {
|
|
369
|
+
summaryKey: 'ariaProhibitedAttr_summary_cantTell_roleless',
|
|
370
|
+
hintKey: 'ariaProhibitedAttr_hint_cantTell_roleless',
|
|
371
|
+
params: { attr, element: tag }
|
|
372
|
+
},
|
|
373
|
+
data: {
|
|
374
|
+
details: {
|
|
375
|
+
reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS_NEEDS_REVIEW',
|
|
376
|
+
attr,
|
|
377
|
+
role: null,
|
|
378
|
+
element: tag
|
|
379
|
+
}
|
|
386
380
|
}
|
|
387
|
-
}
|
|
388
|
-
|
|
381
|
+
})
|
|
382
|
+
);
|
|
389
383
|
} else {
|
|
390
|
-
failOccurrences.push(
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
384
|
+
failOccurrences.push(
|
|
385
|
+
helpers.reportOccurrence(el, {
|
|
386
|
+
occurrenceOutcome: 'fail',
|
|
387
|
+
summary: `This ${tag} has no role and no other accessible-name source, so ${attr} is not reliably exposed to assistive technology.`,
|
|
388
|
+
hint: 'Give this element a role that supports an accessible name (e.g. role="img"/"button"), or remove this attribute if it serves no purpose without one.',
|
|
389
|
+
i18n: {
|
|
390
|
+
summaryKey: 'ariaProhibitedAttr_summary_fail_roleless',
|
|
391
|
+
hintKey: 'ariaProhibitedAttr_hint_fail_roleless',
|
|
392
|
+
params: { attr, element: tag }
|
|
393
|
+
},
|
|
394
|
+
data: {
|
|
395
|
+
details: {
|
|
396
|
+
reasonCode: 'ARIA_ATTR_PROHIBITED_ROLELESS',
|
|
397
|
+
attr,
|
|
398
|
+
role: null,
|
|
399
|
+
element: tag
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
})
|
|
403
|
+
);
|
|
405
404
|
}
|
|
406
405
|
}
|
|
407
406
|
}
|
|
@@ -412,7 +411,7 @@ function runInPage(ctx) {
|
|
|
412
411
|
|
|
413
412
|
// See helpers.resolveTieredOutcome's own header comment (src/core/dom-helpers.js):
|
|
414
413
|
// a fail-tier finding never silently discards cantTell-tier findings from
|
|
415
|
-
// the same run
|
|
414
|
+
// the same run. Both are returned together when the outcome is 'fail'.
|
|
416
415
|
const resolved = helpers.resolveTieredOutcome(
|
|
417
416
|
failOccurrences,
|
|
418
417
|
cantTellOccurrences,
|