feat: add UI primitive library — Button, Card, Input, Badge, Modal, Toast

- Add cn() utility for class merging in lib/utils.ts
- Button: primary/secondary/ghost/danger variants, sm/md/lg sizes, disabled/loading states
- Card: gradient-card background with optional header/footer slots
- Input: text/email/password/number types with label, error, helper text, focus ring
- Badge: default/success/warning/error/info variants
- Modal: Portal-based dialog with focus trap, ESC/backdrop close, animations
- Toast: ToastProvider context with show/dismiss/auto-dismiss and variant support
- Barrel export via index.ts
- 46 unit tests across all primitives
- Configure vitest with vite-plugin-solid for JSX support
This commit is contained in:
2026-05-25 13:03:00 -04:00
parent 06bf9ac97c
commit 4118a25388
12 changed files with 1019 additions and 0 deletions

3
pnpm-lock.yaml generated
View File

@@ -66,6 +66,9 @@ importers:
jsdom:
specifier: ^29.1.1
version: 29.1.1
vite-plugin-solid:
specifier: ^2.11.12
version: 2.11.12(solid-js@1.9.13)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
vitest:
specifier: ^4.1.5
version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))

View File

@@ -28,6 +28,7 @@
},
"devDependencies": {
"jsdom": "^29.1.1",
"vite-plugin-solid": "^2.11.12",
"vitest": "^4.1.5"
}
}

View File

@@ -0,0 +1,32 @@
import { cn } from "~/lib/utils";
import type { JSX } from "solid-js";
type BadgeVariant = "default" | "success" | "warning" | "error" | "info";
interface BadgeProps {
variant?: BadgeVariant;
children: JSX.Element;
class?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: "bg-[var(--color-bg-secondary)] text-[var(--color-text-secondary)]",
success: "bg-[var(--color-success-bg)] text-[var(--color-success)]",
warning: "bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
error: "bg-[var(--color-error-bg)] text-[var(--color-error)]",
info: "bg-[var(--color-info-bg)] text-[var(--color-info)]",
};
export default function Badge(props: BadgeProps) {
return (
<span
class={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
variantClasses[props.variant ?? "default"],
props.class,
)}
>
{props.children}
</span>
);
}

View File

