@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
|
@@ -84,15 +84,8 @@ function runInPage(ctx) {
|
|
|
84
84
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(html) : 'html';
|
|
88
|
-
const htmlSnippet = helpers.getOuterHtmlSnippet
|
|
89
|
-
? helpers.getOuterHtmlSnippet(html)
|
|
90
|
-
: (html.outerHTML || '').slice(0, 200);
|
|
91
|
-
|
|
92
87
|
const occurrences = [
|
|
93
|
-
{
|
|
94
|
-
selector: stableSelector,
|
|
95
|
-
html: htmlSnippet,
|
|
88
|
+
helpers.reportOccurrence(html, {
|
|
96
89
|
summary: `The lang ("${lang}") and xml:lang ("${xmlLang}") attributes declare different languages.`,
|
|
97
90
|
hint: 'Make lang and xml:lang declare the same primary language, or remove the deprecated xml:lang attribute.',
|
|
98
91
|
i18n: {
|
|
@@ -103,7 +96,7 @@ function runInPage(ctx) {
|
|
|
103
96
|
data: {
|
|
104
97
|
details: { reasonCode: 'HTML_XML_LANG_MISMATCH', lang, xmlLang }
|
|
105
98
|
}
|
|
106
|
-
}
|
|
99
|
+
})
|
|
107
100
|
];
|
|
108
101
|
|
|
109
102
|
return {
|
|
@@ -322,47 +322,45 @@ function runInPage(ctx) {
|
|
|
322
322
|
if (!candidates.length) continue;
|
|
323
323
|
|
|
324
324
|
const tag = el.tagName.toLowerCase();
|
|
325
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
326
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
327
325
|
const shouldProbe = candidates.length === 1;
|
|
328
326
|
const runtimeProbe = shouldProbe
|
|
329
327
|
? probeImmediateFocusRedirect(el, contentDoc, candidates[0])
|
|
330
328
|
: null;
|
|
331
329
|
|
|
332
330
|
if (runtimeProbe && runtimeProbe.redirected) {
|
|
333
|
-
cantTellOccurrences.push(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
'
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
331
|
+
cantTellOccurrences.push(
|
|
332
|
+
helpers.reportOccurrence(el, {
|
|
333
|
+
summary:
|
|
334
|
+
'This frame has tabindex="-1" and a focusable candidate, but focus moves immediately to another target. Verify keyboard reachability in a real browser.',
|
|
335
|
+
hint: 'If this is an intentional focus handoff, ensure keyboard users cannot remain on hidden/intermediate frame content.',
|
|
336
|
+
i18n: null,
|
|
337
|
+
data: {
|
|
338
|
+
details: {
|
|
339
|
+
reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_RUNTIME_REDIRECT',
|
|
340
|
+
element: tag,
|
|
341
|
+
runtimeProbe
|
|
342
|
+
}
|
|
345
343
|
}
|
|
346
|
-
}
|
|
347
|
-
|
|
344
|
+
})
|
|
345
|
+
);
|
|
348
346
|
continue;
|
|
349
347
|
}
|
|
350
348
|
|
|
351
|
-
failOccurrences.push(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
'
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
349
|
+
failOccurrences.push(
|
|
350
|
+
helpers.reportOccurrence(el, {
|
|
351
|
+
summary:
|
|
352
|
+
'This frame has tabindex="-1" but its content contains focusable elements, which remain reachable by keyboard.',
|
|
353
|
+
hint: 'Remove focusable content from the frame, or remove tabindex="-1" if the frame is meant to be reachable.',
|
|
354
|
+
i18n: {
|
|
355
|
+
summaryKey: 'iframeFocusableContent_summary_fail',
|
|
356
|
+
hintKey: 'iframeFocusableContent_hint_fail',
|
|
357
|
+
params: { element: tag }
|
|
358
|
+
},
|
|
359
|
+
data: {
|
|
360
|
+
details: { reasonCode: 'IFRAME_TABINDEX_NEGATIVE_CONTENT_FOCUSABLE', element: tag }
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
);
|
|
366
364
|
}
|
|
367
365
|
|
|
368
366
|
if (applicableCount === 0) {
|
|
@@ -88,24 +88,22 @@ function runInPage(ctx) {
|
|
|
88
88
|
})()
|
|
89
89
|
: null;
|
|
90
90
|
const tag = el.tagName.toLowerCase();
|
|
91
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
92
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
93
91
|
|
|
94
|
-
occurrences.push(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
92
|
+
occurrences.push(
|
|
93
|
+
helpers.reportOccurrence(el, {
|
|
94
|
+
summary: 'This frame has no accessible name.',
|
|
95
|
+
hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
|
|
96
|
+
i18n: {
|
|
97
|
+
summaryKey: 'iframeNamePresent_summary_fail',
|
|
98
|
+
hintKey: 'iframeNamePresent_hint_fail',
|
|
99
|
+
params: { element: tag }
|
|
100
|
+
},
|
|
101
|
+
data: {
|
|
102
|
+
details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
|
|
103
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
);
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
if (applicableCount === 0) {
|
|
@@ -89,30 +89,25 @@ function runInPage(ctx) {
|
|
|
89
89
|
|
|
90
90
|
for (const el of els) {
|
|
91
91
|
const tag = el.tagName.toLowerCase();
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
details: {
|
|
109
|
-
reasonCode: 'IFRAME_TITLE_DUPLICATE',
|
|
110
|
-
element: tag,
|
|
111
|
-
title,
|
|
112
|
-
duplicateCount: els.length
|
|
92
|
+
occurrences.push(
|
|
93
|
+
helpers.reportOccurrence(el, {
|
|
94
|
+
summary: 'This frame’s title is not unique among the frames on this page.',
|
|
95
|
+
hint: 'Give each frame a distinct title describing its specific content or purpose.',
|
|
96
|
+
i18n: {
|
|
97
|
+
summaryKey: 'iframeTitleUnique_summary_fail',
|
|
98
|
+
hintKey: 'iframeTitleUnique_hint_fail',
|
|
99
|
+
params: { element: tag, title }
|
|
100
|
+
},
|
|
101
|
+
data: {
|
|
102
|
+
details: {
|
|
103
|
+
reasonCode: 'IFRAME_TITLE_DUPLICATE',
|
|
104
|
+
element: tag,
|
|
105
|
+
title,
|
|
106
|
+
duplicateCount: els.length
|
|
107
|
+
}
|
|
113
108
|
}
|
|
114
|
-
}
|
|
115
|
-
|
|
109
|
+
})
|
|
110
|
+
);
|
|
116
111
|
}
|
|
117
112
|
}
|
|
118
113
|
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <img> elements
|
|
13
|
-
* Images with role="presentation" or role="none" are excluded only when they are not focusable
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Applies to <img> elements included in the accessibility tree.
|
|
13
|
+
* Images with role="presentation" or role="none" are excluded only when they are not focusable,
|
|
14
|
+
* since a focusable one reverts to the img role under presentational roles conflict resolution.
|
|
15
|
+
* Hidden images are excluded whether or not they are focusable.
|
|
16
16
|
* @expectation
|
|
17
17
|
* Each applicable <img> element has an alt attribute.
|
|
18
18
|
* The alt attribute may be empty (alt="").
|
|
@@ -60,8 +60,15 @@ function runInPage(ctx) {
|
|
|
60
60
|
const getEligibilityInfo =
|
|
61
61
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// ACT 23a2a8 exempts programmatically hidden images, and that glossary term
|
|
64
|
+
// has no focusability carve-out, so an aria-hidden image stays out of scope
|
|
65
|
+
// even when tabbable; aria-hidden-focus reports that markup instead.
|
|
66
|
+
const isEligibleHelper =
|
|
67
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
68
|
+
? helpers.isIncludedInAccessibilityTree
|
|
69
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
70
|
+
? helpers.isAccTreeEligible
|
|
71
|
+
: null;
|
|
65
72
|
const getFocusableInfo =
|
|
66
73
|
helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
|
|
67
74
|
const getAriaNameInfo =
|
|
@@ -113,11 +120,11 @@ function runInPage(ctx) {
|
|
|
113
120
|
const el = imgs[i];
|
|
114
121
|
if (!el || !el.getAttribute) continue;
|
|
115
122
|
|
|
116
|
-
// Eligibility: only imgs exposed to assistive tech
|
|
117
|
-
if (
|
|
123
|
+
// Eligibility: only imgs exposed to assistive tech.
|
|
124
|
+
if (isEligibleHelper) {
|
|
118
125
|
let elig;
|
|
119
126
|
try {
|
|
120
|
-
elig =
|
|
127
|
+
elig = isEligibleHelper(el, ctx);
|
|
121
128
|
} catch {
|
|
122
129
|
elig = null;
|
|
123
130
|
}
|
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
* @standard WCAG 2.2
|
|
10
10
|
* @sc 1.1.1
|
|
11
11
|
* @applicability
|
|
12
|
-
* Applies to <input type="image"> elements
|
|
13
|
-
* Elements otherwise hidden from the accessibility tree remain applicable
|
|
14
|
-
* if they are focusable or referenced by IDREF relationships (per engine eligibility checks).
|
|
12
|
+
* Applies to <input type="image"> elements included in the accessibility tree.
|
|
15
13
|
* @expectation
|
|
16
|
-
* Each applicable <input type="image"> element has an alt attribute
|
|
14
|
+
* Each applicable <input type="image"> element has an alt attribute, and its
|
|
15
|
+
* accessible name is not the browser default for an image button.
|
|
17
16
|
* The alt attribute may be empty (alt="").
|
|
18
17
|
*/
|
|
19
18
|
|
|
@@ -72,8 +71,14 @@ function runInPage(ctx) {
|
|
|
72
71
|
const getEligibilityInfo =
|
|
73
72
|
helpers && typeof helpers.getEligibilityInfo === 'function' ? helpers.getEligibilityInfo : null;
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
// ACT 59796f applies to image buttons included in the accessibility tree,
|
|
75
|
+
// which excludes focusable content inside aria-hidden.
|
|
76
|
+
const isEligibleHelper =
|
|
77
|
+
helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
|
|
78
|
+
? helpers.isIncludedInAccessibilityTree
|
|
79
|
+
: helpers && typeof helpers.isAccTreeEligible === 'function'
|
|
80
|
+
? helpers.isAccTreeEligible
|
|
81
|
+
: null;
|
|
77
82
|
|
|
78
83
|
const getAriaNameInfo =
|
|
79
84
|
helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
|
|
@@ -99,63 +104,109 @@ function runInPage(ctx) {
|
|
|
99
104
|
for (const el of inputs) {
|
|
100
105
|
if (!el || !el.getAttribute) continue;
|
|
101
106
|
|
|
102
|
-
|
|
103
|
-
if (isAccTreeEligible) {
|
|
107
|
+
if (isEligibleHelper) {
|
|
104
108
|
const elig = (() => {
|
|
105
109
|
try {
|
|
106
|
-
return
|
|
110
|
+
return isEligibleHelper(el, ctx);
|
|
107
111
|
} catch {
|
|
108
|
-
return
|
|
112
|
+
return true;
|
|
109
113
|
}
|
|
110
114
|
})();
|
|
111
|
-
|
|
115
|
+
const eligible = typeof elig === 'boolean' ? elig : !(elig && elig.eligible === false);
|
|
116
|
+
if (!eligible) continue;
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
applicableCount += 1;
|
|
115
120
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
121
|
+
// The browser's own fallback name for an image button carries no
|
|
122
|
+
// information, so an author-supplied name equal to it is treated as no
|
|
123
|
+
// name at all (ACT 59796f). Only the English defaults are recognised:
|
|
124
|
+
// "Submit Query" from HTML-AAM, "Submit" from Chrome.
|
|
125
|
+
const effectiveName = (() => {
|
|
126
|
+
let v = '';
|
|
127
|
+
if (getAriaNameInfo) {
|
|
128
|
+
try {
|
|
129
|
+
const aria = getAriaNameInfo(el, ctx);
|
|
130
|
+
if (aria && aria.present && aria.value) v = String(aria.value);
|
|
131
|
+
} catch {
|
|
132
|
+
v = '';
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// A non-empty title attribute is HTML-AAM's own next fallback naming
|
|
134
|
-
// source once alt is entirely absent. Same gap img-alt-present handles
|
|
135
|
-
// for <img title="..."> with no alt.
|
|
136
|
-
const titleRaw = (() => {
|
|
137
|
-
try {
|
|
138
|
-
return el.getAttribute('title');
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
135
|
+
if (!v) {
|
|
136
|
+
const alt = el.getAttribute('alt');
|
|
137
|
+
if (alt != null && String(alt).trim()) v = String(alt);
|
|
141
138
|
}
|
|
139
|
+
if (!v) {
|
|
140
|
+
const t = el.getAttribute('title');
|
|
141
|
+
if (t != null && String(t).trim()) v = String(t);
|
|
142
|
+
}
|
|
143
|
+
return v.trim().toLowerCase();
|
|
142
144
|
})();
|
|
143
|
-
|
|
145
|
+
|
|
146
|
+
if (effectiveName === 'submit query' || effectiveName === 'submit') {
|
|
147
|
+
const eligInfoDefault = getEligibilityInfo
|
|
148
|
+
? getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
149
|
+
: null;
|
|
150
|
+
const defaultNameOccurrence = {
|
|
151
|
+
summary:
|
|
152
|
+
'Accessible name is the browser default for an image button, which conveys nothing.',
|
|
153
|
+
hint: 'Replace it with text describing what the button does, for example "Search".',
|
|
154
|
+
i18n: {
|
|
155
|
+
summaryKey: 'inputImage_altPresent_summary_defaultName',
|
|
156
|
+
hintKey: 'inputImage_altPresent_hint_defaultName',
|
|
157
|
+
params: { element: 'input[type=image]' }
|
|
158
|
+
},
|
|
159
|
+
data: {
|
|
160
|
+
visibilityFilter: eligInfoDefault || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
161
|
+
details: { reasonCode: 'default_name' }
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
occurrences.push(
|
|
165
|
+
helpers && typeof helpers.reportOccurrence === 'function'
|
|
166
|
+
? helpers.reportOccurrence(el, defaultNameOccurrence)
|
|
167
|
+
: { selector: '', html: '', ...defaultNameOccurrence }
|
|
168
|
+
);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// effectiveName already covers the naming sources HTML-AAM allows here,
|
|
173
|
+
// in order: aria-label/aria-labelledby, then alt, then title.
|
|
174
|
+
if (effectiveName) continue;
|
|
175
|
+
|
|
176
|
+
// An image button is a control, so an empty name fails whether alt is
|
|
177
|
+
// absent or present-but-empty. alt="" marks a decorative image, and an
|
|
178
|
+
// image button is never decorative.
|
|
179
|
+
const emptyAlt = el.getAttribute('alt') !== null;
|
|
144
180
|
|
|
145
181
|
const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
|
|
146
182
|
|
|
147
|
-
const baseOccurrence =
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
183
|
+
const baseOccurrence = emptyAlt
|
|
184
|
+
? {
|
|
185
|
+
summary: 'Empty alt="" on <input type="image"> leaves the control unnamed.',
|
|
186
|
+
hint: 'Describe the action in alt, or name the control with aria-label or aria-labelledby.',
|
|
187
|
+
i18n: {
|
|
188
|
+
summaryKey: 'inputImage_altPresent_summary_emptyAlt',
|
|
189
|
+
hintKey: 'inputImage_altPresent_hint_emptyAlt',
|
|
190
|
+
params: { element: 'input[type=image]' }
|
|
191
|
+
},
|
|
192
|
+
data: {
|
|
193
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
194
|
+
details: { reasonCode: 'empty_alt' }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
: {
|
|
198
|
+
summary: 'Missing alt attribute on <input type="image">.',
|
|
199
|
+
hint: 'Add an alt attribute (use alt="" only when a separate accessible name is provided).',
|
|
200
|
+
i18n: {
|
|
201
|
+
summaryKey: 'inputImage_altPresent_summary_fail',
|
|
202
|
+
hintKey: 'inputImage_altPresent_hint_fail',
|
|
203
|
+
params: { element: 'input[type=image]' }
|
|
204
|
+
},
|
|
205
|
+
data: {
|
|
206
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
207
|
+
details: { reasonCode: 'missing_alt' }
|
|
208
|
+
}
|
|
209
|
+
};
|
|
159
210
|
|
|
160
211
|
if (helpers && typeof helpers.reportOccurrence === 'function') {
|
|
161
212
|
occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
|
|
@@ -52,6 +52,56 @@ function runInPage(ctx) {
|
|
|
52
52
|
return v.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
|
|
56
|
+
// parenthesised text, case-fold and NFKD-normalise, then reduce every
|
|
57
|
+
// non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
|
|
58
|
+
// splitting on them, which distinguishes a real mismatch from one that is
|
|
59
|
+
// only a hyphenation difference.
|
|
60
|
+
function tokenize(s, hyphensJoin) {
|
|
61
|
+
let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
|
|
62
|
+
try {
|
|
63
|
+
v = v.normalize('NFKD');
|
|
64
|
+
} catch {
|
|
65
|
+
// Realm without String#normalize: the word comparison below still holds.
|
|
66
|
+
}
|
|
67
|
+
if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
|
|
68
|
+
return v
|
|
69
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
70
|
+
.split(' ')
|
|
71
|
+
.filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The label's words must appear adjacent and in order inside the name, so a
|
|
75
|
+
// scattered subsequence does not count. Words in `prefixable` may match a
|
|
76
|
+
// longer name word they start.
|
|
77
|
+
function containsWordRun(needle, hay, prefixable) {
|
|
78
|
+
if (!needle.length) return true;
|
|
79
|
+
for (let i = 0; i + needle.length <= hay.length; i++) {
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (let j = 0; j < needle.length; j++) {
|
|
82
|
+
const want = needle[j];
|
|
83
|
+
const got = hay[i + j];
|
|
84
|
+
if (got === want) continue;
|
|
85
|
+
if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
|
|
86
|
+
ok = false;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
if (ok) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A trailing period marks a word the author may have shortened ("Ave." for
|
|
95
|
+
// "Avenue"). Tokenizing removes the period, so collect these beforehand.
|
|
96
|
+
function abbreviatedWords(s) {
|
|
97
|
+
const out = new Set();
|
|
98
|
+
for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
|
|
99
|
+
const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
|
|
100
|
+
if (m) out.add(m[1].toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
55
105
|
function getElementDescriptor(el) {
|
|
56
106
|
const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
|
|
57
107
|
let role;
|
|
@@ -297,52 +347,71 @@ function runInPage(ctx) {
|
|
|
297
347
|
const accName = acc && acc.value != null ? String(acc.value) : '';
|
|
298
348
|
const accNorm = norm(accName);
|
|
299
349
|
|
|
300
|
-
const
|
|
350
|
+
const labelTokens = tokenize(visibleLabel, false);
|
|
351
|
+
const nameTokens = tokenize(accName, false);
|
|
352
|
+
const contains = containsWordRun(labelTokens, nameTokens, null);
|
|
301
353
|
|
|
354
|
+
// An abbreviation, or a word hyphenated differently in the two places, is
|
|
355
|
+
// not something markup settles: the author may have meant either. Report
|
|
356
|
+
// without asserting a defect instead of failing or staying silent.
|
|
357
|
+
let uncertainty = '';
|
|
302
358
|
if (!contains) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
359
|
+
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
360
|
+
uncertainty = 'HYPHENATION_DIFFERS';
|
|
361
|
+
} else {
|
|
362
|
+
const abbreviated = abbreviatedWords(visibleLabel);
|
|
363
|
+
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
|
|
364
|
+
uncertainty = 'POSSIBLE_ABBREVIATION';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!contains) {
|
|
369
|
+
occurrences.push(
|
|
370
|
+
helpers.reportOccurrence(el, {
|
|
371
|
+
...(uncertainty ? { outcome: 'cantTell' } : null),
|
|
372
|
+
summary: uncertainty
|
|
373
|
+
? 'Accessible name may not contain the visible label text.'
|
|
374
|
+
: 'Accessible name does not contain the visible label text.',
|
|
375
|
+
hint: uncertainty
|
|
376
|
+
? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
|
|
377
|
+
: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
|
|
378
|
+
i18n: {
|
|
379
|
+
summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
|
|
380
|
+
hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
|
|
381
|
+
params: {
|
|
382
|
+
element: getElementDescriptor(el),
|
|
383
|
+
visibleLabel: clipForSummary(visibleLabel),
|
|
384
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
385
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
data: {
|
|
389
|
+
details: {
|
|
390
|
+
reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
391
|
+
visibleLabel,
|
|
392
|
+
accessibleName: accName,
|
|
393
|
+
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
394
|
+
tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
|
|
395
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
396
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
397
|
+
}
|
|
331
398
|
}
|
|
332
|
-
}
|
|
333
|
-
|
|
399
|
+
})
|
|
400
|
+
);
|
|
334
401
|
}
|
|
335
402
|
}
|
|
336
403
|
|
|
337
404
|
if (applicableCount === 0)
|
|
338
405
|
return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
|
|
339
|
-
if (occurrences.length)
|
|
406
|
+
if (occurrences.length) {
|
|
407
|
+
const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
|
|
340
408
|
return {
|
|
341
409
|
ruleId: rule.ruleId,
|
|
342
|
-
outcome: 'fail',
|
|
410
|
+
outcome: anyFail ? 'fail' : 'cantTell',
|
|
343
411
|
severity: rule.defaultSeverity || 'minor',
|
|
344
412
|
occurrences
|
|
345
413
|
};
|
|
414
|
+
}
|
|
346
415
|
return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
|
|
347
416
|
}
|
|
348
417
|
|
|
@@ -144,7 +144,11 @@ function runInPage(ctx) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// Minimal BCP47 primary subtag check
|
|
147
|
-
|
|
147
|
+
const isValidTag =
|
|
148
|
+
helpers && typeof helpers.isValidLanguageTag === 'function'
|
|
149
|
+
? helpers.isValidLanguageTag
|
|
150
|
+
: (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
|
|
151
|
+
if (!isValidTag(lang)) {
|
|
148
152
|
return {
|
|
149
153
|
ruleId: rule.ruleId,
|
|
150
154
|
outcome: 'fail',
|