@surea11y/core 1.5.0 → 1.7.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 +240 -149
- package/README.md +51 -44
- package/docs/ACT_RULE_MAPPING.md +245 -0
- package/docs/API_STABILITY.md +53 -5
- package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
- package/docs/DESIGN_CHALLENGES.md +367 -0
- package/docs/EARL.md +100 -0
- package/docs/ENGINE_OPTIONS.md +42 -4
- package/docs/I18N.md +4 -4
- package/docs/INTEGRATION.md +4 -2
- package/docs/LIMITATIONS.md +9 -5
- package/docs/OUTPUT_SCHEMA.md +44 -6
- package/docs/POLICY.md +1 -1
- package/docs/REPORT.md +1 -1
- package/docs/RULE_AUTHORING.md +63 -36
- package/docs/RULE_CATALOG.md +1928 -169
- package/docs/RULE_HELPERS.md +333 -0
- package/docs/RULE_TAXONOMY.md +27 -6
- package/docs/SARIF.md +21 -2
- package/docs/TROUBLESHOOTING.md +2 -2
- package/docs/WCAG_CONFORMANCE.md +34 -10
- package/package.json +11 -9
- package/src/baseline.js +3 -3
- package/src/checks/automatic/area-alt-present.js +2 -2
- package/src/checks/automatic/aria-allowed-attr.js +74 -10
- package/src/checks/automatic/aria-allowed-role.js +34 -25
- package/src/checks/automatic/aria-braille-equivalent.js +21 -13
- package/src/checks/automatic/aria-conditional-attr.js +22 -15
- package/src/checks/automatic/aria-deprecated-role.js +13 -1
- package/src/checks/automatic/aria-hidden-body.js +3 -3
- package/src/checks/automatic/aria-hidden-focus.js +5 -5
- package/src/checks/automatic/aria-prohibited-attr.js +23 -18
- package/src/checks/automatic/aria-prohibited-children.js +136 -43
- package/src/checks/automatic/aria-required-attr.js +119 -24
- package/src/checks/automatic/aria-required-children.js +54 -30
- package/src/checks/automatic/aria-required-parent.js +93 -15
- package/src/checks/automatic/aria-role-name-present.js +37 -23
- package/src/checks/automatic/aria-roles-valid.js +52 -21
- package/src/checks/automatic/aria-valid-attr-value.js +89 -33
- package/src/checks/automatic/aria-valid-attr.js +15 -10
- package/src/checks/automatic/autocomplete-valid.js +2 -2
- package/src/checks/automatic/avoid-inline-spacing.js +133 -6
- package/src/checks/automatic/binary-control-name-present.js +27 -5
- package/src/checks/automatic/button-name-present.js +92 -6
- package/src/checks/automatic/combobox-name-present.js +26 -6
- package/src/checks/automatic/contrast-computable.js +42 -0
- package/src/checks/automatic/contrast-enhanced.js +33 -1
- package/src/checks/automatic/contrast-minimum.js +33 -1
- package/src/checks/automatic/css-orientation-lock.js +138 -24
- package/src/checks/automatic/definition-list-children-valid.js +7 -8
- package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
- package/src/checks/automatic/dialog-name-present.js +20 -2
- package/src/checks/automatic/duplicate-id-aria.js +10 -3
- package/src/checks/automatic/duplicate-id.js +203 -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 +10 -1
- package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
- package/src/checks/automatic/iframe-focusable-content.js +68 -7
- package/src/checks/automatic/iframe-name-present.js +37 -3
- package/src/checks/automatic/iframe-title-unique.js +1 -1
- package/src/checks/automatic/img-alt-present.js +12 -4
- package/src/checks/automatic/label-in-name.js +204 -68
- package/src/checks/automatic/link-in-text-block.js +285 -29
- package/src/checks/automatic/link-name-present.js +22 -1
- package/src/checks/automatic/list-children-valid.js +6 -6
- package/src/checks/automatic/listbox-name-present.js +28 -8
- package/src/checks/automatic/listitem-parent-valid.js +4 -4
- package/src/checks/automatic/menuitem-name-present.js +20 -2
- package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
- package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
- package/src/checks/automatic/meter-name-present.js +23 -4
- package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
- package/src/checks/automatic/option-name-present.js +23 -4
- 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 +23 -4
- package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
- package/src/checks/automatic/searchbox-name-present.js +28 -8
- package/src/checks/automatic/server-side-image-map-absent.js +1 -1
- package/src/checks/automatic/slider-name-present.js +27 -6
- package/src/checks/automatic/spinbutton-name-present.js +28 -8
- package/src/checks/automatic/summary-name-present.js +18 -2
- 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 +21 -2
- package/src/checks/automatic/table-headers-attr-valid.js +43 -8
- package/src/checks/automatic/table-th-has-data-cells.js +61 -5
- package/src/checks/automatic/target-size-minimum.js +155 -58
- package/src/checks/automatic/td-has-header.js +24 -23
- package/src/checks/automatic/textbox-name-present.js +28 -8
- package/src/checks/automatic/tooltip-name-present.js +21 -2
- package/src/checks/automatic/treeitem-name-present.js +23 -4
- package/src/checks/automatic/valid-lang.js +92 -7
- package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
- package/src/checks/manual/accesskeys-manual.js +3 -3
- 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 +5 -5
- package/src/checks/manual/aria-text-manual.js +4 -4
- package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
- 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 +58 -11
- package/src/checks/manual/empty-table-header-manual.js +8 -8
- package/src/checks/manual/focus-order-semantics-manual.js +15 -15
- package/src/checks/manual/form-control-label-quality-manual.js +563 -0
- package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
- package/src/checks/manual/heading-order-manual.js +3 -3
- 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 +4 -4
- 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 +4 -4
- package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
- package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
- package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
- package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
- package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
- package/src/checks/manual/landmark-one-main-manual.js +6 -6
- package/src/checks/manual/landmark-unique-manual.js +9 -9
- package/src/checks/manual/link-name-quality-manual.js +161 -32
- package/src/checks/manual/media-transcript-present-manual.js +2 -3
- package/src/checks/manual/meta-viewport-large-manual.js +2 -2
- package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
- 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 +6 -6
- package/src/checks/manual/page-title-patterns-manual.js +26 -3
- package/src/checks/manual/password-paste-enabled-manual.js +255 -0
- package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
- package/src/checks/manual/region-manual.js +19 -19
- package/src/checks/manual/scope-attr-valid-manual.js +2 -2
- package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
- package/src/checks/manual/skip-link-manual.js +5 -5
- package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
- package/src/checks/manual/tabindex-manual.js +2 -2
- package/src/checks/manual/table-duplicate-name-manual.js +2 -2
- 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 +8880 -41883
- package/src/earl.js +144 -0
- package/src/report.js +2 -2
- package/src/sarif.js +22 -2
- package/surea11y.browser.js +10 -37882
- package/surea11y.i18n.de.js +2 -21
- package/surea11y.i18n.es.js +2 -21
- package/surea11y.i18n.fr.js +2 -21
- 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);
|
|
@@ -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):
|
|
@@ -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;
|
|
@@ -221,59 +254,28 @@ function runInPage(ctx) {
|
|
|
221
254
|
return parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
222
255
|
}
|
|
223
256
|
|
|
257
|
+
// Real <label> elements associated with a native form control -- the
|
|
258
|
+
// shared dom-helpers.js implementation (a `for`-attribute index plus a
|
|
259
|
+
// bounded closest('label') walk), not the native `.labels`/`.control`
|
|
260
|
+
// pair: jsdom implements those as a whole-document walk on every access
|
|
261
|
+
// (see that function's own header comment for the full explanation),
|
|
262
|
+
// real cost this engine's Node/jsdom runtime pays, not just a browser
|
|
263
|
+
// realm this code happens to also run in.
|
|
224
264
|
function getAssociatedLabelElements(control) {
|
|
225
|
-
|
|
226
|
-
try {
|
|
227
|
-
if (control && control.labels && typeof control.labels.length === 'number') {
|
|
228
|
-
for (const l of control.labels) labels.push(l);
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
// ignore
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Fallback: wrapped label
|
|
235
|
-
try {
|
|
236
|
-
const w = control && control.closest ? control.closest('label') : null;
|
|
237
|
-
if (w) labels.push(w);
|
|
238
|
-
} catch {
|
|
239
|
-
// ignore
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Fallback: label[for=id]. Uses `document` directly rather than
|
|
243
|
-
// ctx.root -- label[for] association is a document-wide relationship
|
|
244
|
-
// (IDs are document-unique), not bounded by whatever contextSelector
|
|
245
|
-
// region happens to be scanned, and ctx.root is an array (multi-region
|
|
246
|
-
// contextSelector support), not a single element with its own
|
|
247
|
-
// .querySelector to call directly.
|
|
248
|
-
try {
|
|
249
|
-
const idAttribute = control && control.getAttribute ? control.getAttribute('id') || '' : '';
|
|
250
|
-
const key = String(idAttribute || '').trim();
|
|
251
|
-
if (key && document && document.querySelector) {
|
|
252
|
-
const l = document.querySelector('label[for="' + CSS.escape(key) + '"]');
|
|
253
|
-
if (l) labels.push(l);
|
|
254
|
-
}
|
|
255
|
-
} catch {
|
|
256
|
-
// ignore
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// De-dupe in document order
|
|
260
|
-
const seen = new Set();
|
|
261
|
-
const out = [];
|
|
262
|
-
for (const l of labels) {
|
|
265
|
+
if (helpers && typeof helpers.getAssociatedLabelElements === 'function') {
|
|
263
266
|
try {
|
|
264
|
-
|
|
265
|
-
if (seen.has(l)) continue;
|
|
266
|
-
seen.add(l);
|
|
267
|
-
out.push(l);
|
|
267
|
+
return helpers.getAssociatedLabelElements(control) || [];
|
|
268
268
|
} catch {
|
|
269
|
-
|
|
269
|
+
return [];
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
-
return
|
|
272
|
+
return [];
|
|
273
273
|
}
|
|
274
274
|
|
|
275
275
|
function getVisibleTextLabelInfo(el) {
|
|
276
|
-
// Returns { text, source } where source helps reporting
|
|
276
|
+
// Returns { text, source, sourceElements } where source helps reporting
|
|
277
|
+
// and sourceElements (the actual DOM nodes the visible text came from)
|
|
278
|
+
// lets the icon-font check below look at the right element's own style.
|
|
277
279
|
// Source policy (deterministic):
|
|
278
280
|
// 1) <label> association for form controls
|
|
279
281
|
// 2) visible text inside the element
|
|
@@ -288,18 +290,22 @@ function runInPage(ctx) {
|
|
|
288
290
|
if (isFormControl) {
|
|
289
291
|
const labels = getAssociatedLabelElements(el);
|
|
290
292
|
const labelParts = [];
|
|
293
|
+
const contributing = [];
|
|
291
294
|
for (const l of labels) {
|
|
292
295
|
if (!l || !isDomVisible(l)) continue;
|
|
293
296
|
const t = collectVisibleTextUnder(l);
|
|
294
|
-
if (t)
|
|
297
|
+
if (t) {
|
|
298
|
+
labelParts.push(t);
|
|
299
|
+
contributing.push(l);
|
|
300
|
+
}
|
|
295
301
|
}
|
|
296
302
|
const joined = labelParts.join(' ').replace(/\s+/g, ' ').trim();
|
|
297
|
-
if (joined) return { text: joined, source: 'label' };
|
|
303
|
+
if (joined) return { text: joined, source: 'label', sourceElements: contributing };
|
|
298
304
|
}
|
|
299
305
|
|
|
300
306
|
// 2) Text inside the control itself
|
|
301
307
|
text = collectVisibleTextUnder(el);
|
|
302
|
-
if (text) return { text, source: 'self' };
|
|
308
|
+
if (text) return { text, source: 'self', sourceElements: [el] };
|
|
303
309
|
|
|
304
310
|
// 3) aria-labelledby referenced visible text (only if refs exist and are visible)
|
|
305
311
|
try {
|
|
@@ -307,20 +313,115 @@ function runInPage(ctx) {
|
|
|
307
313
|
if (idrefs && helpers.resolveIdRefs) {
|
|
308
314
|
const r = helpers.resolveIdRefs(idrefs, ctx, { maxRefs: 8 });
|
|
309
315
|
const parts = [];
|
|
316
|
+
const contributing = [];
|
|
310
317
|
for (const ref of r && Array.isArray(r.refs) ? r.refs : []) {
|
|
311
318
|
if (!ref || !ref.tagName) continue;
|
|
312
319
|
if (!isDomVisible(ref)) continue;
|
|
313
320
|
const t = collectVisibleTextUnder(ref);
|
|
314
|
-
if (t)
|
|
321
|
+
if (t) {
|
|
322
|
+
parts.push(t);
|
|
323
|
+
contributing.push(ref);
|
|
324
|
+
}
|
|
315
325
|
}
|
|
316
326
|
const joined = parts.join(' ').replace(/\s+/g, ' ').trim();
|
|
317
|
-
if (joined)
|
|
327
|
+
if (joined)
|
|
328
|
+
return { text: joined, source: 'aria-labelledby', sourceElements: contributing };
|
|
318
329
|
}
|
|
319
330
|
} catch {
|
|
320
331
|
// ignore
|
|
321
332
|
}
|
|
322
333
|
|
|
323
|
-
return { text: '', source };
|
|
334
|
+
return { text: '', source, sourceElements: [] };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Curated real-world icon-font family names. These fonts remap ordinary
|
|
338
|
+
// word glyphs to unrelated symbols via ligatures/PUA codepoints, so the
|
|
339
|
+
// DOM text is real words but nothing readable actually renders, ACT
|
|
340
|
+
// 2ee8b8's own passed example is exactly this (a button's DOM text
|
|
341
|
+
// "search" rendered as a magnifying-glass icon by "Material Icons").
|
|
342
|
+
// Same curated-list tradeoff as link-name-quality's phrase list.
|
|
343
|
+
const ICON_FONT_FAMILIES = new Set([
|
|
344
|
+
'material icons',
|
|
345
|
+
'material icons outlined',
|
|
346
|
+
'material icons round',
|
|
347
|
+
'material icons sharp',
|
|
348
|
+
'material icons two tone',
|
|
349
|
+
'material symbols outlined',
|
|
350
|
+
'material symbols rounded',
|
|
351
|
+
'material symbols sharp',
|
|
352
|
+
'font awesome 5 free',
|
|
353
|
+
'font awesome 5 brands',
|
|
354
|
+
'font awesome 5 pro',
|
|
355
|
+
'font awesome 6 free',
|
|
356
|
+
'font awesome 6 brands',
|
|
357
|
+
'font awesome 6 pro',
|
|
358
|
+
'fontawesome',
|
|
359
|
+
'glyphicons halflings',
|
|
360
|
+
'ionicons',
|
|
361
|
+
'icomoon',
|
|
362
|
+
'bootstrap-icons',
|
|
363
|
+
'bootstrap icons',
|
|
364
|
+
'feather'
|
|
365
|
+
]);
|
|
366
|
+
|
|
367
|
+
// Same two-tier lookup as avoid-inline-spacing.js's computedStyleOf:
|
|
368
|
+
// helpers.computedStyle isn't actually part of the public helpers API
|
|
369
|
+
// (it's internal to dom-helpers.js), so the realm's own getComputedStyle
|
|
370
|
+
// is what actually resolves a stylesheet-declared font-family.
|
|
371
|
+
function computedStyleOf(node) {
|
|
372
|
+
if (helpers && typeof helpers.computedStyle === 'function') {
|
|
373
|
+
try {
|
|
374
|
+
const cs = helpers.computedStyle(node);
|
|
375
|
+
if (cs) return cs;
|
|
376
|
+
} catch {
|
|
377
|
+
// fall through to the realm's own view
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const view = node.ownerDocument && node.ownerDocument.defaultView;
|
|
382
|
+
if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(node);
|
|
383
|
+
} catch {
|
|
384
|
+
// no computed style available
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function isIconFontElement(node) {
|
|
390
|
+
if (!node) return false;
|
|
391
|
+
const cs = computedStyleOf(node);
|
|
392
|
+
if (!cs) return false;
|
|
393
|
+
let family;
|
|
394
|
+
try {
|
|
395
|
+
family =
|
|
396
|
+
cs.fontFamily || (cs.getPropertyValue ? cs.getPropertyValue('font-family') : '') || '';
|
|
397
|
+
} catch {
|
|
398
|
+
family = '';
|
|
399
|
+
}
|
|
400
|
+
if (!family) return false;
|
|
401
|
+
const names = String(family)
|
|
402
|
+
.split(',')
|
|
403
|
+
.map((s) =>
|
|
404
|
+
s
|
|
405
|
+
.trim()
|
|
406
|
+
.replace(/^['"]|['"]$/g, '')
|
|
407
|
+
.toLowerCase()
|
|
408
|
+
);
|
|
409
|
+
return names.some((n) => ICON_FONT_FAMILIES.has(n));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ACT 2ee8b8's own passed example `<button aria-label="close">X</button>`:
|
|
413
|
+
// a single character standing in for an icon ("x" meaning "close") is
|
|
414
|
+
// "non-text content" per the rule's own background text, which names no
|
|
415
|
+
// algorithmic test for it. Scoped narrowly to a whole visible label of
|
|
416
|
+
// exactly one character that doesn't even appear inside the accessible
|
|
417
|
+
// name: "x" has no relation at all to "close", which is the icon-glyph
|
|
418
|
+
// shape. A single character that DOES appear in the name (e.g. visible
|
|
419
|
+
// "1" against aria-label "1a") is a real word-boundary mismatch, not a
|
|
420
|
+
// symbol standing in for something else, and still fails outright.
|
|
421
|
+
function isSingleSymbolicCharacter(text, accessibleNameNorm) {
|
|
422
|
+
const t = norm(text);
|
|
423
|
+
if (!t || Array.from(t).length !== 1) return false;
|
|
424
|
+
return accessibleNameNorm.indexOf(t) === -1;
|
|
324
425
|
}
|
|
325
426
|
|
|
326
427
|
for (const el of nodes) {
|
|
@@ -351,33 +452,54 @@ function runInPage(ctx) {
|
|
|
351
452
|
const nameTokens = tokenize(accName, false);
|
|
352
453
|
const contains = containsWordRun(labelTokens, nameTokens, null);
|
|
353
454
|
|
|
354
|
-
// An abbreviation,
|
|
355
|
-
// not
|
|
356
|
-
//
|
|
357
|
-
|
|
455
|
+
// An abbreviation, a word hyphenated differently, or visible text that
|
|
456
|
+
// may not be literal text at all (an icon-font glyph, a single symbolic
|
|
457
|
+
// character) is not something markup settles: the author may have meant
|
|
458
|
+
// either. Report without asserting a defect instead of failing or
|
|
459
|
+
// staying silent.
|
|
460
|
+
let uncertainReason = '';
|
|
358
461
|
if (!contains) {
|
|
359
462
|
if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
|
|
360
|
-
|
|
463
|
+
uncertainReason = 'HYPHENATION_DIFFERS';
|
|
361
464
|
} else {
|
|
362
465
|
const abbreviated = abbreviatedWords(visibleLabel);
|
|
363
|
-
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
|
|
364
|
-
|
|
466
|
+
if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated)) {
|
|
467
|
+
uncertainReason = 'POSSIBLE_ABBREVIATION';
|
|
468
|
+
} else if ((labelInfo.sourceElements || []).some(isIconFontElement)) {
|
|
469
|
+
uncertainReason = 'POSSIBLE_ICON_FONT_GLYPH';
|
|
470
|
+
} else if (isSingleSymbolicCharacter(visibleLabel, accNorm)) {
|
|
471
|
+
uncertainReason = 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
472
|
+
}
|
|
365
473
|
}
|
|
366
474
|
}
|
|
367
475
|
|
|
476
|
+
const isSymbolicUncertainty =
|
|
477
|
+
uncertainReason === 'POSSIBLE_ICON_FONT_GLYPH' ||
|
|
478
|
+
uncertainReason === 'POSSIBLE_SYMBOLIC_CHARACTER';
|
|
479
|
+
|
|
368
480
|
if (!contains) {
|
|
369
481
|
occurrences.push(
|
|
370
482
|
helpers.reportOccurrence(el, {
|
|
371
|
-
...(
|
|
372
|
-
summary:
|
|
483
|
+
...(uncertainReason ? { outcome: 'cantTell' } : null),
|
|
484
|
+
summary: uncertainReason
|
|
373
485
|
? 'Accessible name may not contain the visible label text.'
|
|
374
486
|
: 'Accessible name does not contain the visible label text.',
|
|
375
|
-
hint:
|
|
376
|
-
? '
|
|
377
|
-
:
|
|
487
|
+
hint: !uncertainReason
|
|
488
|
+
? 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).'
|
|
489
|
+
: isSymbolicUncertainty
|
|
490
|
+
? 'Check by hand: the visible text may render as an icon or symbol rather than literal words, which markup cannot settle.'
|
|
491
|
+
: 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.',
|
|
378
492
|
i18n: {
|
|
379
|
-
summaryKey:
|
|
380
|
-
|
|
493
|
+
summaryKey: !uncertainReason
|
|
494
|
+
? 'labelInName_summary_fail'
|
|
495
|
+
: isSymbolicUncertainty
|
|
496
|
+
? 'labelInName_summary_cantTell_symbolic'
|
|
497
|
+
: 'labelInName_summary_cantTell',
|
|
498
|
+
hintKey: !uncertainReason
|
|
499
|
+
? 'labelInName_hint_fail'
|
|
500
|
+
: isSymbolicUncertainty
|
|
501
|
+
? 'labelInName_hint_cantTell_symbolic'
|
|
502
|
+
: 'labelInName_hint_cantTell',
|
|
381
503
|
params: {
|
|
382
504
|
element: getElementDescriptor(el),
|
|
383
505
|
visibleLabel: clipForSummary(visibleLabel),
|
|
@@ -385,9 +507,23 @@ function runInPage(ctx) {
|
|
|
385
507
|
nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
|
|
386
508
|
}
|
|
387
509
|
},
|
|
510
|
+
...(uncertainReason
|
|
511
|
+
? {
|
|
512
|
+
uncertainty: {
|
|
513
|
+
code: 'equivalence-unknown',
|
|
514
|
+
needed:
|
|
515
|
+
'Whether the accessible name and the visible label say the same thing to a user.',
|
|
516
|
+
evidence: {
|
|
517
|
+
visibleLabel,
|
|
518
|
+
accessibleName: accName,
|
|
519
|
+
difference: uncertainReason
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
: null),
|
|
388
524
|
data: {
|
|
389
525
|
details: {
|
|
390
|
-
reasonCode:
|
|
526
|
+
reasonCode: uncertainReason || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
|
|
391
527
|
visibleLabel,
|
|
392
528
|
accessibleName: accName,
|
|
393
529
|
normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
|