@surea11y/core 1.4.0 → 1.5.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 +88 -7
- package/README.md +19 -3
- package/bin/surea11y-core.js +0 -0
- package/docs/API_STABILITY.md +2 -2
- package/docs/ARIA_DEPRECATION.md +95 -0
- package/docs/ENGINE_OPTIONS.md +14 -10
- package/docs/I18N.md +176 -20
- package/docs/INTEGRATION.md +28 -6
- package/docs/LIMITATIONS.md +1 -1
- package/docs/OUTPUT_SCHEMA.md +13 -3
- package/docs/REPORT.md +2 -0
- package/docs/RULE_AUTHORING.md +53 -0
- package/docs/RULE_CATALOG.md +6 -6
- package/docs/TROUBLESHOOTING.md +2 -2
- package/package.json +8 -1
- package/src/checks/automatic/aria-allowed-attr.js +661 -99
- package/src/checks/automatic/aria-allowed-role.js +14 -16
- package/src/checks/automatic/aria-braille-equivalent.js +17 -19
- package/src/checks/automatic/aria-conditional-attr.js +17 -19
- package/src/checks/automatic/aria-deprecated-role.js +122 -41
- package/src/checks/automatic/aria-hidden-body.js +2 -9
- package/src/checks/automatic/aria-hidden-focus.js +99 -18
- package/src/checks/automatic/aria-prohibited-attr.js +54 -55
- package/src/checks/automatic/aria-prohibited-children.js +26 -26
- package/src/checks/automatic/aria-required-attr.js +14 -17
- package/src/checks/automatic/aria-required-children.js +17 -20
- package/src/checks/automatic/aria-required-parent.js +17 -20
- package/src/checks/automatic/aria-roles-valid.js +66 -27
- package/src/checks/automatic/aria-valid-attr-value.js +18 -21
- package/src/checks/automatic/aria-valid-attr.js +14 -17
- package/src/checks/automatic/autocomplete-valid.js +52 -18
- package/src/checks/automatic/avoid-inline-spacing.js +192 -34
- package/src/checks/automatic/binary-control-name-present.js +30 -24
- package/src/checks/automatic/button-name-present.js +73 -45
- package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
- package/src/checks/automatic/combobox-name-present.js +23 -18
- package/src/checks/automatic/css-orientation-lock.js +22 -22
- package/src/checks/automatic/definition-list-children-valid.js +18 -21
- package/src/checks/automatic/deprecated-elements-not-used.js +14 -16
- package/src/checks/automatic/dialog-name-present.js +24 -19
- package/src/checks/automatic/dlitem-parent-valid.js +15 -17
- package/src/checks/automatic/duplicate-id-aria.js +45 -37
- package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
- package/src/checks/automatic/form-control-single-label.js +109 -43
- package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
- package/src/checks/automatic/iframe-focusable-content.js +29 -31
- package/src/checks/automatic/iframe-name-present.js +15 -17
- package/src/checks/automatic/iframe-title-unique.js +18 -23
- package/src/checks/automatic/img-alt-present.js +16 -9
- package/src/checks/automatic/input-image-alt-present.js +99 -48
- package/src/checks/automatic/label-in-name.js +102 -33
- package/src/checks/automatic/language-page-present.js +5 -1
- package/src/checks/automatic/link-in-text-block.js +19 -21
- package/src/checks/automatic/link-name-present.js +75 -47
- package/src/checks/automatic/list-children-valid.js +15 -17
- package/src/checks/automatic/listbox-name-present.js +23 -18
- package/src/checks/automatic/listitem-parent-valid.js +14 -17
- package/src/checks/automatic/menuitem-name-present.js +24 -19
- package/src/checks/automatic/meta-refresh-no-exceptions.js +44 -18
- package/src/checks/automatic/meta-refresh-timing-absent.js +44 -21
- package/src/checks/automatic/meta-viewport-zoom-enabled.js +51 -32
- package/src/checks/automatic/meter-name-present.js +24 -19
- package/src/checks/automatic/nested-interactive-controls-absent.js +179 -51
- package/src/checks/automatic/object-text-alternative-present.js +14 -7
- package/src/checks/automatic/option-name-present.js +24 -19
- package/src/checks/automatic/progressbar-name-present.js +27 -22
- package/src/checks/automatic/searchbox-name-present.js +27 -18
- package/src/checks/automatic/server-side-image-map-absent.js +15 -18
- package/src/checks/automatic/slider-name-present.js +26 -19
- package/src/checks/automatic/spinbutton-name-present.js +27 -18
- package/src/checks/automatic/summary-name-present.js +24 -19
- package/src/checks/automatic/tab-name-present.js +24 -19
- package/src/checks/automatic/table-headers-attr-valid.js +15 -17
- package/src/checks/automatic/table-th-has-data-cells.js +82 -25
- package/src/checks/automatic/target-size-minimum.js +104 -81
- package/src/checks/automatic/td-has-header.js +15 -20
- package/src/checks/automatic/textbox-name-present.js +23 -18
- package/src/checks/automatic/tooltip-name-present.js +24 -19
- package/src/checks/automatic/treeitem-name-present.js +24 -19
- package/src/checks/automatic/valid-lang.js +31 -20
- package/src/checks/manual/accesskeys-manual.js +18 -19
- package/src/checks/manual/aria-checked-state-mismatch-manual.js +19 -22
- package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +98 -44
- package/src/checks/manual/empty-heading-manual.js +15 -17
- package/src/checks/manual/empty-table-header-manual.js +27 -30
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -9
- package/src/checks/manual/heading-order-manual.js +17 -22
- package/src/checks/manual/image-redundant-alt-manual.js +14 -17
- package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
- package/src/checks/manual/label-title-only-manual.js +15 -17
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +25 -39
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +21 -27
- package/src/checks/manual/landmark-main-is-top-level-manual.js +14 -17
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +2 -7
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -7
- package/src/checks/manual/landmark-one-main-manual.js +2 -9
- package/src/checks/manual/landmark-unique-manual.js +22 -27
- package/src/checks/manual/link-name-quality-manual.js +15 -17
- package/src/checks/manual/meta-viewport-large-manual.js +14 -17
- package/src/checks/manual/mouse-only-event-handlers-manual.js +17 -19
- package/src/checks/manual/page-has-heading-one-manual.js +2 -9
- package/src/checks/manual/presentation-role-conflict-manual.js +19 -21
- package/src/checks/manual/region-manual.js +13 -6
- package/src/checks/manual/scope-attr-valid-manual.js +14 -17
- package/src/checks/manual/skip-link-manual.js +39 -47
- package/src/checks/manual/tabindex-manual.js +14 -17
- package/src/checks/manual/table-duplicate-name-manual.js +14 -17
- package/src/core.js +8818 -4330
- package/src/report.js +14 -0
- package/src/sarif.js +2 -2
- package/surea11y.browser.js +4023 -3911
- package/surea11y.i18n.de.js +22 -0
- package/surea11y.i18n.es.js +22 -0
- package/surea11y.i18n.fr.js +22 -0
package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js}
RENAMED
|
@@ -21,30 +21,56 @@
|
|
|
21
21
|
* before it (nav, header, repeated blocks) in one step;
|
|
22
22
|
* (b) a working same-page anchor link — technique G1/G123: an
|
|
23
23
|
* <a href="#id"> (or legacy <a name="id">) whose target resolves to
|
|
24
|
-
* a real element
|
|
25
|
-
* required to be positioned before a <nav> or
|
|
26
|
-
* order-first — see implementation notes;
|
|
24
|
+
* a real element in the link's own tree (light DOM or the same shadow
|
|
25
|
+
* root). Deliberately NOT required to be positioned before a <nav> or
|
|
26
|
+
* be keyboard-focus-order-first — see implementation notes;
|
|
27
27
|
* (c) at least one heading (<h1>-<h6> or [role="heading"]) — technique
|
|
28
28
|
* H69: heading navigation is itself a standards-recognized bypass
|
|
29
29
|
* mechanism (e.g. a screen reader's "jump by heading" command).
|
|
30
30
|
* @implementation-notes
|
|
31
|
+
* - Outcome model: this rule is `type: 'manual'` (cantTell-capped, never
|
|
32
|
+
* `fail`). When a recognized mechanism is found the page has nothing to
|
|
33
|
+
* review here → `notApplicable` (matching page-has-heading-one-manual /
|
|
34
|
+
* skip-link-manual's "nothing to flag" convention). When none is found we
|
|
35
|
+
* return `cantTell` — "we could not detect a bypass mechanism, please
|
|
36
|
+
* verify" — rather than a hard `fail`. The absence of a *detectable*
|
|
37
|
+
* mechanism is NOT high-confidence evidence that 2.4.1 is violated, for
|
|
38
|
+
* several reasons the engine cannot resolve from a single static snapshot:
|
|
39
|
+
* • Applicability itself is undecidable in-page. 2.4.1 governs blocks of
|
|
40
|
+
* content "repeated on multiple Web pages"; whether any block is
|
|
41
|
+
* actually repeated across the site is not knowable from one document,
|
|
42
|
+
* so a page that legitimately needs no bypass mechanism would be
|
|
43
|
+
* indistinguishable from one that omits a required one.
|
|
44
|
+
* • Transient accessibility-tree state. When a modal dialog is open the
|
|
45
|
+
* rest of the page is routinely made `inert` or `aria-hidden="true"`,
|
|
46
|
+
* so the page's real <main>/headings are (correctly) filtered out by
|
|
47
|
+
* isAccTreeEligible for the duration of that state and only the dialog
|
|
48
|
+
* is exposed — a snapshot taken then would see "no mechanism" though
|
|
49
|
+
* the page has one once the dialog closes. The same applies to content
|
|
50
|
+
* that is display:none until revealed by script (tabs, accordions, an
|
|
51
|
+
* unmounted SPA view).
|
|
52
|
+
* Both cases would produce false positives under a hard `fail`, which this
|
|
53
|
+
* engine reserves for high-confidence violations; `cantTell` routes them to
|
|
54
|
+
* human review instead. No ACT rule hard-fails 2.4.1 by presence alone,
|
|
55
|
+
* for the same reason.
|
|
31
56
|
* - This rule intentionally checks presence, not position, for the
|
|
32
57
|
* same-page-anchor condition (b): a full bypass algorithm is heuristic
|
|
33
58
|
* (see ROADMAP.md's Tier 1a note on why this rule was
|
|
34
59
|
* deferred from the rest of that batch), and getting DOM-order /
|
|
35
60
|
* keyboard-focus-order positioning exactly right without introducing
|
|
36
61
|
* false positives is materially harder than the rest of Tier 1a. Being
|
|
37
|
-
* lenient about condition (b) can only
|
|
38
|
-
* a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
39
|
-
* "back to top" link) — never a
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
62
|
+
* lenient about condition (b) can only make us *miss* a review prompt
|
|
63
|
+
* (a page whose only anchor link isn't a real skip mechanism, e.g. a
|
|
64
|
+
* "back to top" link) — never raise a spurious one.
|
|
65
|
+
* - Shadow DOM: all three conditions use `helpers.queryAllSmart`, which is
|
|
66
|
+
* shadow-DOM-aware (when the run enables includeShadowDom) and applies the
|
|
67
|
+
* engine's hidden-content policy. The same-page-anchor target is resolved
|
|
68
|
+
* in the link's own root (`getRootNode()` — the document, or the shadow
|
|
69
|
+
* root the link lives in) before falling back to the document, so a skip
|
|
70
|
+
* link encapsulated in a web component is credited the same as one in the
|
|
71
|
+
* light DOM. (Previously the anchor path used raw
|
|
72
|
+
* `document.querySelectorAll`/`getElementById`, which never pierced shadow
|
|
73
|
+
* roots — a genuine gap now closed.)
|
|
48
74
|
*/
|
|
49
75
|
|
|
50
76
|
const id = 'bypass-blocks-present';
|
|
@@ -58,7 +84,7 @@ const meta = {
|
|
|
58
84
|
descriptionKey: 'bypassBlocksPresent_description'
|
|
59
85
|
},
|
|
60
86
|
helpUrl: null,
|
|
61
|
-
tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', '
|
|
87
|
+
tags: ['wcag2a', 'wcag241', 'navigation', 'atomic', 'manual'],
|
|
62
88
|
wcagSc: ['2.4.1'],
|
|
63
89
|
normativeMappings: [
|
|
64
90
|
{
|
|
@@ -69,9 +95,9 @@ const meta = {
|
|
|
69
95
|
conformanceLevel: 'A'
|
|
70
96
|
}
|
|
71
97
|
],
|
|
72
|
-
defaultSeverity: '
|
|
98
|
+
defaultSeverity: 'moderate',
|
|
73
99
|
category: 'operable',
|
|
74
|
-
type: '
|
|
100
|
+
type: 'manual',
|
|
75
101
|
defaultConfidence: 'medium',
|
|
76
102
|
coverage: { facetsBySc: { '2.4.1': ['bypass-blocks-present'] } }
|
|
77
103
|
};
|
|
@@ -122,7 +148,8 @@ function runInPage(ctx) {
|
|
|
122
148
|
// (e.g. a page whose only <h1> sits inside a display:none ancestor,
|
|
123
149
|
// unreachable by sighted and screen reader users alike). A fully
|
|
124
150
|
// non-rendered <main>/heading must not be credited here, since that would
|
|
125
|
-
// wrongly
|
|
151
|
+
// wrongly treat a page with zero currently-exposed bypass mechanisms as
|
|
152
|
+
// having one.
|
|
126
153
|
function hasMainLandmark() {
|
|
127
154
|
for (const el of queryAll('main, [role="main"]')) {
|
|
128
155
|
if (el && isExposedToAt(el)) return true;
|
|
@@ -130,10 +157,41 @@ function runInPage(ctx) {
|
|
|
130
157
|
return false;
|
|
131
158
|
}
|
|
132
159
|
|
|
160
|
+
// Resolve a fragment id (or legacy <a name>) inside a specific root node
|
|
161
|
+
// (a Document or a ShadowRoot). Both expose getElementById; querySelector
|
|
162
|
+
// is used for the legacy anchor-name fallback.
|
|
163
|
+
function resolveInRoot(root, fragment) {
|
|
164
|
+
if (!root) return null;
|
|
165
|
+
let target;
|
|
166
|
+
try {
|
|
167
|
+
target = typeof root.getElementById === 'function' ? root.getElementById(fragment) : null;
|
|
168
|
+
} catch {
|
|
169
|
+
target = null;
|
|
170
|
+
}
|
|
171
|
+
if (target) return target;
|
|
172
|
+
try {
|
|
173
|
+
target =
|
|
174
|
+
typeof root.querySelector === 'function'
|
|
175
|
+
? root.querySelector('a[name="' + fragment.replace(/"/g, '\\"') + '"]')
|
|
176
|
+
: null;
|
|
177
|
+
} catch {
|
|
178
|
+
target = null;
|
|
179
|
+
}
|
|
180
|
+
return target;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Shadow-DOM-aware: gather anchors via queryAllSmart (pierces shadow roots
|
|
184
|
+
// when includeShadowDom is enabled, and drops hard-hidden links), and
|
|
185
|
+
// resolve each fragment in the link's own root before falling back to the
|
|
186
|
+
// document. This credits a skip link encapsulated in a web component the
|
|
187
|
+
// same way as one authored in the light DOM.
|
|
133
188
|
function hasWorkingAnchorLink() {
|
|
134
189
|
let links;
|
|
135
190
|
try {
|
|
136
|
-
links =
|
|
191
|
+
links =
|
|
192
|
+
helpers && typeof helpers.queryAllSmart === 'function'
|
|
193
|
+
? helpers.queryAllSmart('a[href]')
|
|
194
|
+
: document.querySelectorAll('a[href]');
|
|
137
195
|
} catch {
|
|
138
196
|
links = [];
|
|
139
197
|
}
|
|
@@ -150,18 +208,19 @@ function runInPage(ctx) {
|
|
|
150
208
|
fragment = fragment.trim();
|
|
151
209
|
if (!fragment) continue;
|
|
152
210
|
|
|
153
|
-
let
|
|
211
|
+
let root = document;
|
|
154
212
|
try {
|
|
155
|
-
|
|
213
|
+
if (typeof a.getRootNode === 'function') {
|
|
214
|
+
const r = a.getRootNode();
|
|
215
|
+
if (r) root = r;
|
|
216
|
+
}
|
|
156
217
|
} catch {
|
|
157
|
-
|
|
218
|
+
root = document;
|
|
158
219
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
target = null;
|
|
164
|
-
}
|
|
220
|
+
|
|
221
|
+
let target = resolveInRoot(root, fragment);
|
|
222
|
+
if (!target && root !== document) {
|
|
223
|
+
target = resolveInRoot(document, fragment);
|
|
165
224
|
}
|
|
166
225
|
if (target) return true;
|
|
167
226
|
}
|
|
@@ -179,37 +238,32 @@ function runInPage(ctx) {
|
|
|
179
238
|
const anchorLink = mainLandmark ? false : hasWorkingAnchorLink();
|
|
180
239
|
const heading = mainLandmark || anchorLink ? false : hasHeading();
|
|
181
240
|
|
|
241
|
+
// A recognized mechanism is present -> nothing to review on this page.
|
|
182
242
|
if (mainLandmark || anchorLink || heading) {
|
|
183
|
-
return { ruleId: rule.ruleId, outcome: '
|
|
243
|
+
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
184
244
|
}
|
|
185
245
|
|
|
186
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
|
|
187
|
-
const html = helpers.getOuterHtmlSnippet
|
|
188
|
-
? helpers.getOuterHtmlSnippet(body)
|
|
189
|
-
: (body.outerHTML || '').slice(0, 200);
|
|
190
|
-
|
|
191
246
|
const occurrences = [
|
|
192
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
hint: 'Add a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content.',
|
|
247
|
+
helpers.reportOccurrence(body, {
|
|
248
|
+
summary:
|
|
249
|
+
'No recognized way to bypass repeated blocks of content was detected on this page — verify a bypass mechanism exists.',
|
|
250
|
+
hint: 'Confirm the page offers a bypass mechanism: a main landmark (<main> or role="main"), a working "skip to content" link, or heading elements that assistive technology can use to jump past repeated content. (A mechanism may be temporarily hidden — e.g. while a modal dialog makes the page inert — or provided on a per-site basis; this needs human confirmation.)',
|
|
197
251
|
i18n: {
|
|
198
|
-
summaryKey: '
|
|
199
|
-
hintKey: '
|
|
252
|
+
summaryKey: 'bypassBlocksPresent_summary_cantTell',
|
|
253
|
+
hintKey: 'bypassBlocksPresent_hint_cantTell',
|
|
200
254
|
params: {}
|
|
201
255
|
},
|
|
202
256
|
data: {
|
|
203
257
|
details: { reasonCode: 'BYPASS_MECHANISM_ABSENT' },
|
|
204
258
|
visibilityFilter: { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
205
259
|
}
|
|
206
|
-
}
|
|
260
|
+
})
|
|
207
261
|
];
|
|
208
262
|
|
|
209
263
|
return {
|
|
210
264
|
ruleId: rule.ruleId,
|
|
211
|
-
outcome: '
|
|
212
|
-
severity: rule.defaultSeverity || '
|
|
265
|
+
outcome: 'cantTell',
|
|
266
|
+
severity: rule.defaultSeverity || 'moderate',
|
|
213
267
|
occurrences
|
|
214
268
|
};
|
|
215
269
|
}
|
|
@@ -148,8 +148,6 @@ function runInPage(ctx) {
|
|
|
148
148
|
const name = getAccessibleNameText(el);
|
|
149
149
|
if (name) continue;
|
|
150
150
|
|
|
151
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
152
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
153
151
|
const eligInfo = helpers.getEligibilityInfo
|
|
154
152
|
? (() => {
|
|
155
153
|
try {
|
|
@@ -160,21 +158,21 @@ function runInPage(ctx) {
|
|
|
160
158
|
})()
|
|
161
159
|
: null;
|
|
162
160
|
|
|
163
|
-
occurrences.push(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
161
|
+
occurrences.push(
|
|
162
|
+
helpers.reportOccurrence(el, {
|
|
163
|
+
summary: 'This heading has no accessible name.',
|
|
164
|
+
hint: 'Add text content (or aria-label/aria-labelledby) to this heading, or remove it if it is not needed.',
|
|
165
|
+
i18n: {
|
|
166
|
+
summaryKey: 'emptyHeading_summary_cantTell',
|
|
167
|
+
hintKey: 'emptyHeading_hint_cantTell',
|
|
168
|
+
params: {}
|
|
169
|
+
},
|
|
170
|
+
data: {
|
|
171
|
+
details: { reasonCode: 'HEADING_EMPTY' },
|
|
172
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
);
|
|
178
176
|
}
|
|
179
177
|
|
|
180
178
|
if (applicableCount === 0) {
|
|
@@ -141,43 +141,40 @@ function runInPage(ctx) {
|
|
|
141
141
|
|
|
142
142
|
if (getVisibleText(el)) continue;
|
|
143
143
|
|
|
144
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
145
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
146
|
-
|
|
147
144
|
const ariaName = getAriaOnlyName(el);
|
|
148
145
|
if (ariaName) {
|
|
149
|
-
occurrences.push(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'
|
|
154
|
-
|
|
146
|
+
occurrences.push(
|
|
147
|
+
helpers.reportOccurrence(el, {
|
|
148
|
+
summary:
|
|
149
|
+
'This table header cell has no visible text — its only accessible name comes from aria-label/aria-labelledby, which real screen-reader/browser combinations (e.g. NVDA+Firefox, iOS VoiceOver+Safari) are known to ignore on <th> elements.',
|
|
150
|
+
hint: 'Add visible text content to this header cell (in addition to, or instead of, aria-label/aria-labelledby) — visible text is the only naming mechanism confirmed to work across tested screen readers.',
|
|
151
|
+
i18n: {
|
|
152
|
+
summaryKey: 'emptyTableHeader_summary_cantTell_ariaOnly',
|
|
153
|
+
hintKey: 'emptyTableHeader_hint_cantTell_ariaOnly',
|
|
154
|
+
params: {}
|
|
155
|
+
},
|
|
156
|
+
data: {
|
|
157
|
+
details: { reasonCode: 'TABLE_HEADER_NAME_NOT_VISIBLE_TEXT', ariaName }
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
occurrences.push(
|
|
165
|
+
helpers.reportOccurrence(el, {
|
|
166
|
+
summary: 'This table header cell has no accessible name.',
|
|
167
|
+
hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
|
|
155
168
|
i18n: {
|
|
156
|
-
summaryKey: '
|
|
157
|
-
hintKey: '
|
|
169
|
+
summaryKey: 'emptyTableHeader_summary_cantTell',
|
|
170
|
+
hintKey: 'emptyTableHeader_hint_cantTell',
|
|
158
171
|
params: {}
|
|
159
172
|
},
|
|
160
173
|
data: {
|
|
161
|
-
details: { reasonCode: '
|
|
174
|
+
details: { reasonCode: 'TABLE_HEADER_EMPTY' }
|
|
162
175
|
}
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
occurrences.push({
|
|
168
|
-
selector: stableSelector,
|
|
169
|
-
html,
|
|
170
|
-
summary: 'This table header cell has no accessible name.',
|
|
171
|
-
hint: 'Add text content (or aria-label/aria-labelledby) to this header cell, or remove it if it is not needed.',
|
|
172
|
-
i18n: {
|
|
173
|
-
summaryKey: 'emptyTableHeader_summary_cantTell',
|
|
174
|
-
hintKey: 'emptyTableHeader_hint_cantTell',
|
|
175
|
-
params: {}
|
|
176
|
-
},
|
|
177
|
-
data: {
|
|
178
|
-
details: { reasonCode: 'TABLE_HEADER_EMPTY' }
|
|
179
|
-
}
|
|
180
|
-
});
|
|
176
|
+
})
|
|
177
|
+
);
|
|
181
178
|
}
|
|
182
179
|
|
|
183
180
|
if (applicableCount === 0) {
|
|
@@ -185,20 +185,13 @@ function runInPage(ctx) {
|
|
|
185
185
|
|
|
186
186
|
const reasonCode =
|
|
187
187
|
method === 'title' ? 'label_from_title_primary' : 'label_from_placeholder_primary';
|
|
188
|
-
const methodLabel =
|
|
189
|
-
method === 'title'
|
|
190
|
-
? 'title'
|
|
191
|
-
: method === 'placeholder'
|
|
192
|
-
? 'placeholder'
|
|
193
|
-
: 'title or placeholder';
|
|
194
|
-
|
|
195
188
|
const baseOccurrence = {
|
|
196
|
-
summary:
|
|
189
|
+
summary: `Form control’s primary label is derived from ${method}.`,
|
|
197
190
|
hint: 'Prefer a persistent <label> or aria-labelledby. Avoid relying on placeholder/title as the primary label.',
|
|
198
191
|
i18n: {
|
|
199
192
|
summaryKey: 'formControl_programmaticLabelQuality_summary_cantTell',
|
|
200
193
|
hintKey: 'formControl_programmaticLabelQuality_hint_cantTell',
|
|
201
|
-
params: { element: (el.tagName || '').toLowerCase(), method
|
|
194
|
+
params: { element: (el.tagName || '').toLowerCase(), method }
|
|
202
195
|
},
|
|
203
196
|
data: {
|
|
204
197
|
visibilityFilter: vf || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
@@ -125,29 +125,24 @@ function runInPage(ctx) {
|
|
|
125
125
|
const { el, level } = headings[i];
|
|
126
126
|
|
|
127
127
|
if (level > highestSoFar + 1) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
data: {
|
|
144
|
-
details: {
|
|
145
|
-
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
146
|
-
fromLevel: highestSoFar,
|
|
147
|
-
toLevel: level
|
|
128
|
+
occurrences.push(
|
|
129
|
+
helpers.reportOccurrence(el, {
|
|
130
|
+
summary: `This heading jumps from level ${highestSoFar} to level ${level}, skipping a level.`,
|
|
131
|
+
hint: 'Use consecutive heading levels (do not skip a level when going deeper) so the document outline stays predictable.',
|
|
132
|
+
i18n: {
|
|
133
|
+
summaryKey: 'headingOrder_summary_cantTell',
|
|
134
|
+
hintKey: 'headingOrder_hint_cantTell',
|
|
135
|
+
params: { fromLevel: String(highestSoFar), toLevel: String(level) }
|
|
136
|
+
},
|
|
137
|
+
data: {
|
|
138
|
+
details: {
|
|
139
|
+
reasonCode: 'HEADING_ORDER_SKIPPED_LEVEL',
|
|
140
|
+
fromLevel: highestSoFar,
|
|
141
|
+
toLevel: level
|
|
142
|
+
}
|
|
148
143
|
}
|
|
149
|
-
}
|
|
150
|
-
|
|
144
|
+
})
|
|
145
|
+
);
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
if (level > highestSoFar) highestSoFar = level;
|
|
@@ -111,23 +111,20 @@ function runInPage(ctx) {
|
|
|
111
111
|
|
|
112
112
|
if (otherText.toLowerCase() !== alt.toLowerCase()) continue;
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
|
|
129
|
-
}
|
|
130
|
-
});
|
|
114
|
+
occurrences.push(
|
|
115
|
+
helpers.reportOccurrence(el, {
|
|
116
|
+
summary: "This image's alt text duplicates other visible text right next to it.",
|
|
117
|
+
hint: 'Make the alt text empty (alt="") if the image is purely decorative alongside the text, or remove the redundant duplication.',
|
|
118
|
+
i18n: {
|
|
119
|
+
summaryKey: 'imageRedundantAlt_summary_cantTell',
|
|
120
|
+
hintKey: 'imageRedundantAlt_hint_cantTell',
|
|
121
|
+
params: { alt }
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
details: { reasonCode: 'IMAGE_ALT_REDUNDANT', alt }
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
);
|
|
131
128
|
}
|
|
132
129
|
|
|
133
130
|
if (applicableCount === 0) {
|
|
@@ -71,6 +71,9 @@ function runInPage(ctx) {
|
|
|
71
71
|
const isAccTreeEligible =
|
|
72
72
|
helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
|
|
73
73
|
|
|
74
|
+
const getAriaNameInfo =
|
|
75
|
+
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
76
|
+
|
|
74
77
|
const getFocusableInfo =
|
|
75
78
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
76
79
|
|
|
@@ -107,6 +110,26 @@ function runInPage(ctx) {
|
|
|
107
110
|
return !focusable;
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
// alt="" plus a name from aria-label/aria-labelledby/title is the judgement
|
|
114
|
+
// call this rule reviews. alt="" with no other source leaves the control
|
|
115
|
+
// unnamed, which input-image-alt-present fails outright.
|
|
116
|
+
function hasNameFromOtherSource(el) {
|
|
117
|
+
if (getAriaNameInfo) {
|
|
118
|
+
try {
|
|
119
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
120
|
+
if (aria && aria.present && String(aria.value || '').trim()) return true;
|
|
121
|
+
} catch {
|
|
122
|
+
// fall through to title
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const title = el.getAttribute('title');
|
|
127
|
+
return title != null && String(title).trim() !== '';
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
const els = (() => {
|
|
111
134
|
try {
|
|
112
135
|
return Array.from(
|
|
@@ -143,6 +166,7 @@ function runInPage(ctx) {
|
|
|
143
166
|
|
|
144
167
|
// Rule-specific applicability (only elements that already have a text alternative mechanism)
|
|
145
168
|
if (!(el.getAttribute('alt') != null && String(el.getAttribute('alt')).trim() === '')) continue;
|
|
169
|
+
if (!hasNameFromOtherSource(el)) continue;
|
|
146
170
|
|
|
147
171
|
applicableCount += 1;
|
|
148
172
|
|
|
@@ -94,23 +94,21 @@ function runInPage(ctx) {
|
|
|
94
94
|
if (!nameInfo || nameInfo.mechanism !== 'title') continue;
|
|
95
95
|
|
|
96
96
|
const tag = el.tagName.toLowerCase();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
});
|
|
97
|
+
|
|
98
|
+
occurrences.push(
|
|
99
|
+
helpers.reportOccurrence(el, {
|
|
100
|
+
summary: 'This form control relies on the title attribute as its only label.',
|
|
101
|
+
hint: 'Add a visible <label> (or aria-label/aria-labelledby) in addition to, or instead of, the title attribute.',
|
|
102
|
+
i18n: {
|
|
103
|
+
summaryKey: 'labelTitleOnly_summary_cantTell',
|
|
104
|
+
hintKey: 'labelTitleOnly_hint_cantTell',
|
|
105
|
+
params: { element: tag }
|
|
106
|
+
},
|
|
107
|
+
data: {
|
|
108
|
+
details: { reasonCode: 'LABEL_TITLE_ONLY', element: tag }
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
);
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
if (applicableCount === 0) {
|
|
@@ -28,19 +28,13 @@
|
|
|
28
28
|
* HTML-AAM implicit-role mapping (header→banner, footer→contentinfo,
|
|
29
29
|
* main→main, nav→navigation, aside→complementary, section/form→
|
|
30
30
|
* region/form only when accessibly named).
|
|
31
|
-
* - Candidate selection (`isBannerCandidate`
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* sectioning
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* landmark, that same nesting would make it stop counting as a banner
|
|
39
|
-
* candidate in the first place, so the rule could never flag the one
|
|
40
|
-
* case it exists to catch. The ancestor walk (`hasLandmarkAncestor`) is intentionally
|
|
41
|
-
* asymmetric: it still uses the full suppression-aware
|
|
42
|
-
* `getLandmarkRole` for each ancestor, since an ancestor genuinely
|
|
43
|
-
* needs its own real role to count as blocking.
|
|
31
|
+
* - Candidate selection (`isBannerCandidate`) requires the element to really
|
|
32
|
+
* carry the banner role, via the suppression-aware `getLandmarkRole`. The
|
|
33
|
+
* two ancestor sets differ, so this does not make the rule vacuous: the
|
|
34
|
+
* suppression set is the sectioning tags plus `<main>`, while the blocking
|
|
35
|
+
* set is any landmark role, so a `<header>` inside `role="region"`,
|
|
36
|
+
* `<form>` or `<footer>` is still caught, and an explicit `role="banner"`
|
|
37
|
+
* is a candidate wherever it sits.
|
|
44
38
|
*/
|
|
45
39
|
|
|
46
40
|
const id = 'landmark-banner-is-top-level';
|
|
@@ -145,16 +139,11 @@ function runInPage(ctx) {
|
|
|
145
139
|
return getImplicitLandmarkRole(el);
|
|
146
140
|
}
|
|
147
141
|
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// of whether sectioning-ancestor nesting would currently suppress its
|
|
152
|
-
// implicit role; an explicit role="banner" is always a candidate too.
|
|
142
|
+
// A candidate must actually have the banner role. Per HTML-AAM a <header>
|
|
143
|
+
// descended from article/aside/main/nav/section is not a banner at all, so
|
|
144
|
+
// flagging it as a nested banner reports a landmark that does not exist.
|
|
153
145
|
function isBannerCandidate(el) {
|
|
154
|
-
|
|
155
|
-
const explicit = getExplicitRoleToken(el);
|
|
156
|
-
if (explicit) return explicit === 'banner';
|
|
157
|
-
return !!(el.tagName && el.tagName.toLowerCase() === 'header');
|
|
146
|
+
return getLandmarkRole(el) === 'banner';
|
|
158
147
|
}
|
|
159
148
|
|
|
160
149
|
function hasLandmarkAncestor(el) {
|
|
@@ -220,23 +209,20 @@ function runInPage(ctx) {
|
|
|
220
209
|
for (const el of banners) {
|
|
221
210
|
if (!hasLandmarkAncestor(el)) continue;
|
|
222
211
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
|
|
238
|
-
}
|
|
239
|
-
});
|
|
212
|
+
occurrences.push(
|
|
213
|
+
helpers.reportOccurrence(el, {
|
|
214
|
+
summary: 'This banner landmark is nested inside another landmark region.',
|
|
215
|
+
hint: 'Move the banner landmark (header/role="banner") so it is not contained by another landmark; a banner should be a top-level region of the page.',
|
|
216
|
+
i18n: {
|
|
217
|
+
summaryKey: 'landmarkBannerIsTopLevel_summary_cantTell',
|
|
218
|
+
hintKey: 'landmarkBannerIsTopLevel_hint_cantTell',
|
|
219
|
+
params: {}
|
|
220
|
+
},
|
|
221
|
+
data: {
|
|
222
|
+
details: { reasonCode: 'LANDMARK_BANNER_NOT_TOP_LEVEL' }
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
);
|
|
240
226
|
}
|
|
241
227
|
|
|
242
228
|
if (occurrences.length) {
|