From 4118a2538832a6fb37c0911a995d19d997099bfb Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 13:03:00 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20UI=20primitive=20library=20?= =?UTF-8?q?=E2=80=94=20Button,=20Card,=20Input,=20Badge,=20Modal,=20Toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pnpm-lock.yaml | 3 + web/package.json | 1 + web/src/components/ui/Badge.tsx | 32 ++ web/src/components/ui/Button.tsx | 81 +++++ web/src/components/ui/Card.tsx | 32 ++ web/src/components/ui/Input.tsx | 66 ++++ web/src/components/ui/Modal.tsx | 144 +++++++++ web/src/components/ui/Toast.tsx | 145 +++++++++ web/src/components/ui/index.ts | 6 + web/src/components/ui/ui.test.tsx | 504 ++++++++++++++++++++++++++++++ web/src/lib/utils.ts | 3 + web/vitest.config.ts | 2 + 12 files changed, 1019 insertions(+) create mode 100644 web/src/components/ui/Badge.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/Card.tsx create mode 100644 web/src/components/ui/Input.tsx create mode 100644 web/src/components/ui/Modal.tsx create mode 100644 web/src/components/ui/Toast.tsx create mode 100644 web/src/components/ui/index.ts create mode 100644 web/src/components/ui/ui.test.tsx create mode 100644 web/src/lib/utils.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04c96f6..b8a2078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) diff --git a/web/package.json b/web/package.json index 5c9a734..6fddceb 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "jsdom": "^29.1.1", + "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.5" } } diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..809f050 --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -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 = { + 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 ( + + {props.children} + + ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..5564ec0 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -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 = { + 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 = { + 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 ( + + + + + ); +} + +export default function Button(props: ButtonProps) { + const variant = () => props.variant ?? "primary"; + const size = () => props.size ?? "md"; + const isDisabled = () => props.disabled || props.loading; + + return ( + + ); +} diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..9a8f0d0 --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -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 ( +
+ {props.header && ( +
+ {props.header} +
+ )} +
{props.children}
+ {props.footer && ( +
+ {props.footer} +
+ )} +
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..f03c6a9 --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -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 ( +
+ {props.label && ( + + )} + + {props.error && ( +

{props.error}

+ )} + {props.helperText && !props.error && ( +

+ {props.helperText} +

+ )} +
+ ); +} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..96d4522 --- /dev/null +++ b/web/src/components/ui/Modal.tsx @@ -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 = { + 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( + '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( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + focusable[0]?.focus(); + } + }); + + return ( + + +
+ +
+
+
+ ); +} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx new file mode 100644 index 0000000..d702af9 --- /dev/null +++ b/web/src/components/ui/Toast.tsx @@ -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(); + +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([]); + const timers = new Map>(); + + 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 ( + + {props.children} + + + ); +} + +const variantStyles: Record = { + 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 ( + 0}> +
+ + {(toast) => ( + + )} + +
+
+ ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts new file mode 100644 index 0000000..4638dbd --- /dev/null +++ b/web/src/components/ui/index.ts @@ -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"; diff --git a/web/src/components/ui/ui.test.tsx b/web/src/components/ui/ui.test.tsx new file mode 100644 index 0000000..b94d71d --- /dev/null +++ b/web/src/components/ui/ui.test.tsx @@ -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).randomUUID = vi.fn( + () => "test-uuid-1234", + ); +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("Button", () => { + it("renders with default props", () => { + mount(() => ); + 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(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("gradient-primary"); + expect(btn.className).toContain("text-white"); + }); + + it("applies secondary variant classes", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("bg-transparent"); + expect(btn.className).toContain("border"); + }); + + it("applies ghost variant classes", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("bg-transparent"); + }); + + it("applies danger variant classes", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("bg-[var(--color-danger)]"); + }); + + it("applies size classes", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("px-3"); + }); + + it("applies lg size classes", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.className).toContain("px-6"); + }); + + it("disables button when disabled prop is true", () => { + mount(() => ); + expect(document.querySelector("button")!.disabled).toBe(true); + }); + + it("shows spinner when loading", () => { + mount(() => ); + const btn = document.querySelector("button")!; + expect(btn.disabled).toBe(true); + expect(btn.querySelector("svg")).toBeTruthy(); + }); + + it("fires onClick handler", () => { + const onClick = vi.fn(); + mount(() => ); + document.querySelector("button")!.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("does not fire onClick when disabled", () => { + const onClick = vi.fn(); + mount(() => ); + document.querySelector("button")!.click(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("merges custom class", () => { + mount(() => ); + expect(document.querySelector("button")!.className).toContain("my-custom"); + }); +}); + +describe("Card", () => { + it("renders children", () => { + mount(() =>

Hello World

); + expect(document.body.textContent).toContain("Hello World"); + expect(document.querySelector(".gradient-card")).toBeTruthy(); + }); + + it("renders header slot", () => { + mount(() => Header Text}>

Body

); + expect(document.body.textContent).toContain("Header Text"); + expect(document.body.textContent).toContain("Body"); + }); + + it("renders footer slot", () => { + mount(() => Footer Text}>

Body

); + expect(document.body.textContent).toContain("Footer Text"); + expect(document.body.textContent).toContain("Body"); + }); + + it("merges custom class", () => { + mount(() => X); + expect(document.body.querySelector(".my-card")).toBeTruthy(); + }); +}); + +describe("Input", () => { + it("renders with label", () => { + mount(() => ); + expect(document.body.textContent).toContain("Email"); + expect(document.querySelector("label")).toBeTruthy(); + }); + + it("renders error message and applies error border", () => { + mount(() => ); + 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(() => ( + + )); + expect(document.body.textContent).toContain( + "We will never share your email.", + ); + }); + + it("hides helper text when error is present", () => { + mount(() => ( + + )); + expect(document.body.textContent).toContain("Invalid"); + expect(document.body.textContent).not.toContain("Helper"); + }); + + it("renders input with correct type", () => { + mount(() => ); + expect(document.querySelector("input")!.getAttribute("type")).toBe( + "password", + ); + }); + + it("shows required indicator", () => { + mount(() => ); + expect(document.body.textContent).toContain("*"); + }); + + it("uses id prop when provided", () => { + mount(() => ); + expect(document.querySelector("input")!.id).toBe("my-input"); + expect(document.querySelector("label")!.getAttribute("for")).toBe( + "my-input", + ); + }); + + it("uses name as id fallback", () => { + mount(() => ); + expect(document.querySelector("input")!.id).toBe("email"); + }); +}); + +describe("Badge", () => { + it("renders children", () => { + mount(() => Active); + const badge = document.querySelector("span")!; + expect(badge.textContent).toContain("Active"); + expect(badge.className).toContain("rounded-full"); + }); + + it("applies success variant", () => { + mount(() => OK); + expect(document.querySelector("span")!.className).toContain( + "bg-[var(--color-success-bg)]", + ); + }); + + it("applies error variant", () => { + mount(() => Fail); + expect(document.querySelector("span")!.className).toContain( + "bg-[var(--color-error-bg)]", + ); + }); + + it("applies warning variant", () => { + mount(() => Warn); + expect(document.querySelector("span")!.className).toContain( + "bg-[var(--color-warning-bg)]", + ); + }); + + it("applies info variant", () => { + mount(() => Info); + expect(document.querySelector("span")!.className).toContain( + "bg-[var(--color-info-bg)]", + ); + }); + + it("defaults to default variant", () => { + mount(() => Default); + expect(document.querySelector("span")!.className).toContain( + "bg-[var(--color-bg-secondary)]", + ); + }); + + it("merges custom class", () => { + mount(() => X); + 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(() => ( + {}}> +

Secret

+
+ )); + vi.advanceTimersByTime(32); + expect(document.body.textContent).not.toContain("Secret"); + }); + + it("renders when open", () => { + mount(() => ( + {}}> +

Visible

+
+ )); + vi.advanceTimersByTime(32); + expect(document.body.textContent).toContain("Visible"); + }); + + it("renders title", () => { + mount(() => ( + {}} title="My Title"> +

Content

+
+ )); + vi.advanceTimersByTime(32); + expect(document.body.textContent).toContain("My Title"); + }); + + it("calls onClose when Escape is pressed", () => { + const onClose = vi.fn(); + mount(() => ( + +

Content

+
+ )); + 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(() => ( + +

Content

+
+ )); + 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(() => ( + +

Content

+
+ )); + 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(() => ( + {}} title="Accessible"> +

Content

+
+ )); + 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( + () => ( + {}}> +

Portal content

+
+ ), + 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
child
; + }; + + mount(() => ( + + + + )); + + 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
; + }; + mount(() => ); + }).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
child
; + }; + + mount(() => ( + + + + )); + + 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
child
; + }; + + mount(() => ( + + + + )); + + 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
child
; + }; + + mount(() => ( + + + + )); + + 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
{toast.toasts().length}
; + }; + + mount(() => ( + + + + )); + + 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
child
; + }; + + mount(() => ( + + + + )); + + show("Error!", "error"); + vi.advanceTimersByTime(0); + + const toastEl = document.body.querySelector("[role='alert']")!; + expect(toastEl.className).toContain("bg-[var(--color-error-bg)]"); + }); +}); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 0000000..df031ad --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,3 @@ +export function cn(...classes: (string | boolean | undefined | null | false)[]): string { + return classes.filter(Boolean).join(" "); +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 33abcb6..d5cfd1f 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -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", },