refactor(ui): extract reusable component package
This commit is contained in:
parent
87261b5a3f
commit
2664804e91
86 changed files with 102 additions and 85 deletions
|
|
@ -2,7 +2,6 @@ import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
stories: ["../src/**/*.stories.@(js|ts|tsx)"],
|
||||||
staticDirs: ["../static"],
|
|
||||||
addons: [
|
addons: [
|
||||||
"@storybook/addon-a11y",
|
"@storybook/addon-a11y",
|
||||||
"@storybook/addon-vitest",
|
"@storybook/addon-vitest",
|
||||||
|
|
@ -11,9 +10,6 @@ const config: StorybookConfig = {
|
||||||
name: "@storybook/react-vite",
|
name: "@storybook/react-vite",
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
docs: {
|
|
||||||
autodocs: "tag",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
@ -1,17 +1,5 @@
|
||||||
import "../src/app.css";
|
import "../src/styles.css";
|
||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { setupWorker } from "msw/browser";
|
|
||||||
import { externalApiHandlers } from "../src/lib/testing/external-api-mocks";
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const worker = setupWorker(...externalApiHandlers);
|
|
||||||
void worker.start({
|
|
||||||
onUnhandledRequest: "bypass",
|
|
||||||
serviceWorker: {
|
|
||||||
url: "/mockServiceWorker.js",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
decorators: [
|
decorators: [
|
||||||
|
|
@ -44,10 +32,10 @@ const preview: Preview = {
|
||||||
backgrounds: {
|
backgrounds: {
|
||||||
default: "canvas",
|
default: "canvas",
|
||||||
values: [
|
values: [
|
||||||
{ name: "canvas", value: "#020302" },
|
{ name: "canvas", value: "#0b0f0d" },
|
||||||
{ name: "raised", value: "#0b0d0c" },
|
{ name: "raised", value: "#151d18" },
|
||||||
{ name: "light canvas", value: "#f3f5ed" },
|
{ name: "light canvas", value: "#eef2e7" },
|
||||||
{ name: "light raised", value: "#eef1e7" },
|
{ name: "light raised", value: "#f1f5ea" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
controls: {
|
controls: {
|
||||||
74
packages/ui/src/content-boundary.test.ts
Normal file
74
packages/ui/src/content-boundary.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const uiSourceRoot = existsSync(join(root, "packages/ui/src"))
|
||||||
|
? join(root, "packages/ui/src")
|
||||||
|
: join(root, "src");
|
||||||
|
|
||||||
|
const forbiddenTerms = [
|
||||||
|
"dimensionlab",
|
||||||
|
"dimension lab",
|
||||||
|
"vaultwarden",
|
||||||
|
"forgejo",
|
||||||
|
"grafana",
|
||||||
|
"uptime kuma",
|
||||||
|
"prometheus",
|
||||||
|
"backrest",
|
||||||
|
"open webui",
|
||||||
|
"comfyui",
|
||||||
|
"adminer",
|
||||||
|
"cockpit",
|
||||||
|
"ollama",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("UI package content boundary", () => {
|
||||||
|
test("contains the reusable dashboard component inventory", () => {
|
||||||
|
expect(existsSync(join(uiSourceRoot, "index.ts"))).toBe(true);
|
||||||
|
expect(existsSync(join(uiSourceRoot, "components/DashboardFrame.tsx"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(uiSourceRoot, "components/ThemeToggle.tsx"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(existsSync(join(uiSourceRoot, "styles.css"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps environment-specific content out of reusable UI source", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot).toLowerCase();
|
||||||
|
|
||||||
|
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not import website runtime modules", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot);
|
||||||
|
|
||||||
|
expect(source).not.toMatch(
|
||||||
|
/from ["'](?:apps\/web|\$lib\/server|\$lib\/model)/,
|
||||||
|
);
|
||||||
|
expect(source).not.toContain("../web/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps icon rendering driven by icon identifiers", () => {
|
||||||
|
const source = readUiSource(uiSourceRoot);
|
||||||
|
|
||||||
|
expect(source).not.toContain("@iconify-json/");
|
||||||
|
expect(source).not.toContain("/icons/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function readUiSource(path: string): string {
|
||||||
|
if (!existsSync(path)) return "";
|
||||||
|
|
||||||
|
const stats = statSync(path);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
if (path.endsWith(".test.ts") || path.endsWith(".test.tsx")) return "";
|
||||||
|
if (!/\.(tsx|ts|css)$/.test(path)) return "";
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return readdirSync(path)
|
||||||
|
.map((entry) => readUiSource(join(path, entry)))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,6 @@ export { TelemetryStrip } from "./components/TelemetryStrip";
|
||||||
export { ThemeToggle } from "./components/ThemeToggle";
|
export { ThemeToggle } from "./components/ThemeToggle";
|
||||||
export { WeatherModule } from "./components/WeatherModule";
|
export { WeatherModule } from "./components/WeatherModule";
|
||||||
export { dashboardPreviewFixtures } from "./fixtures";
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
|
||||||
export {
|
export {
|
||||||
getNextUiTheme,
|
getNextUiTheme,
|
||||||
isUiTheme,
|
isUiTheme,
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
|
@ -2,7 +2,7 @@ import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
|
@ -2,7 +2,7 @@ import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
function Card({
|
function Card({
|
||||||
className,
|
className,
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
function Progress({
|
function Progress({
|
||||||
className,
|
className,
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
function Separator({
|
function Separator({
|
||||||
className,
|
className,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { cn } from "$lib/utils"
|
import { cn } from "../utils"
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -3,8 +3,11 @@ import { join } from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const componentsDir = join(root, "src/lib/ui/components");
|
const packageRoot = existsSync(join(root, "packages/ui/package.json"))
|
||||||
const storiesDir = join(root, "src/lib/ui/stories");
|
? join(root, "packages/ui")
|
||||||
|
: root;
|
||||||
|
const componentsDir = join(packageRoot, "src/components");
|
||||||
|
const storiesDir = join(packageRoot, "src/stories");
|
||||||
|
|
||||||
const requiredStoryFiles = [
|
const requiredStoryFiles = [
|
||||||
"Badge.stories.tsx",
|
"Badge.stories.tsx",
|
||||||
|
|
@ -49,7 +52,7 @@ const forbiddenStoryContent = [
|
||||||
|
|
||||||
describe("Storybook inventory", () => {
|
describe("Storybook inventory", () => {
|
||||||
test("exposes scripts for local and static Storybook review", () => {
|
test("exposes scripts for local and static Storybook review", () => {
|
||||||
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as {
|
||||||
scripts?: Record<string, string>;
|
scripts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,21 +68,22 @@ describe("Storybook inventory", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loads dashboard component styles through the global app stylesheet", () => {
|
test("loads dashboard component styles through the global app stylesheet", () => {
|
||||||
const appStyles = readFileSync(join(root, "src/app.css"), "utf8");
|
const packageStyles = readFileSync(join(packageRoot, "src/styles.css"), "utf8");
|
||||||
const dashboardFrame = readFileSync(
|
const dashboardFrame = readFileSync(
|
||||||
join(componentsDir, "DashboardFrame.tsx"),
|
join(componentsDir, "DashboardFrame.tsx"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(appStyles).toContain('./lib/ui/components/styles.css');
|
expect(packageStyles).toContain('./components/styles.css');
|
||||||
expect(dashboardFrame).not.toContain('./styles.css');
|
expect(dashboardFrame).not.toContain('./styles.css');
|
||||||
});
|
});
|
||||||
|
|
||||||
test("configures Storybook theme switching for reusable components", () => {
|
test("configures Storybook theme switching for reusable components", () => {
|
||||||
const previewSource = readFileSync(join(root, ".storybook/preview.ts"), "utf8");
|
const previewSource = readFileSync(join(packageRoot, ".storybook/preview.ts"), "utf8");
|
||||||
|
|
||||||
expect(previewSource).toContain("globalTypes");
|
expect(previewSource).toContain("globalTypes");
|
||||||
expect(previewSource).toContain("data-ui-theme");
|
expect(previewSource).toContain("data-ui-theme");
|
||||||
|
expect(previewSource).toContain("../src/styles.css");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps component and story files paired as the UI inventory changes", () => {
|
test("keeps component and story files paired as the UI inventory changes", () => {
|
||||||
|
|
@ -135,7 +139,7 @@ describe("Storybook inventory", () => {
|
||||||
|
|
||||||
test("does not add deferred form/navigation primitives", () => {
|
test("does not add deferred form/navigation primitives", () => {
|
||||||
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
|
for (const component of ["Input", "ToggleGroup", "ScrollArea"]) {
|
||||||
expect(existsSync(join(root, `src/lib/ui/components/${component}.tsx`))).toBe(false);
|
expect(existsSync(join(componentsDir, `${component}.tsx`))).toBe(false);
|
||||||
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
|
expect(existsSync(join(storiesDir, `${component}.stories.tsx`))).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
4
packages/ui/src/styles.css
Normal file
4
packages/ui/src/styles.css
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@import "@fontsource-variable/geist";
|
||||||
|
@import "uplot/dist/uPlot.min.css";
|
||||||
|
@import "./tokens.css";
|
||||||
|
@import "./components/styles.css";
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
const forbiddenTerms = [
|
|
||||||
"dimensionlab",
|
|
||||||
"dimension lab",
|
|
||||||
"vaultwarden",
|
|
||||||
"forgejo",
|
|
||||||
"grafana",
|
|
||||||
"uptime kuma",
|
|
||||||
"prometheus",
|
|
||||||
"backrest",
|
|
||||||
"open webui",
|
|
||||||
"comfyui",
|
|
||||||
"adminer",
|
|
||||||
"cockpit",
|
|
||||||
"ollama",
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("UI content boundary", () => {
|
|
||||||
test("keeps environment-specific content out of reusable UI source", () => {
|
|
||||||
const source = readUiSource(join(process.cwd(), "src", "lib", "ui"));
|
|
||||||
const normalized = source.toLowerCase();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
forbiddenTerms.filter((term) => normalized.includes(term)),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps icon rendering driven by icon identifiers", () => {
|
|
||||||
const source = readUiSource(join(process.cwd(), "src", "lib", "ui"));
|
|
||||||
|
|
||||||
expect(source).not.toContain("@iconify-json/");
|
|
||||||
expect(source).not.toContain("/icons/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function readUiSource(path: string): string {
|
|
||||||
const stats = statSync(path);
|
|
||||||
if (stats.isFile()) {
|
|
||||||
if (path.endsWith(".test.ts")) return "";
|
|
||||||
return readFileSync(path, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
return readdirSync(path)
|
|
||||||
.map((entry) => readUiSource(join(path, entry)))
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue