import ts from "typescript"; import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; // The rule: **a button whose whole content is an icon is wrapped in // `IconTooltip`, and never names itself.** An icon button that carries its own // `aria-label` is one a screen reader can use and a pointer user has to guess // at - which is the state the whole app was in, and the state one new button // added next to the others quietly returns it to. // // Structural rather than a hover test per button, because the failure this // guards against is a *missing* control, and there is nothing to query for the // button somebody forgot. It reads the source for the shape instead: an open // tag that declares `aria-label` on children that render no words. // // "Renders no words" is the only judgement here, and it is deliberately narrow. // Children count as visible text if they contain a bare text node, or an // expression that is anything other than a choice between JSX elements - so // `{isBusy ? : }` is still an icon, and `{label}`, // `{value || placeholder}` and `{"Save"}` are not. A button that overrides // visible text with `aria-label` (`entry-unit-toggle`, `data-export-card`, // `dive-form-fields-menu` all do, each for a reason `DECISIONS.md` records) is // left alone; a button with nothing but an icon in it is not. // // One shape is exempt: a button rendered by a `PopoverTrigger` that opens it // from its own `onPointerEnter`. Its hover shows a passage rather than its name, // so it names itself. A popover trigger without one shows nothing until clicked, // so it is not exempt. See "A button that only shows a passage names itself" in // `DECISIONS.md`. const here = path.dirname(fileURLToPath(import.meta.url)); const SRC = path.resolve(here, ".."); const sourceFiles = (dir: string): string[] => readdirSync(dir).flatMap((entry) => { const full = path.join(dir, entry); if (statSync(full).isDirectory()) return sourceFiles(full); return full.endsWith(".tsx") && !full.includes(".test.") ? [full] : []; }); // Index of the first `token` in `src` that sits outside brackets, quotes and // template literals - i.e. one belonging to this expression rather than to a // nested one. const topLevelIndex = (src: string, token: string, from = 0): number => { let depth = 0; let quote: string | null = null; for (let i = from; i < src.length; i++) { const c = src[i]; if (quote) { if (c === quote && src[i - 1] !== "\\") quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if ("([{".includes(c)) depth++; else if (")]}".includes(c)) depth--; else if (depth === 0 && src.startsWith(token, i)) return i; } return -1; }; // Blanks out every JSX element tag while keeping offsets, so what remains is the // text and expressions a button actually renders. A regex cannot do this: an // attribute is allowed both braces and a `>` of its own (`className={cn(...)}`, // `onClick={() => reset()}`), so the end of a tag has to be walked for. Getting // this wrong is not a loud failure - a brace read out of `className={cn(...)}` // looks like a children expression, the button is credited with visible text, // and it is quietly exempted from the rule this file exists to enforce. const blankTags = (src: string) => { const out = src.split(""); for (let i = 0; i < src.length; i++) { // `<` is a tag only before a name, a closing slash or a fragment's `>`; // `{count < 3 && …}` is a comparison and has to stay legible. if (src[i] !== "<" || !/[A-Za-z/>]/.test(src[i + 1] ?? "")) continue; let depth = 0; let quote: string | null = null; let end = i + 1; for (; end < src.length; end++) { const c = src[end]; if (quote) { if (c === quote && src[end - 1] !== "\\") quote = null; continue; } if (c === '"' || c === "'" || c === "`") quote = c; else if (c === "{") depth++; else if (c === "}") depth--; else if (c === ">" && depth === 0) break; } for (let k = i; k <= Math.min(end, src.length - 1); k++) { if (out[k] !== "\n") out[k] = " "; } i = end; } return out.join(""); }; // Blanks out comments while keeping every offset, so the scanners below can // track quotes without an apostrophe in prose ("WCAG's Label in Name") reading // as the start of a string and swallowing the rest of the file. Both forms in // this codebase sit on their own line, which is what makes the line-comment rule // safe: a `//` inside a string literal is never the first thing on its line. const blankComments = (src: string) => src .replace(/\{\/\*[\s\S]*?\*\/\}/g, (m) => m.replace(/[^\n]/g, " ")) .replace(/^([ \t]*)\/\/.*$/gm, (m) => m.replace(/[^\n]/g, " ")); // Whether a branch of a conditional puts anything on screen once its JSX // elements are taken out. `( )` and `(\n)` do not; `("Save")` and `(count)` do. const branchRendersText = (src: string) => /[A-Za-z0-9"'`]/.test(blankTags(src).replace(/[()\s]/g, "")); const expressionRendersText = (expr: string): boolean => { const src = blankTags(expr); const question = topLevelIndex(src, "?"); if (question >= 0) { const colon = topLevelIndex(src, ":", question + 1); if (colon >= 0) { return ( branchRendersText(src.slice(question + 1, colon)) || branchRendersText(src.slice(colon + 1)) ); } } const and = topLevelIndex(src, "&&"); if (and >= 0) return branchRendersText(src.slice(and + 2)); return branchRendersText(src); }; const matchBrace = (src: string, start: number): number => { let depth = 0; let quote: string | null = null; for (let i = start; i < src.length; i++) { const c = src[i]; if (quote) { if (c === quote && src[i - 1] !== "\\") quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if (c === "{") depth++; else if (c === "}" && --depth === 0) return i; } return src.length; }; const childrenRenderText = (inner: string): boolean => { // Tags go first, so the only braces left are ones opening a children // expression rather than an attribute's. const src = blankTags(blankComments(inner)); let outsideExpressions = ""; for (let i = 0; i < src.length; i++) { if (src[i] !== "{") { outsideExpressions += src[i]; continue; } const end = matchBrace(src, i); if (expressionRendersText(src.slice(i + 1, end))) return true; i = end; } // Anything left is a bare text node. return outsideExpressions.trim().length > 0; }; // Parse render composition so a trigger's children and name belong to the Button it renders. const buttonElements = (original: string) => { const file = ts.createSourceFile( "control.tsx", original, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX, ); const found: { line: number; attributes: string; children: string; triggersOwnPassage: boolean; }[] = []; const visit = (node: ts.Node) => { if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) { const tag = node.tagName.getText(file); if (["Button", "ButtonLink", "button"].includes(tag)) { let attributes = node.attributes.getText(file); let children = ts.isJsxElement(node.parent) ? node.parent.children.map((child) => child.getText(file)).join(" ") : ""; let passage = false; const rendered = ts.isJsxElement(node.parent) ? node.parent : node; const expression = rendered.parent; if ( ts.isJsxExpression(expression) && expression.expression === rendered && ts.isJsxAttribute(expression.parent) && expression.parent.name.getText(file) === "render" ) { const parent = expression.parent.parent.parent; if ( (ts.isJsxOpeningElement(parent) || ts.isJsxSelfClosingElement(parent)) && /Trigger$/.test(parent.tagName.getText(file)) ) { attributes += " " + parent.attributes.properties .filter( (attr) => ts.isJsxAttribute(attr) && attr.name.getText(file) !== "render", ) .map((attr) => attr.getText(file)) .join(" "); if (ts.isJsxElement(parent.parent)) children = parent.parent.children .map((child) => child.getText(file)) .join(" "); passage = parent.tagName.getText(file) === "PopoverTrigger" && /\sonPointerEnter[=\s]/.test(" " + attributes); } } found.push({ line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1, attributes: " " + attributes, children, triggersOwnPassage: passage, }); } } ts.forEachChild(node, visit); }; visit(file); return found; }; // The rule above is only as good as this one judgement, and it is judgement made // by a hand-rolled scanner rather than a parser - so the cases it has to get // right are pinned here rather than left to whichever call sites happen to exist. describe("the visible-text rule", () => { const icon = (children: string) => childrenRenderText(children); it("reads a lone icon as wordless", () => { expect(icon('')).toBe(false); }); it("is not fooled by braces in the icon's own attributes", () => { // Pins that tags are taken out before the scanner looks for `{` - otherwise // it hands `cn("h-4 w-4", danger && "text-destructive")` to the expression // rule and credits the button with words it does not render. expect( icon( '', ), ).toBe(false); expect(icon("")).toBe(false); }); it("reads a choice between icons as wordless", () => { expect( icon(`{isBusy ? ( ) : ( )}`), ).toBe(false); }); it("reads a rendered value as words", () => { expect(icon('{label}')).toBe(true); expect(icon("{value || placeholder}")).toBe(true); expect(icon('\n Add schedule')).toBe(true); }); it("reads a choice between words as words", () => { expect(icon('{item.is_archived ? "Unarchive" : "Archive"}')).toBe(true); expect(icon("{count > 0 && {count}}")).toBe(true); }); it("ignores comments, apostrophes and all", () => { expect( icon(`{/* Named per row: ten "Edit"s tell a screen reader's controls list nothing about which row. */} `), ).toBe(false); }); }); const exempt = (src: string) => buttonElements(src).map(({ triggersOwnPassage }) => triggersOwnPassage); describe("a popover trigger that opens on hover", () => { it("exempts the button it directly renders", () => { expect( exempt(` }>`), ).toEqual([true]); }); it("exempts nothing behind a wrapper, and no button without the trigger", () => { expect( exempt(` } /> `), ).toEqual([false, false]); }); it("is required: a popover trigger without one shows nothing until clicked", () => { expect( exempt(` }>`), ).toEqual([false]); }); it("does not extend to a tooltip trigger", () => { expect( exempt(` }>`), ).toEqual([false]); }); }); describe("trigger render composition", () => { it.each(["PopoverTrigger", "DialogTrigger", "DropdownMenuTrigger"])( "reads visible children of %s", (trigger) => { const [button] = buttonElements( `<${trigger} render={}>Save', ); expect(button.attributes).not.toContain('aria-label="Save"'); expect(childrenRenderText(button.children)).toBe(false); }); }); describe("icon buttons", () => { it("carry no accessible name that the hover hint does not also show", () => { const offenders = sourceFiles(SRC).flatMap((file) => buttonElements(readFileSync(file, "utf8")) .filter( ({ attributes, children, triggersOwnPassage }) => /\saria-label[=\s]/.test(attributes) && !childrenRenderText(children) && !triggersOwnPassage, ) .map(({ line }) => `${path.relative(SRC, file)}:${line}`), ); // Wrap it in `IconTooltip` and pass the name as `label` instead. expect(offenders.join("\n")).toEqual(""); }); });