Skip to content

Commit 09b51fc

Browse files
koddssonclaude
andauthored
Implement the ACT label in name algorithm for 2ee8b8 (#463)
Upstream rewrote ACT rule 2ee8b8 "Visible label is part of accessible name", growing it from 11 to 32 test cases and replacing the informal check with a formal label in name algorithm. CI re-syncs testcases.json from the ACT feed on every run, so every open PR started failing the same nine cases. Replace the case-insensitive substring test with the specified algorithm: tokenize both sides (strip parenthesised asides, case fold, NFKD, reduce non-letter/non-digit characters to whitespace, split into words) and check that the label is a contiguous subsequence of the name. Compute visible inner text per the ACT glossary, which fixes three bugs in the old text walk: whitespace was trimmed at every recursion level, aria-hidden descendants were excluded even though they are still rendered, and CSS-hidden content was not excluded at all. The ACT tests parse fixtures with DOMParser, so the document has no browsing context and neither getComputedStyle nor stylesheets are available. Resolve the few properties the rule needs from computed styles when there is a view, and otherwise from <style> text plus inline attributes. 2ee8b8 goes from 10/11 to 31/32; the remaining case needs icon font rendering and stays skipped. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 59fbc21 commit 09b51fc

59 files changed

Lines changed: 1909 additions & 123 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The W3C [ACT implementations report](https://www.w3.org/WAI/standards-guidelines
1111
- **20** ACT rules pass every generated test case (W3C "consistent implementation" criterion).
1212
- **14** more rules pass at least one test case but not all.
1313
- **53 / 88** ACT rules have some scanner implementation, even if untested.
14-
- **296 / 843** ACT test cases (35%) are exercised against the scanner.
14+
- **317 / 865** ACT test cases (37%) are exercised against the scanner.
1515

1616
| Implemented | ACT Rule | WCAG | axe-core rule(s) | Test cases |
1717
| :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------- | :------------------ | :------------------------------------------------------------------------ | :--------- |
@@ -43,7 +43,7 @@ The W3C [ACT implementations report](https://www.w3.org/WAI/standards-guidelines
4343
| ✅ | [6cfa84](https://act-rules.github.io/rules/6cfa84) — Element with aria-hidden has no content in sequential focus navigation | 4.1.2 | `aria-hidden-focus` | 11 / 12 |
4444
| ✅ | [de46e4](https://act-rules.github.io/rules/de46e4) — Element with lang attribute has valid language tag | 3.1.2 | `valid-lang` | 14 / 14 |
4545
| ✅ | [307n5z](https://act-rules.github.io/rules/307n5z) — Element with presentational children has no focusable content | 4.1.2 | `nested-interactive` | 0 / 9 |
46-
| ✅ | [4e8ab6](https://act-rules.github.io/rules/4e8ab6) — Element with role attribute has required states and properties | 1.3.1, 4.1.2 | `aria-required-attr` | 0 / 11 |
46+
| ✅ | [4e8ab6](https://act-rules.github.io/rules/4e8ab6) — Element with role attribute has required states and properties | 1.3.1, 4.1.2 | `aria-required-attr` | 0 / 12 |
4747
| ✅ | [36b590](https://act-rules.github.io/rules/36b590) — Error message describes invalid form field value | 3.3.1 | `error-message` | 8 / 8 |
4848
| ❌ | [80af7b](https://act-rules.github.io/rules/80af7b) — Focusable element has no keyboard trap | 2.1.2 | — | 0 / 12 |
4949
| ❌ | [ebe86a](https://act-rules.github.io/rules/ebe86a) — Focusable element has no keyboard trap via non-standard navigation | — | — | 0 / 6 |
@@ -101,7 +101,7 @@ The W3C [ACT implementations report](https://www.w3.org/WAI/standards-guidelines
101101
| ❌ | [d7ba54](https://act-rules.github.io/rules/d7ba54) — Video element visual-only content has audio track alternative | — | — | 0 / 3 |
102102
| ❌ | [ee13b5](https://act-rules.github.io/rules/ee13b5) — Video element visual-only content has transcript | 1.2.1, 1.3.1 | — | 0 / 6 |
103103
| ❌ | [fd26cf](https://act-rules.github.io/rules/fd26cf) — Video element visual-only content is media alternative for text | — | — | 0 / 5 |
104-
| ✅ | [2ee8b8](https://act-rules.github.io/rules/2ee8b8) — Visible label is part of accessible name | 2.5.3 | `label-content-name-mismatch` | 10 / 11 |
104+
| ✅ | [2ee8b8](https://act-rules.github.io/rules/2ee8b8) — Visible label is part of accessible name | 2.5.3 | `label-content-name-mismatch` | 31 / 32 |
105105
| ❌ | [59br37](https://act-rules.github.io/rules/59br37) — Zoomed text node is not clipped with CSS overflow | 1.4.4 | — | 0 / 9 |
106106

107107

‎src/rules/label-content-name-mismatch.ts‎

Lines changed: 281 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,240 @@ const widgetRolesWithNameFromContent = new Set([
2323
"option",
2424
"radio",
2525
"rowheader",
26+
"searchbox",
2627
"switch",
2728
"tab",
2829
"treeitem",
2930
]);
3031

32+
type StyleMap = Record<string, string>;
33+
34+
const styledProperties = [
35+
"display",
36+
"visibility",
37+
"opacity",
38+
"clip",
39+
"clip-path",
40+
"width",
41+
"height",
42+
] as const;
43+
44+
function parseDeclarations(block: string): StyleMap {
45+
const declarations: StyleMap = {};
46+
for (const declaration of block.split(";")) {
47+
const separator = declaration.indexOf(":");
48+
if (separator === -1) continue;
49+
const property = declaration.slice(0, separator).trim().toLowerCase();
50+
if (
51+
!styledProperties.includes(property as (typeof styledProperties)[number])
52+
)
53+
continue;
54+
declarations[property] = declaration
55+
.slice(separator + 1)
56+
.replaceAll(/\s*!important\s*$/gi, "")
57+
.trim()
58+
.toLowerCase();
59+
}
60+
return declarations;
61+
}
62+
63+
const documentRules = new WeakMap<
64+
Document,
65+
{ selector: string; declarations: StyleMap }[]
66+
>();
67+
68+
/**
69+
* Documents produced by `DOMParser` have no browsing context, so their
70+
* `<style>` elements are never turned into `CSSStyleSheet`s and
71+
* `getComputedStyle` is unavailable. Parse the stylesheets by hand so the
72+
* rule can still resolve the handful of properties it cares about.
73+
*/
74+
function getDocumentRules(document: Document) {
75+
const cached = documentRules.get(document);
76+
if (cached) return cached;
77+
78+
const rules: { selector: string; declarations: StyleMap }[] = [];
79+
for (const styleElement of document.querySelectorAll("style")) {
80+
const css = (styleElement.textContent ?? "").replaceAll(
81+
/\/\*[\S\s]*?\*\//g,
82+
"",
83+
);
84+
for (const [, selector, block] of css.matchAll(/([^{}]+){([^{}]*)}/g)) {
85+
const trimmed = selector.trim();
86+
if (!trimmed || trimmed.startsWith("@")) continue;
87+
rules.push({ selector: trimmed, declarations: parseDeclarations(block) });
88+
}
89+
}
90+
91+
documentRules.set(document, rules);
92+
return rules;
93+
}
94+
95+
function matchesSelector(element: Element, selector: string): boolean {
96+
try {
97+
return element.matches(selector);
98+
} catch {
99+
return false;
100+
}
101+
}
102+
103+
function getStyle(element: Element): StyleMap {
104+
const view = element.ownerDocument?.defaultView;
105+
if (element.isConnected && view) {
106+
const computed = view.getComputedStyle(element);
107+
const style: StyleMap = {};
108+
for (const property of styledProperties) {
109+
style[property] = computed.getPropertyValue(property);
110+
}
111+
return style;
112+
}
113+
114+
const style: StyleMap = {};
115+
for (const { selector, declarations } of getDocumentRules(
116+
element.ownerDocument,
117+
)) {
118+
if (matchesSelector(element, selector)) Object.assign(style, declarations);
119+
}
120+
Object.assign(style, parseDeclarations(element.getAttribute("style") ?? ""));
121+
return style;
122+
}
123+
124+
const neverRenderedTags = new Set([
125+
"head",
126+
"link",
127+
"meta",
128+
"noscript",
129+
"script",
130+
"style",
131+
"template",
132+
"title",
133+
]);
134+
135+
const blockLevelTags = new Set([
136+
"address",
137+
"article",
138+
"aside",
139+
"blockquote",
140+
"body",
141+
"details",
142+
"dd",
143+
"dialog",
144+
"div",
145+
"dl",
146+
"dt",
147+
"fieldset",
148+
"figcaption",
149+
"figure",
150+
"footer",
151+
"form",
152+
"h1",
153+
"h2",
154+
"h3",
155+
"h4",
156+
"h5",
157+
"h6",
158+
"header",
159+
"hgroup",
160+
"hr",
161+
"main",
162+
"nav",
163+
"ol",
164+
"p",
165+
"pre",
166+
"section",
167+
"ul",
168+
]);
169+
170+
function getDisplay(element: Element): string {
171+
const declared = getStyle(element).display;
172+
if (declared) return declared;
173+
174+
const tag = element.tagName.toLowerCase();
175+
if (tag === "td" || tag === "th") return "table-cell";
176+
if (tag === "tr") return "table-row";
177+
if (tag === "caption") return "table-caption";
178+
if (tag === "table") return "table";
179+
if (tag === "li") return "list-item";
180+
if (blockLevelTags.has(tag)) return "block";
181+
return "inline";
182+
}
183+
184+
function hasBlockOuterDisplay(display: string): boolean {
185+
return (
186+
display.startsWith("block") ||
187+
display.startsWith("flow-root") ||
188+
display === "list-item" ||
189+
display === "table" ||
190+
display === "flex" ||
191+
display === "grid"
192+
);
193+
}
194+
195+
function isTinyLength(value: string | undefined): boolean {
196+
if (!value) return false;
197+
const length = Number.parseFloat(value);
198+
return Number.isFinite(length) && length <= 1;
199+
}
200+
201+
/**
202+
* Detects the "visually hidden" pattern — a clipped box collapsed to a pixel —
203+
* which renders nothing on screen even though it stays in the accessibility
204+
* tree.
205+
*/
206+
function isClippedOutOfView(style: StyleMap): boolean {
207+
const clipPath = style["clip-path"];
208+
const clip = style.clip;
209+
const clipped =
210+
(clipPath && clipPath !== "none") || (clip && clip !== "auto");
211+
if (!clipped) return false;
212+
return isTinyLength(style.width) || isTinyLength(style.height);
213+
}
214+
215+
/**
216+
* The "visible inner text" of a node, per the ACT glossary. It differs from
217+
* both `textContent` (which includes content hidden with CSS) and `innerText`
218+
* (which collapses whitespace-only inline elements).
219+
*
220+
* Note that `aria-hidden` content is deliberately *included*: it is removed
221+
* from the accessibility tree but still rendered on screen, so it forms part
222+
* of the visible label.
223+
*/
224+
function getVisibleInnerText(node: Node): string {
225+
if (node.nodeType === Node.TEXT_NODE) {
226+
return (node.textContent ?? "").replaceAll(/\s+/g, " ");
227+
}
228+
229+
if (node.nodeType !== Node.ELEMENT_NODE) {
230+
return getChildrenVisibleInnerText(node);
231+
}
232+
233+
const element = node as Element;
234+
const tag = element.tagName.toLowerCase();
235+
if (neverRenderedTags.has(tag)) return "";
236+
if (tag === "br") return "\n";
237+
238+
const style = getStyle(element);
239+
const display = getDisplay(element);
240+
if (display === "none") return "";
241+
if (style.visibility && style.visibility !== "visible") return " ";
242+
if (style.opacity && Number.parseFloat(style.opacity) === 0) return " ";
243+
if (isClippedOutOfView(style)) return " ";
244+
245+
const inner = getChildrenVisibleInnerText(element);
246+
if (display === "table-cell" || display === "table-row") return ` ${inner} `;
247+
if (display === "table-caption" || hasBlockOuterDisplay(display))
248+
return `\n${inner}\n`;
249+
return inner;
250+
}
251+
252+
function getChildrenVisibleInnerText(node: Node): string {
253+
let result = "";
254+
for (const child of node.childNodes) {
255+
result += getVisibleInnerText(child);
256+
}
257+
return result;
258+
}
259+
31260
/**
32261
* Map from HTML element tag names to their implicit ARIA roles
33262
* (only for widget roles that name from content).
@@ -57,39 +286,6 @@ function getEffectiveRole(element: Element): string | null {
57286
return getImplicitRole(element);
58287
}
59288

60-
/**
61-
* Get the visible text content of an element, considering only direct
62-
* text node descendants (not alt text of images, etc.).
63-
*/
64-
function getVisibleText(element: Element): string {
65-
let text = "";
66-
for (const node of element.childNodes) {
67-
if (node.nodeType === Node.TEXT_NODE) {
68-
text += node.textContent || "";
69-
} else if (node.nodeType === Node.ELEMENT_NODE) {
70-
const child = node as Element;
71-
// Skip hidden elements
72-
if (child.getAttribute("aria-hidden") === "true") continue;
73-
// Skip img elements (their alt text is not "visible text")
74-
if (child.tagName.toLowerCase() === "img") continue;
75-
// Recurse into child elements for their text nodes
76-
text += getVisibleText(child);
77-
}
78-
}
79-
// Normalize whitespace
80-
return text.trim().replaceAll(/\s+/g, " ");
81-
}
82-
83-
/**
84-
* Check if the visible text is a single character, which the ACT rule
85-
* considers potentially symbolic/iconic and therefore excluded.
86-
*/
87-
function isSingleCharacter(text: string): boolean {
88-
// After normalization, check if it's a single unicode character
89-
const chars = [...text];
90-
return chars.length === 1;
91-
}
92-
93289
/**
94290
* Get the accessible name of an element (from aria-label or aria-labelledby)
95291
*/
@@ -124,18 +320,57 @@ function getAccessibleName(element: Element): string | null {
124320
}
125321

126322
/**
127-
* Check if the visible text is a substring of the accessible name (case-insensitive)
323+
* Tokenize a string as described by the ACT "label in name algorithm":
324+
* drop parenthesised asides, case fold and normalise, reduce everything that
325+
* is neither a letter nor a digit to whitespace, then split into words.
128326
*/
129-
function isTextPartOfAccessibleName(
130-
visibleText: string,
131-
accessibleName: string,
327+
function tokenize(value: string): string[] {
328+
const withoutParentheticals = value.replaceAll(/\([^()]*\)/g, " ");
329+
const folded = withoutParentheticals.toLowerCase().normalize("NFKD");
330+
331+
let letters = "";
332+
for (const character of folded) {
333+
letters += /[\p{L}\p{N}]/u.test(character) ? character : " ";
334+
}
335+
336+
return letters.split(/\s+/).filter(Boolean);
337+
}
338+
339+
/**
340+
* Whether `needle` appears as a run of consecutive entries in `haystack`.
341+
*/
342+
function isContiguousSubsequence(
343+
needle: string[],
344+
haystack: string[],
132345
): boolean {
133-
if (!visibleText || !accessibleName) return true;
346+
if (needle.length === 0) return true;
347+
if (needle.length > haystack.length) return false;
134348

135-
const normalizedVisible = visibleText.toLowerCase();
136-
const normalizedAccessible = accessibleName.toLowerCase();
349+
for (let start = 0; start <= haystack.length - needle.length; start++) {
350+
let matches = true;
351+
for (const [offset, element] of needle.entries()) {
352+
if (haystack[start + offset] !== element) {
353+
matches = false;
354+
break;
355+
}
356+
}
357+
if (matches) return true;
358+
}
137359

138-
return normalizedAccessible.includes(normalizedVisible);
360+
return false;
361+
}
362+
363+
/**
364+
* A label made of a single letter — "X" for "close", say — is a character
365+
* expressing non-text content, which the algorithm ignores. A single digit is
366+
* ordinary text and is not excluded.
367+
*/
368+
function expressesNonTextContent(tokens: string[]): boolean {
369+
return (
370+
tokens.length === 1 &&
371+
[...tokens[0]].length === 1 &&
372+
/\p{L}/u.test(tokens[0])
373+
);
139374
}
140375

141376
export default function (element: Element): AccessibilityError[] {
@@ -165,19 +400,14 @@ export default function (element: Element): AccessibilityError[] {
165400
const role = getEffectiveRole(el);
166401
if (!role || !widgetRolesWithNameFromContent.has(role)) continue;
167402

168-
const visibleText = getVisibleText(el);
169403
const accessibleName = getAccessibleName(el);
404+
if (!accessibleName) continue;
170405

171-
// Only check if:
172-
// 1. Element has visible text content
173-
// 2. Element has an accessible name from aria-label or aria-labelledby
174-
if (!visibleText || !accessibleName) continue;
175-
176-
// Skip single-character visible text (treated as potentially symbolic)
177-
if (isSingleCharacter(visibleText)) continue;
406+
const label = tokenize(getVisibleInnerText(el));
407+
if (label.length === 0) continue;
408+
if (expressesNonTextContent(label)) continue;
178409

179-
// Check if visible text is part of accessible name
180-
if (!isTextPartOfAccessibleName(visibleText, accessibleName)) {
410+
if (!isContiguousSubsequence(label, tokenize(accessibleName))) {
181411
errors.push({
182412
id,
183413
element: el,

0 commit comments

Comments
 (0)