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:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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))
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^29.1.1",
|
||||
"vite-plugin-solid": "^2.11.12",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
32
web/src/components/ui/Badge.tsx
Normal file
32
web/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
web/src/components/ui/Button.tsx
Normal file
81
web/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
web/src/components/ui/Card.tsx
Normal file
32
web/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/components/ui/Input.tsx
Normal file
66
web/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
web/src/components/ui/Modal.tsx
Normal file
144
web/src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
web/src/components/ui/Toast.tsx
Normal file
145
web/src/components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
web/src/components/ui/index.ts
Normal file
6
web/src/components/ui/index.ts
Normal 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";
|
||||
504
web/src/components/ui/ui.test.tsx
Normal file
504
web/src/components/ui/ui.test.tsx
Normal 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
3
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: (string | boolean | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user