Files
Kordant/web/src/components/ui/Toast.tsx
Michael Freno 4118a25388 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
2026-05-25 13:03:00 -04:00

146 lines
3.9 KiB
TypeScript

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>
);
}