@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
|
@@ -10,17 +10,34 @@
|
|
|
10
10
|
* @sc 4.1.2
|
|
11
11
|
* @applicability
|
|
12
12
|
* Applies to <iframe>/<frame> elements that are eligible for the
|
|
13
|
-
* accessibility tree (isAccTreeEligible)
|
|
13
|
+
* accessibility tree (isAccTreeEligible) AND reachable by sequential
|
|
14
|
+
* focus navigation, both conditions required, regardless of role.
|
|
14
15
|
* @expectation
|
|
15
16
|
* The element has a non-empty accessible name via aria-labelledby,
|
|
16
17
|
* aria-label, or the title attribute. Unlike most interactive elements,
|
|
17
18
|
* an iframe's name is never derived from its rendered content (the
|
|
18
|
-
* embedded document is a separate browsing context)
|
|
19
|
+
* embedded document is a separate browsing context), this mirrors
|
|
19
20
|
* dialog-name-present's "name-from-author-only" reasoning.
|
|
20
21
|
* @implementation-notes
|
|
21
22
|
* - Uses helpers.getAccessibleNameInfo, which already stops at
|
|
22
23
|
* aria-label/aria-labelledby/label[for]/title without falling back to
|
|
23
|
-
* subtree text content
|
|
24
|
+
* subtree text content, the right shape for this element.
|
|
25
|
+
* - Per ACT cae760's own Applicability text, an iframe is only in scope
|
|
26
|
+
* when it is BOTH accessibility-tree-eligible AND reachable by
|
|
27
|
+
* sequential focus navigation, unconditionally, not only as a
|
|
28
|
+
* role="none"/"presentation" exception. Unlike most elements,
|
|
29
|
+
* <iframe>/<frame> are natively focusable by default (no tabindex
|
|
30
|
+
* needed), so `tabindex="-1"` (or any negative tabindex) exempts a
|
|
31
|
+
* frame regardless of whether it also carries a role, a plain
|
|
32
|
+
* `<iframe tabindex="-1">` with no role at all is just as out of scope
|
|
33
|
+
* as `<iframe role="none" tabindex="-1">`; ACT cae760's own passed
|
|
34
|
+
* example is the roleless case. (The role="none" reasoning, "because
|
|
35
|
+
* iframe elements are part of sequential focus navigation, the
|
|
36
|
+
* explicit semantic role of none will be ignored, due to Presentational
|
|
37
|
+
* Roles Conflict Resolution", is *why* a focusable role="none" iframe
|
|
38
|
+
* still needs a name, not a separate applicability path of its own.)
|
|
39
|
+
* helpers.getFocusableInfo doesn't model iframe/frame's native
|
|
40
|
+
* focusability at all, so it's computed locally here instead.
|
|
24
41
|
*/
|
|
25
42
|
|
|
26
43
|
const id = 'iframe-name-present';
|
|
@@ -62,9 +79,26 @@ function runInPage(ctx) {
|
|
|
62
79
|
const occurrences = [];
|
|
63
80
|
let applicableCount = 0;
|
|
64
81
|
|
|
82
|
+
// <iframe>/<frame> are natively focusable by default (no tabindex
|
|
83
|
+
// needed), unlike most elements, only an explicit negative tabindex
|
|
84
|
+
// removes them from the tab order.
|
|
85
|
+
function isFrameFocusable(el) {
|
|
86
|
+
try {
|
|
87
|
+
const tabindexRaw = el.getAttribute ? el.getAttribute('tabindex') : null;
|
|
88
|
+
if (tabindexRaw == null) return true;
|
|
89
|
+
const n = Number(String(tabindexRaw).trim());
|
|
90
|
+
if (Number.isFinite(n) && n < 0) return false;
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
65
97
|
for (const el of nodes) {
|
|
66
98
|
if (!el || !el.tagName) continue;
|
|
67
99
|
|
|
100
|
+
if (!isFrameFocusable(el)) continue;
|
|
101
|
+
|
|
68
102
|
if (helpers.isAccTreeEligible) {
|
|
69
103
|
const elig = helpers.isAccTreeEligible(el);
|
|
70
104
|
const isEligible = typeof elig === 'boolean' ? elig : !!(elig && elig.eligible);
|
|
@@ -88,24 +122,22 @@ function runInPage(ctx) {
|
|
|
88
122
|
})()
|
|
89
123
|
: null;
|
|
90
124
|
const tag = el.tagName.toLowerCase();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
});
|
|
125
|
+
|
|
126
|
+
occurrences.push(
|
|
127
|
+
helpers.reportOccurrence(el, {
|
|
128
|
+
summary: 'This frame has no accessible name.',
|
|
129
|
+
hint: 'Add a title attribute (or aria-label/aria-labelledby) describing the frame’s content or purpose.',
|
|
130
|
+
i18n: {
|
|
131
|
+
summaryKey: 'iframeNamePresent_summary_fail',
|
|
132
|
+
hintKey: 'iframeNamePresent_hint_fail',
|
|
133
|
+
params: { element: tag }
|
|
134
|
+
},
|
|
135
|
+
data: {
|
|
136
|
+
details: { reasonCode: 'IFRAME_NAME_MISSING', element: tag },
|
|
137
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
);
|
|
109
141
|
}
|
|
110
142
|
|
|
111
143
|
if (applicableCount === 0) {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* attribute.
|
|
14
14
|
* @expectation
|
|
15
15
|
* No two frames in scope share the same (trimmed, case-sensitive) title
|
|
16
|
-
* attribute value
|
|
16
|
+
* attribute value, a duplicate title prevents assistive technology
|
|
17
17
|
* users from telling frames apart when scanning by name.
|
|
18
18
|
* @implementation-notes
|
|
19
19
|
* - Distinct, atomic decision from iframe-name-present (presence):
|
|
@@ -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
|
|
|
@@ -157,13 +157,21 @@ function runInPage(ctx) {
|
|
|
157
157
|
// From here: applicable
|
|
158
158
|
applicableCount += 1;
|
|
159
159
|
|
|
160
|
-
// alt attribute presence check
|
|
161
|
-
|
|
160
|
+
// alt attribute presence check. A literally empty alt ("") is the
|
|
161
|
+
// HTML decorative marker and always satisfies this check. A present
|
|
162
|
+
// alt that is non-empty but trims to nothing (a lone space, a tab)
|
|
163
|
+
// does not get that treatment: per HTML-AAM the img-role conflict-
|
|
164
|
+
// resolution flip to presentation only triggers on the literal empty
|
|
165
|
+
// string, so the element keeps its img role while its computed
|
|
166
|
+
// accessible name is empty -- a real failure, not a decorative image.
|
|
167
|
+
let rawAlt;
|
|
162
168
|
try {
|
|
163
|
-
|
|
169
|
+
rawAlt = el.getAttribute('alt');
|
|
164
170
|
} catch {
|
|
165
|
-
|
|
171
|
+
rawAlt = null;
|
|
166
172
|
}
|
|
173
|
+
const isWhitespaceOnlyAlt = rawAlt !== null && rawAlt !== '' && trim(rawAlt) === '';
|
|
174
|
+
const hasAlt = rawAlt !== null && !isWhitespaceOnlyAlt;
|
|
167
175
|
if (hasAlt) continue;
|
|
168
176
|
|
|
169
177
|
// aria-label / aria-labelledby is also a valid, standards-recognized
|
|
@@ -2,12 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @check label-in-name
|
|
7
|
+
* @atomic true
|
|
8
|
+
* @summary A control's accessible name must contain its visible label text
|
|
9
|
+
* @standard WCAG 2.2
|
|
10
|
+
* @sc 2.5.3
|
|
11
|
+
* @applicability
|
|
12
|
+
* Applies to controls that carry aria-label or aria-labelledby, are
|
|
13
|
+
* visually rendered, and have visible label text this engine can extract
|
|
14
|
+
* deterministically, from an associated <label>, from the control's own
|
|
15
|
+
* rendered text, or from the elements aria-labelledby points at. The
|
|
16
|
+
* candidates are <button>, <a href>, <summary>, non-hidden form controls,
|
|
17
|
+
* and the button, link, checkbox, radio, switch, searchbox, tab, menuitem,
|
|
18
|
+
* menuitemcheckbox, menuitemradio, option, treeitem and gridcell roles,
|
|
19
|
+
* minus anything hidden or disabled. aria-hidden is not
|
|
20
|
+
* excluded: it changes nothing about what is rendered on screen, which is
|
|
21
|
+
* what this SC is about.
|
|
22
|
+
* @expectation
|
|
23
|
+
* The accessible name contains the visible label's words, adjacent and in
|
|
24
|
+
* order. The comparison is over words rather than characters:
|
|
25
|
+
* parenthesised text is dropped, case is folded, text is NFKD-normalised,
|
|
26
|
+
* and every non-letter/digit becomes a separator, so punctuation and
|
|
27
|
+
* spacing differences never decide the outcome. Four shapes markup cannot
|
|
28
|
+
* settle are reported as cantTell instead of fail: a word hyphenated
|
|
29
|
+
* differently in the two places; a visible word the author may have
|
|
30
|
+
* abbreviated, marked by its trailing period; visible text rendered
|
|
31
|
+
* through a known icon font (the DOM text is real words, but nothing
|
|
32
|
+
* readable actually renders); and a whole visible label of exactly one
|
|
33
|
+
* character that doesn't even appear inside the accessible name, which
|
|
34
|
+
* per ACT 2ee8b8 may be "non-text content" (e.g. "X" meaning "close")
|
|
35
|
+
* rather than literal text.
|
|
36
|
+
*/
|
|
37
|
+
|
|
5
38
|
const id = 'label-in-name';
|
|
6
39
|
|
|
7
40
|
const meta = {
|
|
8
41
|
title: 'Label in Name: accessible name contains visible text',
|
|
9
42
|
description:
|
|
10
|
-
'Checks that when a control has a visible text label, the accessible name
|
|
43
|
+
'Checks that when a control has a visible text label, the accessible name contains that visible label text (WCAG 2.5.3).',
|
|
11
44
|
i18n: {
|
|
12
45
|
titleKey: 'labelInName_title',
|
|
13
46
|
descriptionKey: 'labelInName_description'
|
|
@@ -147,7 +180,7 @@ function runInPage(ctx) {
|
|
|
147
180
|
// rendering as "format_color_fill") is DOM-visible pixels but is never
|
|
148
181
|
// perceived as literal readable words the way real visible text is.
|
|
149
182
|
// Excluding aria-hidden content is a cheap static-markup signal that gets
|
|
150
|
-
// the common case (decorative icon fonts) right
|
|
183
|
+
// the common case (decorative icon fonts) right. An icon-only button
|
|
151
184
|
// named via aria-label shouldn't have its glyph name counted as text.
|
|
152
185
|
function isAccEligible(el) {
|
|
153
186
|
if (!el) return false;
|
|
@@ -273,7 +306,9 @@ function runInPage(ctx) {
|
|
|
273
306
|
}
|
|
274
307
|
|
|
275
308
|
function getVisibleTextLabelInfo(el) {
|
|
276
|
-
// Returns { text, source } where source helps reporting
|
|
309
|
+
// Returns { text, source, sourceElements } where source helps reporting
|
|
310
|
+
// and sourceElements (the actual DOM nodes the visible text came from)
|
|
311
|
+
// lets the icon-font check below look at the right element's own style.
|
|
277
312
|
// Source policy (deterministic):
|
|
278
313
|
// 1) <label> association for form controls
|
|
279
314
|
// 2) visible text inside the element
|
|
@@ -288,18 +323,22 @@ function runInPage(ctx) {
|
|
|
288
323
|
if (isFormControl) {
|
|
289
324
|
const labels = getAssociatedLabelElements(el);
|
|
290
325
|
const labelParts = [];
|
|
326
|
+
const contributing = [];
|
|
291
327
|
for (const l of labels) {
|
|
292
328
|
if (!l || !isDomVisible(l)) continue;
|
|
293
329
|
const t = collectVisibleTextUnder(l);
|
|
294
|
-
if (t)
|
|
330
|
+
if (t) {
|
|
331
|
+
labelParts.push(t);
|
|
332
|
+
contributing.push(l);
|
|
333
|
+
}
|
|
295
334
|
}
|
|
296
335
|
const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
|
|
297
|
-
if (joined) return { text: joined, source: 'label' };
|
|
336
|
+
if (joined) return { text: joined, source: 'label', sourceElements: contributing };
|
|
298
337
|
}
|
|
299
338
|
|
|
300
339
|
// 2) Text inside the control itself
|
|
301
340
|
text = collectVisibleTextUnder(el);
|
|
302
|
-
if (text) return { text, source: 'self' };
|
|
341
|
+
if (text) return { text, source: 'self', sourceElements: [el] };
|
|
303
342
|
|
|
304
343
|
// 3) aria-labelledby referenced visible text (only if refs exist and are visible)
|
|
305
344
|
try {
|
|
@@ -307,20 +346,115 @@ function runInPage(ctx) {
|
|
|
307
346
|
if (idrefs && helpers.resolveIdRefs) {
|
|
308
347
|
const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
|
|
309
348
|
const parts = [];
|
|
349
|
+
const contributing = [];
|
|
310
350
|
for (const ref of r && Array.isArray(r.refs) ? r.refs : []) {
|
|
311
351
|
if (!ref || !ref.tagName) continue;
|
|
312
352
|
if (!isDomVisible(ref)) continue;
|
|
313
353
|
const t = collectVisibleTextUnder(ref);
|
|
314
|
-
if (t)
|
|
354
|
+
if (t) {
|
|
355
|
+
parts.push(t);
|
|
356
|
+
contributing.push(ref);
|
|
357
|
+
}
|
|
315
358
|
}
|
|
316
359
|
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
317
|
-
if (joined)
|
|
360
|
+
if (joined)
|
|
361
|
+
return { text: joined, source: 'aria-labelledby', sourceElements: contributing };
|
|
318
362
|
}
|
|
319
363
|
} catch {
|
|
320
364
|
// ignore
|
|
321
365
|
}
|
|
322
366
|
|
|
323
|
-
return { text: '', source };
|
|
367
|
+
return { text: '', source, sourceElements: [] };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Curated real-world icon-font family names. These fonts remap ordinary
|
|
371
|
+
// word glyphs to unrelated symbols via ligatures/PUA codepoints, so the
|
|
372
|
+
// DOM text is real words but nothing readable actually renders, ACT
|
|
373
|
+
// 2ee8b8's own passed example is exactly this (a button's DOM text
|
|
374
|
+
// "search" rendered as a magnifying-glass icon by "Material Icons").
|
|
375
|
+
// Same curated-list tradeoff as link-name-quality's phrase list.
|
|
376
|
+
const ICON_FONT_FAMILIES = new Set([
|
|
377
|
+
'material icons',
|
|
378
|
+
'material icons outlined',
|
|
379
|
+
'material icons round',
|
|
380
|
+
'material icons sharp',
|
|
381
|
+
'material icons two tone',
|
|
382
|
+
'material symbols outlined',
|
|
383
|
+
'material symbols rounded',
|
|
384
|
+
'material symbols sharp',
|
|
385
|
+
'font awesome 5 free',
|
|
386
|
+
'font awesome 5 brands',
|
|
387
|
+
'font awesome 5 pro',
|
|
388
|
+
'font awesome 6 free',
|
|
389
|
+
'font awesome 6 brands',
|
|
390
|
+
'font awesome 6 pro',
|
|
391
|
+
'fontawesome',
|
|
392
|
+
'glyphicons halflings',
|
|
393
|
+
'ionicons',
|
|
394
|
+
'icomoon',
|
|
395
|
+
'bootstrap-icons',
|
|
396
|
+
'bootstrap icons',
|
|
397
|
+
'feather'
|
|
398
|
+
]);
|
|
399
|
+
|
|
400
|
+
// Same two-tier lookup as avoid-inline-spacing.js's computedStyleOf:
|
|
401
|
+
// helpers.computedStyle isn't actually part of the public helpers API
|
|
402
|
+
// (it's internal to dom-helpers.js), so the realm's own getComputedStyle
|
|
403
|
+
// is what actually resolves a stylesheet-declared font-family.
|
|
404
|
+
function computedStyleOf(node) {
|
|
405
|
+
if (helpers && typeof helpers.computedStyle === 'function') {
|
|
406
|
+
try {
|
|
407
|
+
const cs = helpers.computedStyle(node);
|
|
408
|
+
if (cs) return cs;
|
|
409
|
+
} catch {
|
|
410
|
+
// fall through to the realm's own view
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const view = node.ownerDocument && node.ownerDocument.defaultView;
|
|
415
|
+
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(node);
|
|
416
|
+
} catch {
|
|
417
|
+
// no computed style available
|
|
418
|
+
}
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isIconFontElement(node) {
|
|
423
|
+
if (!node) return false;
|
|
424
|
+
const cs = computedStyleOf(node);
|
|
425
|
+
if (!cs) return false;
|
|
426
|
+
let family;
|
|
427
|
+
try {
|
|
428
|
+
family =
|
|
429
|
+
cs.fontFamily || (cs.getPropertyValue ? cs.getPropertyValue('font-family') : '') || '';
|
|
430
|
+
} catch {
|
|
431
|
+
family = '';
|
|
432
|
+
}
|
|
433
|
+
if (!family) return false;
|
|
434
|
+
const names = String(family)
|
|
435
|
+
.split(',')
|
|
436
|
+
.map((s) =>
|
|
437
|
+
s
|
|
438
|
+
.trim()
|
|
439
|
+
.replace(/^['"]|['"]$/g, '')
|
|
440
|
+
.toLowerCase()
|
|
441
|
+
);
|
|
442
|
+
return names.some((n) => ICON_FONT_FAMILIES.has(n));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ACT 2ee8b8's own passed example `<button aria-label="close">X</button>`:
|
|
446
|
+
// a single character standing in for an icon ("x" meaning "close") is
|
|
447
|
+
// "non-text content" per the rule's own background text, which names no
|
|
448
|
+
// algorithmic test for it. Scoped narrowly to a whole visible label of
|
|
449
|
+
// exactly one character that doesn't even appear inside the accessible
|
|
450
|
+
// name: "x" has no relation at all to "close", which is the icon-glyph
|
|
451
|
+
// shape. A single character that DOES appear in the name (e.g. visible
|
|
452
|
+
// "1" against aria-label "1a") is a real word-boundary mismatch, not a
|
|
453
|
+
// symbol standing in for something else, and still fails outright.
|
|
454
|
+
function isSingleSymbolicCharacter(text, accessibleNameNorm) {
|
|
455
|
+
const t = norm(text);
|
|
456
|
+
if (!t || Array.from(t).length !== 1) return false;
|
|
457
|
+
return accessibleNameNorm.indexOf(t) === -1;
|
|
324
458
|
}
|
|
325
459
|
|
|
326
460
|
for (const el of nodes) {
|
|
@@ -351,58 +485,73 @@ function runInPage(ctx) {
|
|
|
351
485
|
const nameTokens = tokenize(accName, false);
|
|
352
486
|
const contains = containsWordRun(labelTokens, nameTokens, null);
|
|
353
487
|
|
|
354
|
-
// An abbreviation,
|
|
355
|
-
// not
|
|
356
|
-
//
|
|
488
|
+
// An abbreviation, a word hyphenated differently, or visible text that
|
|
489
|
+
// may not be literal text at all (an icon-font glyph, a single symbolic
|
|
490
|
+
// character) is not something markup settles: the author may have meant
|
|
491
|
+
// either. Report without asserting a defect instead of failing or
|
|
492
|
+
// staying silent.
|
|
357
493
|
let uncertainty = '';
|
|
358
494
|
if (!contains) {
|
|
359
495
|
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
360
496
|
uncertainty = 'HYPHENATION_DIFFERS';
|
|
361
497
|
} else {
|
|
362
498
|
const abbreviated = abbreviatedWords(visibleLabel);
|
|
363
|
-
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
|
|
499
|
+
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
|
|
364
500
|
uncertainty = 'POSSIBLE_ABBREVIATION';
|
|
501
|
+
} else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
|
|
502
|
+
uncertainty = 'POSSIBLE_ICON_FONT_GLYPH';
|
|
503
|
+
} else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
|
|
504
|
+
uncertainty = 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
505
|
+
}
|
|
365
506
|
}
|
|
366
507
|
}
|
|
367
508
|
|
|
509
|
+
const isSymbolicUncertainty =
|
|
510
|
+
uncertainty === 'POSSIBLE_ICON_FONT_GLYPH' || uncertainty === 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
511
|
+
|
|
368
512
|
if (!contains) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
:
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
513
|
+
occurrences.push(
|
|
514
|
+
helpers.reportOccurrence(el, {
|
|
515
|
+
...(uncertainty ? { outcome: 'cantTell' } : null),
|
|
516
|
+
summary: uncertainty
|
|
517
|
+
? 'Accessible name may not contain the visible label text.'
|
|
518
|
+
: 'Accessible name does not contain the visible label text.',
|
|
519
|
+
hint: !uncertainty
|
|
520
|
+
? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
|
|
521
|
+
: isSymbolicUncertainty
|
|
522
|
+
? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
|
|
523
|
+
: 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
|
|
524
|
+
i18n: {
|
|
525
|
+
summaryKey: !uncertainty
|
|
526
|
+
? 'labelInName_summary_fail'
|
|
527
|
+
: isSymbolicUncertainty
|
|
528
|
+
? 'labelInName_summary_cantTell_symbolic'
|
|
529
|
+
: 'labelInName_summary_cantTell',
|
|
530
|
+
hintKey: !uncertainty
|
|
531
|
+
? 'labelInName_hint_fail'
|
|
532
|
+
: isSymbolicUncertainty
|
|
533
|
+
? 'labelInName_hint_cantTell_symbolic'
|
|
534
|
+
: 'labelInName_hint_cantTell',
|
|
535
|
+
params: {
|
|
536
|
+
element: getElementDescriptor(el),
|
|
537
|
+
visibleLabel: clipForSummary(visibleLabel),
|
|
538
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
539
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
data: {
|
|
543
|
+
details: {
|
|
544
|
+
reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
545
|
+
visibleLabel,
|
|
546
|
+
accessibleName: accName,
|
|
547
|
+
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
548
|
+
tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
|
|
549
|
+
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
550
|
+
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
551
|
+
}
|
|
392
552
|
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
details: {
|
|
396
|
-
reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
397
|
-
visibleLabel,
|
|
398
|
-
accessibleName: accName,
|
|
399
|
-
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|
|
400
|
-
tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
|
|
401
|
-
labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
|
|
402
|
-
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
});
|
|
553
|
+
})
|
|
554
|
+
);
|
|
406
555
|
}
|
|
407
556
|
}
|
|
408
557
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Applies to <a href> elements whose immediate parent element also has
|
|
13
13
|
* at least one direct-child text node with non-whitespace content
|
|
14
14
|
* (i.e. the link sits inline within a run of plain text, not as a
|
|
15
|
-
* standalone item
|
|
15
|
+
* standalone item, e.g. not the sole content of a <li> nav item).
|
|
16
16
|
* @expectation
|
|
17
17
|
* A link inside a text block must be visually distinguishable from the
|
|
18
18
|
* surrounding text by at least one non-color means:
|
|
@@ -20,29 +20,29 @@
|
|
|
20
20
|
* - a different font-weight than the surrounding text, OR
|
|
21
21
|
* - a different font-style than the surrounding text, OR
|
|
22
22
|
* - a contrast ratio of at least 3:1 between the link's text color and
|
|
23
|
-
* the surrounding text's color (WCAG technique G183's threshold
|
|
23
|
+
* the surrounding text's color (WCAG technique G183's threshold,
|
|
24
24
|
* sufficient contrast alone is an accepted alternative to underline).
|
|
25
25
|
* Fails only when none of the above hold AND the color contrast between
|
|
26
|
-
* link and surrounding text is confidently computable and below 3:1
|
|
26
|
+
* link and surrounding text is confidently computable and below 3:1,
|
|
27
27
|
* i.e. color is demonstrably the only cue.
|
|
28
28
|
* @implementation-notes
|
|
29
29
|
* - "Surrounding text style" is approximated as the link's immediate
|
|
30
30
|
* parent element's own computed style, not a full inline-context walk
|
|
31
|
-
* of the actual adjacent text node(s)
|
|
31
|
+
* of the actual adjacent text node(s), a deliberate scope-down, since
|
|
32
32
|
* plain text nodes inherit their rendering from the parent in the
|
|
33
33
|
* overwhelming majority of real markup.
|
|
34
34
|
* - When contrast is not confidently computable (background image/
|
|
35
|
-
* gradient, blend mode, filter, non-opaque ancestor
|
|
35
|
+
* gradient, blend mode, filter, non-opaque ancestor, same blockers
|
|
36
36
|
* `contrast-minimum`/`contrast-computable` use), the link is silently
|
|
37
37
|
* skipped rather than flagged or reported as cantTell, to keep `fail`
|
|
38
38
|
* reserved for deterministic, high-confidence violations. This means
|
|
39
|
-
* the rule never emits cantTell
|
|
39
|
+
* the rule never emits cantTell, outcome is notApplicable/pass/fail
|
|
40
40
|
* only, matching this repo's other Tier 2 mechanical rules.
|
|
41
41
|
* - Reuses the shared `helpers.contrast` subsystem (same
|
|
42
42
|
* computeEffectiveForeground/Background, getComputabilityBlocker,
|
|
43
43
|
* contrastRatio helpers as `contrast-minimum`), rather than re-deriving
|
|
44
44
|
* color math independently.
|
|
45
|
-
* - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`)
|
|
45
|
+
* - Scoped to `a[href]` only (not `area[href]` or `[role="link"]`),
|
|
46
46
|
* matches the common real-world shape of this issue (prose links).
|
|
47
47
|
*/
|
|
48
48
|
|
|
@@ -52,7 +52,7 @@ const meta = {
|
|
|
52
52
|
title:
|
|
53
53
|
'Links in text blocks must be distinguishable from surrounding text without relying on color alone',
|
|
54
54
|
description:
|
|
55
|
-
'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference
|
|
55
|
+
'Checks that a link inside a run of text is visually distinguishable from the surrounding text by underline, a font-weight/style difference, or a sufficient (>=3:1) color-contrast difference, not by color alone.',
|
|
56
56
|
i18n: {
|
|
57
57
|
titleKey: 'linkInTextBlock_title',
|
|
58
58
|
descriptionKey: 'linkInTextBlock_description'
|
|
@@ -179,7 +179,7 @@ function runInPage(ctx) {
|
|
|
179
179
|
try {
|
|
180
180
|
const blocker = c.getComputabilityBlocker(el);
|
|
181
181
|
if (blocker && blocker.ok === false) {
|
|
182
|
-
// Not confidently computable
|
|
182
|
+
// Not confidently computable, skip (benefit of the doubt).
|
|
183
183
|
} else {
|
|
184
184
|
const bg = c.computeEffectiveBackground(el, {
|
|
185
185
|
contrast: { mode, rootCanvasFallback },
|
|
@@ -204,7 +204,7 @@ function runInPage(ctx) {
|
|
|
204
204
|
|
|
205
205
|
if (!(ratio >= 3)) flagged = true;
|
|
206
206
|
}
|
|
207
|
-
// else: not confidently computable
|
|
207
|
+
// else: not confidently computable, skip.
|
|
208
208
|
}
|
|
209
209
|
} catch {
|
|
210
210
|
// no-throw: treat as not computable, skip.
|
|
@@ -215,31 +215,29 @@ function runInPage(ctx) {
|
|
|
215
215
|
const eligInfo = helpers.getEligibilityInfo
|
|
216
216
|
? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
|
|
217
217
|
: null;
|
|
218
|
-
const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
|
|
219
|
-
const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
|
|
220
218
|
const tag = (el.tagName || '').toLowerCase();
|
|
221
219
|
const ratioStr = c.round2 ? c.round2(ratio) : String(ratio);
|
|
222
220
|
|
|
223
|
-
occurrences.push(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
'
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
221
|
+
occurrences.push(
|
|
222
|
+
helpers.reportOccurrence(el, {
|
|
223
|
+
summary:
|
|
224
|
+
'This link in a block of text relies on color alone to be distinguished from the surrounding text.',
|
|
225
|
+
hint: 'Add an underline, a font-weight/style difference, or increase the color contrast between the link and surrounding text to at least 3:1.',
|
|
226
|
+
i18n: {
|
|
227
|
+
summaryKey: 'linkInTextBlock_summary_fail',
|
|
228
|
+
hintKey: 'linkInTextBlock_hint_fail',
|
|
229
|
+
params: { element: tag, ratio: String(ratioStr), threshold: '3' }
|
|
230
|
+
},
|
|
231
|
+
data: {
|
|
232
|
+
visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] },
|
|
233
|
+
details: {
|
|
234
|
+
reasonCode: 'COLOR_ONLY_DIFFERENTIATION',
|
|
235
|
+
metrics: { ratio, threshold: 3 },
|
|
236
|
+
colors: { linkForegroundHex: fgLinkHex, surroundingTextForegroundHex: fgParentHex }
|
|
237
|
+
}
|
|
240
238
|
}
|
|
241
|
-
}
|
|
242
|
-
|
|
239
|
+
})
|
|
240
|
+
);
|
|
243
241
|
}
|
|
244
242
|
|
|
245
243
|
if (applicableCount === 0) {
|