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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user