Skip to content

Commit

Test: no native title hints in apps/web

app/lib/no-native-title.test.ts fails on a title attribute on an HTML element, or on a component that passes it to one (Link, NavLink, Button, SubmitButton, Badge, Switch and the like), with an allowlist for a deliberate one.

syntaqxcommitted Parent6d9d3ddBrowse files
1 file+88−00/1 viewed
+88−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { join, relative } from "node:path";
4+import { test } from "node:test";
5+import { fileURLToPath } from "node:url";
6+
7+// Hover hints are the site's tooltip (components/ui/hint.tsx, or Tooltip
8+// from components/ui/tooltip.tsx), never the browser's `title`: it looks
9+// foreign, waits a second and a half, and never shows on a touch screen or
10+// to the keyboard. This fails on a `title` on an HTML element, or on a
11+// component that hands its props to one.
12+
13+/** Components that pass `title` on to an HTML element. */
14+const FORWARDS = ["Link", "NavLink", "Button", "ButtonLink", "SubmitButton", "Badge", "Switch", "Input", "Textarea", "TabLink"];
15+
16+/**
17+ * Where a native `title` is right. `file:line`, with the reason. An SVG's
18+ * `<title>` child is an element, not an attribute, and needs no entry.
19+ */
20+const ALLOWED: Record<string, string> = {};
21+
22+const APP = fileURLToPath(new URL("..", import.meta.url));
23+
24+function* sources(dir: string): Generator<string> {
25+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
26+ const path = join(dir, entry.name);
27+ if (entry.isDirectory()) yield* sources(path);
28+ else if (entry.name.endsWith(".tsx")) yield path;
29+ }
30+}
31+
32+/** Each opening tag of an HTML element or a forwarding component with a `title` attribute. */
33+function nativeTitles(source: string): { tag: string; line: number }[] {
34+ const found: { tag: string; line: number }[] = [];
35+ const opening = new RegExp(`<([a-z][a-z0-9]*|${FORWARDS.join("|")})(?=[\\s/>])`, "g");
36+ for (let match = opening.exec(source); match; match = opening.exec(source)) {
37+ // The tag's attributes, up to its closing `>`, with every {...} left out.
38+ let attributes = "";
39+ let depth = 0;
40+ let quote: string | null = null;
41+ for (let i = match.index + match[0].length; i < source.length; i++) {
42+ const c = source[i];
43+ if (quote) {
44+ if (c === quote) quote = null;
45+ if (depth === 0) attributes += c;
46+ continue;
47+ }
48+ if (c === '"' || c === "'" || c === "`") quote = c;
49+ else if (c === "{") depth++;
50+ else if (c === "}") depth--;
51+ else if (c === ">" && depth === 0) break;
52+ if (depth === 0) attributes += c;
53+ }
54+ if (/(^|\s)title=/.test(attributes)) {
55+ found.push({ tag: match[1], line: source.slice(0, match.index).split("\n").length });
56+ }
57+ }
58+ return found;
59+}
60+
61+test("finds a title on an element, not on a component's own prop or inside an expression", () => {
62+ const hits = nativeTitles(
63+ [
64+ `<span title="x">a</span>`,
65+ `<Section title="Heading">`,
66+ `<div\n className="a"\n title={label}\n>`,
67+ `<Link to="/" title={x}>`,
68+ `<button onClick={() => open({ title: "x" })}>`,
69+ `<p className={cn("a", b && "title=")}>`,
70+ ].join("\n"),
71+ );
72+ assert.deepEqual(hits, [
73+ { tag: "span", line: 1 },
74+ { tag: "div", line: 3 },
75+ { tag: "Link", line: 7 },
76+ ]);
77+});
78+
79+test("no native title hints in apps/web", () => {
80+ const offenders: string[] = [];
81+ for (const file of sources(APP)) {
82+ const name = relative(APP, file).replaceAll("\\", "/");
83+ for (const { tag, line } of nativeTitles(readFileSync(file, "utf8"))) {
84+ if (!ALLOWED[`${name}:${line}`]) offenders.push(`app/${name}:${line} <${tag} title=…>`);
85+ }
86+ }
87+ assert.deepEqual(offenders, [], "Use <Hint label=…> from components/ui/hint.tsx in place of a native title.");
88+});