Compare commits
4 Commits
main
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f228a2d8e5 | ||
|
|
1d9cba7e3c | ||
|
|
b68f7a7ff7 | ||
|
|
919f69b37f |
80
packages/ui-web/src/lib/utils.test.ts
Normal file
80
packages/ui-web/src/lib/utils.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buttonVariants } from "../components/ui/button.js";
|
||||||
|
import { cn } from "./utils.js";
|
||||||
|
|
||||||
|
describe("cn", () => {
|
||||||
|
it("keeps a text color next to a ui text size", () => {
|
||||||
|
expect(cn("text-primary-foreground", "text-ui-label")).toBe(
|
||||||
|
"text-primary-foreground text-ui-label",
|
||||||
|
);
|
||||||
|
expect(cn("text-ui-body-sm", "text-muted-foreground")).toBe(
|
||||||
|
"text-ui-body-sm text-muted-foreground",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges two text sizes to the last one", () => {
|
||||||
|
expect(cn("text-ui-body-sm", "text-ui-label")).toBe("text-ui-label");
|
||||||
|
expect(cn("text-sm", "text-ui-caption")).toBe("text-ui-caption");
|
||||||
|
expect(cn("text-ui-caption", "text-lg")).toBe("text-lg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges two text colors to the last one", () => {
|
||||||
|
expect(cn("text-ui-label text-primary-foreground", "text-destructive")).toBe(
|
||||||
|
"text-ui-label text-destructive",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges ui control sizes with other sizes", () => {
|
||||||
|
expect(cn("h-control-sm", "h-8")).toBe("h-8");
|
||||||
|
expect(cn("h-auto", "h-control-md")).toBe("h-control-md");
|
||||||
|
expect(cn("[&_svg]:size-icon-md", "[&_svg]:size-icon-lg")).toBe("[&_svg]:size-icon-lg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps small buttons readable", () => {
|
||||||
|
for (const size of ["xs", "sm", "default", "lg"] as const) {
|
||||||
|
const classes = cn(buttonVariants({ variant: "default", size })).split(" ");
|
||||||
|
expect(classes, size).toContain("text-primary-foreground");
|
||||||
|
expect(classes.filter((name) => name.startsWith("text-ui-")).length, size).toBe(1);
|
||||||
|
}
|
||||||
|
expect(cn(buttonVariants({ variant: "destructive", size: "sm" })).split(" ")).toEqual(
|
||||||
|
expect.arrayContaining(["text-destructive", "text-ui-label"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One merge check per `@theme` namespace: [earlier classes, later classes, expected]. */
|
||||||
|
const NAMESPACE_PROBES: Record<string, (name: string) => [string, string, string]> = {
|
||||||
|
color: (name) => ["text-ui-body text-foreground", `text-${name}`, `text-ui-body text-${name}`],
|
||||||
|
radius: (name) => ["rounded-sm", `rounded-${name}`, `rounded-${name}`],
|
||||||
|
spacing: (name) => [
|
||||||
|
"h-4 p-2 gap-2",
|
||||||
|
`h-${name} p-${name} gap-${name}`,
|
||||||
|
`h-${name} p-${name} gap-${name}`,
|
||||||
|
],
|
||||||
|
text: (name) => ["text-foreground text-sm", `text-${name}`, `text-foreground text-${name}`],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cn knows every @theme key in styles.css", () => {
|
||||||
|
const css = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||||
|
const theme = css.match(/@theme inline \{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||||
|
const keys = [...theme.matchAll(/^\s*--([a-z0-9-]+):/gm)]
|
||||||
|
.map((match) => match[1] ?? "")
|
||||||
|
.filter((key) => !key.includes("--"));
|
||||||
|
|
||||||
|
it("finds the theme keys", () => {
|
||||||
|
expect(keys).toContain("text-ui-label");
|
||||||
|
expect(keys).toContain("spacing-control-sm");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
it(`--${key}`, () => {
|
||||||
|
const namespace = Object.keys(NAMESPACE_PROBES).find((ns) => key.startsWith(`${ns}-`));
|
||||||
|
expect(namespace, `add a probe for the @theme namespace of --${key}`).toBeDefined();
|
||||||
|
const [earlier, later, expected] = NAMESPACE_PROBES[namespace ?? ""]!(
|
||||||
|
key.slice(`${namespace}-`.length),
|
||||||
|
);
|
||||||
|
expect(cn(earlier, later)).toBe(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -1,5 +1,40 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import type { ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { clsx } from "clsx";
|
||||||
|
import { extendTailwindMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scales `styles.css` adds under `@theme`. tailwind-merge only knows Tailwind's
|
||||||
|
* defaults, so without them it reads `text-ui-label` as a text color (dropping the
|
||||||
|
* real color) and never merges `h-control-sm` with `h-8`. `utils.test.ts` fails
|
||||||
|
* when `@theme` gains a key that is missing here.
|
||||||
|
*/
|
||||||
|
const twMerge = extendTailwindMerge({
|
||||||
|
extend: {
|
||||||
|
theme: {
|
||||||
|
text: [
|
||||||
|
"ui-caption",
|
||||||
|
"ui-label",
|
||||||
|
"ui-body-sm",
|
||||||
|
"ui-body",
|
||||||
|
"ui-reading",
|
||||||
|
"ui-title-sm",
|
||||||
|
"ui-title",
|
||||||
|
],
|
||||||
|
spacing: [
|
||||||
|
"control-xs",
|
||||||
|
"control-sm",
|
||||||
|
"control-md",
|
||||||
|
"control-lg",
|
||||||
|
"icon-sm",
|
||||||
|
"icon-md",
|
||||||
|
"icon-lg",
|
||||||
|
"rk-shell",
|
||||||
|
"rk-sidebar-x",
|
||||||
|
"rk-sidebar-y",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user