@@ -0,0 +1,81 @@
import { cn } from "~/lib/utils";
import type { JSX } from "solid-js";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
class?: string;
children: JSX.Element;
onClick?: (e: MouseEvent) => void;
type?: "button" | "submit" | "reset";
}
const variantClasses: Record<ButtonVariant, string> = {
primary:
"gradient-primary text-white shadow-glow-primary hover:opacity-90",
secondary:
"bg-transparent border border-[var(--color-brand-primary)] text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary)]/10",
ghost:
"bg-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-secondary)]",
danger:
"bg-[var(--color-danger)] text-white hover:opacity-90 border border-[var(--color-danger)]",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-3 py-1.5 text-sm rounded-lg",
md: "px-4 py-2 text-base rounded-lg",
lg: "px-6 py-3 text-lg rounded-xl",
};
function Spinner() {
return (
<svg
class="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
export default function Button(props: ButtonProps) {
const variant = () => props.variant ?? "primary";
const size = () => props.size ?? "md";
const isDisabled = () => props.disabled || props.loading;
return (
<button
type={props.type ?? "button"}
disabled={isDisabled()}
onClick={props.onClick}
class={cn(
"inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer",
variantClasses[variant()],
sizeClasses[size()],
props.class,
)}
>
{props.loading && <Spinner />}
{props.children}
</button>
);
}

View File

@@ -0,0 +1,32 @@
import { cn } from "~/lib/utils";
import type { JSX } from "solid-js";
interface CardProps {
class?: string;
children: JSX.Element;
header?: JSX.Element;
footer?: JSX.Element;
}
export default function Card(props: CardProps) {
return (
<div
class={cn(
"gradient-card border border-[var(--color-border)]/50 rounded-xl overflow-hidden",
props.class,
)}
>
{props.header && (
<div class="px-6 py-4 border-b border-[var(--color-border)]/50">
{props.header}
</div>
)}
<div class="px-6 py-4">{props.children}</div>
{props.footer && (
<div class="px-6 py-4 border-t border-[var(--color-border)]/50">
{props.footer}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { cn } from "~/lib/utils";
import type { JSX } from "solid-js";
interface InputProps {
label?: string;
type?: "text" | "email" | "password" | "number";
value?: string;
onInput?: (e: InputEvent & { currentTarget: HTMLInputElement }) => void;
error?: string;
helperText?: string;
placeholder?: string;
class?: string;
id?: string;
name?: string;
required?: boolean;
disabled?: boolean;
}
export default function Input(props: InputProps) {
const id = () =>
props.id ??
props.name ??
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 10);
return (
<div class={cn("flex flex-col gap-1", props.class)}>
{props.label && (
<label
for={id()}
class="text-sm font-medium text-[var(--color-text-primary)]"
>
{props.label}
{props.required && (
<span class="text-[var(--color-error)] ml-1">*</span>
)}
</label>
)}
<input
id={id()}
type={props.type ?? "text"}
value={props.value ?? ""}
onInput={props.onInput}
placeholder={props.placeholder}
name={props.name}
disabled={props.disabled}
class={cn(
"w-full bg-transparent border rounded-lg px-4 py-2 text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] transition-all duration-200",
props.error
? "border-[var(--color-error)] focus:ring-[var(--color-error)]"
: "border-[var(--color-border)] focus:ring-[var(--color-focus-ring)]",
"focus:outline-none focus:ring-2",
props.disabled && "opacity-50 cursor-not-allowed",
)}
/>
{props.error && (
<p class="text-sm text-[var(--color-error)]">{props.error}</p>
)}
{props.helperText && !props.error && (
<p class="text-sm text-[var(--color-text-tertiary)]">
{props.helperText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
import { cn } from "~/lib/utils";
import { createSignal, createEffect, onCleanup, Show } from "solid-js";
import { Portal } from "solid-js/web";
import type { JSX } from "solid-js";
type ModalSize = "sm" | "md" | "lg";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: JSX.Element;
size?: ModalSize;
class?: string;
}
const sizeClasses: Record<ModalSize, string> = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
};
export default function Modal(props: ModalProps) {
const [visible, setVisible] = createSignal(false);
const [animating, setAnimating] = createSignal(false);
let contentRef!: HTMLDivElement;
createEffect(() => {
if (props.isOpen) {
setVisible(true);
requestAnimationFrame(() => setAnimating(true));
} else if (visible()) {
setAnimating(false);
const timer = setTimeout(() => setVisible(false), 200);
onCleanup(() => clearTimeout(timer));
}
});
createEffect(() => {
if (props.isOpen) {
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
onCleanup(() => {
document.body.style.overflow = prev;
});
}
});
const handleBackdropClick = (e: MouseEvent) => {
if (e.target === e.currentTarget) {
props.onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
props.onClose();
return;
}
if (e.key === "Tab" && contentRef) {
const focusable = contentRef.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
createEffect(() => {
if (props.isOpen && visible() && contentRef) {
const focusable = contentRef.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
focusable[0]?.focus();
}
});
return (
<Show when={visible()}>
<Portal mount={document.body}>
<div
class={cn(
"fixed inset-0 z-50 flex items-center justify-center p-4 transition-colors duration-200",
animating()
? "bg-black/50 backdrop-blur-sm"
: "bg-transparent",
)}
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
>
<div
ref={contentRef}
role="dialog"
aria-modal="true"
aria-label={props.title}
class={cn(
"gradient-card border border-[var(--color-border)]/50 rounded-xl w-full shadow-xl transition-all duration-200",
animating()
? "scale-100 opacity-100"
: "scale-95 opacity-0",
sizeClasses[props.size ?? "md"],
props.class,
)}
>
{props.title && (
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)]/50">
<h2 class="text-lg font-semibold text-[var(--color-text-primary)]">
{props.title}
</h2>
<button
type="button"
onClick={props.onClose}
class="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors cursor-pointer"
aria-label="Close"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
)}
<div class="px-6 py-4">{props.children}</div>
</div>
</div>
</Portal>
</Show>
);
}

View File

@@ -0,0 +1,145 @@
import {
createContext,
useContext,
createSignal,
For,
Show,
onCleanup,
} from "solid-js";
import { cn } from "~/lib/utils";
import type { JSX } from "solid-js";
type ToastVariant = "success" | "error" | "warning" | "info";
interface Toast {
id: string;
message: string;
variant: ToastVariant;
duration: number;
}
interface ToastContextValue {
toasts: () => Toast[];
showToast: (
message: string,
variant?: ToastVariant,
duration?: number,
) => string;
dismissToast: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue>();
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
return ctx;
}
export function ToastProvider(props: { children: JSX.Element }) {
const [toasts, setToasts] = createSignal<Toast[]>([]);
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const showToast = (
message: string,
variant: ToastVariant = "info",
duration = 4000,
) => {
const id =
globalThis.crypto?.randomUUID?.() ??
Math.random().toString(36).slice(2, 10);
const toast: Toast = { id, message, variant, duration };
setToasts((prev) => [...prev, toast]);
if (duration > 0) {
const timer = setTimeout(() => dismissToast(id), duration);
timers.set(id, timer);
}
return id;
};
const dismissToast = (id: string) => {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
setToasts((prev) => prev.filter((t) => t.id !== id));
};
onCleanup(() => {
for (const timer of timers.values()) {
clearTimeout(timer);
}
timers.clear();
});
const value: ToastContextValue = {
toasts,
showToast,
dismissToast,
};
return (
<ToastContext.Provider value={value}>
{props.children}
<ToastContainer />
</ToastContext.Provider>
);
}
const variantStyles: Record<ToastVariant, string> = {
success:
"border-[var(--color-success)] bg-[var(--color-success-bg)] text-[var(--color-success)]",
error:
"border-[var(--color-error)] bg-[var(--color-error-bg)] text-[var(--color-error)]",
warning:
"border-[var(--color-warning)] bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
info: "border-[var(--color-info)] bg-[var(--color-info-bg)] text-[var(--color-info)]",
};
function ToastContainer() {
const { toasts, dismissToast } = useToast();
return (
<Show when={toasts().length > 0}>
<div
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm"
role="region"
aria-label="Notifications"
>
<For each={toasts()}>
{(toast) => (
<div
class={cn(
"flex items-center justify-between gap-2 px-4 py-3 rounded-lg border-l-4 shadow-lg transition-all duration-200",
variantStyles[toast.variant],
)}
role="alert"
>
<span class="text-sm font-medium">{toast.message}</span>
<button
type="button"
onClick={() => dismissToast(toast.id)}
class="shrink-0 text-current opacity-70 hover:opacity-100 transition-opacity cursor-pointer"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
)}
</For>
</div>
</Show>
);
}

View File

@@ -0,0 +1,6 @@
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as Input } from "./Input";
export { default as Badge } from "./Badge";
export { default as Modal } from "./Modal";
export { ToastProvider, useToast } from "./Toast";

View File

@@ -0,0 +1,504 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "solid-js/web";
import type { JSX } from "solid-js";
import Button from "./Button";
import Card from "./Card";
import Input from "./Input";
import Badge from "./Badge";
import Modal from "./Modal";
import { ToastProvider, useToast } from "./Toast";
function mount(comp: () => JSX.Element): HTMLDivElement {
const container = document.createElement("div");
document.body.appendChild(container);
render(() => comp(), container);
return container;
}
beforeEach(() => {
document.body.innerHTML = "";
if (!globalThis.crypto) {
Object.defineProperty(globalThis, "crypto", { value: {} });
}
(globalThis.crypto as unknown as Record<string, unknown>).randomUUID = vi.fn(
() => "test-uuid-1234",
);
});
afterEach(() => {
document.body.innerHTML = "";
});
describe("Button", () => {
it("renders with default props", () => {
mount(() => <Button>Click me</Button>);
const btn = document.querySelector("button")!;
expect(btn.textContent).toContain("Click me");
expect(btn.getAttribute("type")).toBe("button");
expect(btn.className).toContain("gradient-primary");
});
it("applies primary variant by default", () => {
mount(() => <Button>Primary</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("gradient-primary");
expect(btn.className).toContain("text-white");
});
it("applies secondary variant classes", () => {
mount(() => <Button variant="secondary">Secondary</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("bg-transparent");
expect(btn.className).toContain("border");
});
it("applies ghost variant classes", () => {
mount(() => <Button variant="ghost">Ghost</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("bg-transparent");
});
it("applies danger variant classes", () => {
mount(() => <Button variant="danger">Danger</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("bg-[var(--color-danger)]");
});
it("applies size classes", () => {
mount(() => <Button size="sm">Small</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("px-3");
});
it("applies lg size classes", () => {
mount(() => <Button size="lg">Large</Button>);
const btn = document.querySelector("button")!;
expect(btn.className).toContain("px-6");
});
it("disables button when disabled prop is true", () => {
mount(() => <Button disabled>Disabled</Button>);
expect(document.querySelector("button")!.disabled).toBe(true);
});
it("shows spinner when loading", () => {
mount(() => <Button loading>Loading</Button>);
const btn = document.querySelector("button")!;
expect(btn.disabled).toBe(true);
expect(btn.querySelector("svg")).toBeTruthy();
});
it("fires onClick handler", () => {
const onClick = vi.fn();
mount(() => <Button onClick={onClick}>Click</Button>);
document.querySelector("button")!.click();
expect(onClick).toHaveBeenCalledTimes(1);
});
it("does not fire onClick when disabled", () => {
const onClick = vi.fn();
mount(() => <Button onClick={onClick} disabled>Click</Button>);
document.querySelector("button")!.click();
expect(onClick).not.toHaveBeenCalled();
});
it("merges custom class", () => {
mount(() => <Button class="my-custom">Styled</Button>);
expect(document.querySelector("button")!.className).toContain("my-custom");
});
});
describe("Card", () => {
it("renders children", () => {
mount(() => <Card><p>Hello World</p></Card>);
expect(document.body.textContent).toContain("Hello World");
expect(document.querySelector(".gradient-card")).toBeTruthy();
});
it("renders header slot", () => {
mount(() => <Card header={<span>Header Text</span>}><p>Body</p></Card>);
expect(document.body.textContent).toContain("Header Text");
expect(document.body.textContent).toContain("Body");
});
it("renders footer slot", () => {
mount(() => <Card footer={<span>Footer Text</span>}><p>Body</p></Card>);
expect(document.body.textContent).toContain("Footer Text");
expect(document.body.textContent).toContain("Body");
});
it("merges custom class", () => {
mount(() => <Card class="my-card">X</Card>);
expect(document.body.querySelector(".my-card")).toBeTruthy();
});
});
describe("Input", () => {
it("renders with label", () => {
mount(() => <Input label="Email" />);
expect(document.body.textContent).toContain("Email");
expect(document.querySelector("label")).toBeTruthy();
});
it("renders error message and applies error border", () => {
mount(() => <Input label="Email" error="Invalid email" />);
expect(document.body.textContent).toContain("Invalid email");
const input = document.querySelector("input")!;
expect(input.className).toContain("border-[var(--color-error)]");
});
it("renders helper text when no error", () => {
mount(() => (
<Input label="Email" helperText="We will never share your email." />
));
expect(document.body.textContent).toContain(
"We will never share your email.",
);
});
it("hides helper text when error is present", () => {
mount(() => (
<Input label="Email" error="Invalid" helperText="Helper" />
));
expect(document.body.textContent).toContain("Invalid");
expect(document.body.textContent).not.toContain("Helper");
});
it("renders input with correct type", () => {
mount(() => <Input type="password" />);
expect(document.querySelector("input")!.getAttribute("type")).toBe(
"password",
);
});
it("shows required indicator", () => {
mount(() => <Input label="Name" required />);
expect(document.body.textContent).toContain("*");
});
it("uses id prop when provided", () => {
mount(() => <Input id="my-input" label="Label" />);
expect(document.querySelector("input")!.id).toBe("my-input");
expect(document.querySelector("label")!.getAttribute("for")).toBe(
"my-input",
);
});
it("uses name as id fallback", () => {
mount(() => <Input name="email" label="Email" />);
expect(document.querySelector("input")!.id).toBe("email");
});
});
describe("Badge", () => {
it("renders children", () => {
mount(() => <Badge>Active</Badge>);
const badge = document.querySelector("span")!;
expect(badge.textContent).toContain("Active");
expect(badge.className).toContain("rounded-full");
});
it("applies success variant", () => {
mount(() => <Badge variant="success">OK</Badge>);
expect(document.querySelector("span")!.className).toContain(
"bg-[var(--color-success-bg)]",
);
});
it("applies error variant", () => {
mount(() => <Badge variant="error">Fail</Badge>);
expect(document.querySelector("span")!.className).toContain(
"bg-[var(--color-error-bg)]",
);
});
it("applies warning variant", () => {
mount(() => <Badge variant="warning">Warn</Badge>);
expect(document.querySelector("span")!.className).toContain(
"bg-[var(--color-warning-bg)]",
);
});
it("applies info variant", () => {
mount(() => <Badge variant="info">Info</Badge>);
expect(document.querySelector("span")!.className).toContain(
"bg-[var(--color-info-bg)]",
);
});
it("defaults to default variant", () => {
mount(() => <Badge>Default</Badge>);
expect(document.querySelector("span")!.className).toContain(
"bg-[var(--color-bg-secondary)]",
);
});
it("merges custom class", () => {
mount(() => <Badge class="my-badge">X</Badge>);
expect(document.querySelector("span")!.className).toContain("my-badge");
});
});
describe("Modal", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("does not render when closed", () => {
mount(() => (
<Modal isOpen={false} onClose={() => {}}>
<p>Secret</p>
</Modal>
));
vi.advanceTimersByTime(32);
expect(document.body.textContent).not.toContain("Secret");
});
it("renders when open", () => {
mount(() => (
<Modal isOpen={true} onClose={() => {}}>
<p>Visible</p>
</Modal>
));
vi.advanceTimersByTime(32);
expect(document.body.textContent).toContain("Visible");
});
it("renders title", () => {
mount(() => (
<Modal isOpen={true} onClose={() => {}} title="My Title">
<p>Content</p>
</Modal>
));
vi.advanceTimersByTime(32);
expect(document.body.textContent).toContain("My Title");
});
it("calls onClose when Escape is pressed", () => {
const onClose = vi.fn();
mount(() => (
<Modal isOpen={true} onClose={onClose}>
<p>Content</p>
</Modal>
));
vi.advanceTimersByTime(32);
const overlay = document.body.querySelector("[role='dialog']")!
.parentElement!;
overlay.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
);
expect(onClose).toHaveBeenCalled();
});
it("calls onClose when backdrop is clicked", () => {
const onClose = vi.fn();
mount(() => (
<Modal isOpen={true} onClose={onClose}>
<p>Content</p>
</Modal>
));
vi.advanceTimersByTime(32);
const dialog = document.body.querySelector("[role='dialog']")!;
const overlay = dialog.parentElement!;
overlay.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onClose).toHaveBeenCalled();
});
it("does not call onClose when clicking inside modal content", () => {
const onClose = vi.fn();
mount(() => (
<Modal isOpen={true} onClose={onClose}>
<p>Content</p>
</Modal>
));
vi.advanceTimersByTime(32);
const dialog = document.body.querySelector("[role='dialog']")!;
dialog.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onClose).not.toHaveBeenCalled();
});
it("sets aria attributes for accessibility", () => {
mount(() => (
<Modal isOpen={true} onClose={() => {}} title="Accessible">
<p>Content</p>
</Modal>
));
vi.advanceTimersByTime(32);
const dialog = document.body.querySelector("[role='dialog']")!;
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-label")).toBe("Accessible");
});
it("renders with Portal to document.body", () => {
const container = document.createElement("div");
document.body.appendChild(container);
render(
() => (
<Modal isOpen={true} onClose={() => {}}>
<p>Portal content</p>
</Modal>
),
container,
);
vi.advanceTimersByTime(32);
expect(document.body.textContent).toContain("Portal content");
container.remove();
});
});
describe("Toast", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it("provides context to children", () => {
let captured: { showToast: unknown; dismissToast: unknown } | undefined;
const Child = () => {
captured = useToast();
return <div>child</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
expect(captured).toBeDefined();
expect(typeof captured!.showToast).toBe("function");
expect(typeof captured!.dismissToast).toBe("function");
});
it("throws when useToast is used outside provider", () => {
expect(() => {
const Bad = () => {
useToast();
return <div />;
};
mount(() => <Bad />);
}).toThrow("useToast must be used within a ToastProvider");
});
it("shows a toast", () => {
let show!: (msg: string) => string;
const Child = () => {
const toast = useToast();
show = (msg) => toast.showToast(msg);
return <div>child</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
show("Hello Toast");
vi.advanceTimersByTime(0);
expect(document.body.textContent).toContain("Hello Toast");
});
it("dismisses a toast manually", () => {
let show!: (msg: string) => string;
let dismiss!: (id: string) => void;
const Child = () => {
const toast = useToast();
show = (msg) => toast.showToast(msg);
dismiss = (id) => toast.dismissToast(id);
return <div>child</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
const id = show("Temp");
vi.advanceTimersByTime(0);
expect(document.body.textContent).toContain("Temp");
dismiss(id);
vi.advanceTimersByTime(0);
expect(document.body.textContent).not.toContain("Temp");
});
it("auto-dismisses toast after duration", () => {
let show!: (msg: string, variant?: string, duration?: number) => string;
const Child = () => {
const toast = useToast();
show = (msg, variant, duration) =>
toast.showToast(msg, variant as "info", duration);
return <div>child</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
show("Auto", "info", 1000);
vi.advanceTimersByTime(0);
expect(document.body.textContent).toContain("Auto");
vi.advanceTimersByTime(1000);
expect(document.body.textContent).not.toContain("Auto");
});
it("shows multiple toasts", () => {
let show!: (msg: string) => string;
const Child = () => {
const toast = useToast();
show = (msg) => toast.showToast(msg);
return <div>{toast.toasts().length}</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
show("First");
show("Second");
vi.advanceTimersByTime(0);
expect(document.body.textContent).toContain("First");
expect(document.body.textContent).toContain("Second");
});
it("renders with correct variant styles", () => {
let show!: (msg: string, variant: string) => string;
const Child = () => {
const toast = useToast();
show = (msg, variant) => toast.showToast(msg, variant as "error");
return <div>child</div>;
};
mount(() => (
<ToastProvider>
<Child />
</ToastProvider>
));
show("Error!", "error");
vi.advanceTimersByTime(0);
const toastEl = document.body.querySelector("[role='alert']")!;
expect(toastEl.className).toContain("bg-[var(--color-error-bg)]");
});
});

3
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,3 @@
export function cn(...classes: (string | boolean | undefined | null | false)[]): string {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,7 +1,9 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [solid()],
test: {
environment: "jsdom",
},