- 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
146 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|