Replace hardcoded Tailwind color classes with CSS custom property inline styles across all packages/ui components (Button, Card, Badge, Input, Select, Textarea, Avatar, Modal, Toast). Badge: add new variants (badge-teal, badge-amber, badge-blue, badge-purple, badge-pulse) with mono font and pill shape. Button: add success variant, hover states via React state. Card: flat design, no shadows, semantic border/surface tokens. New: Dot component (teal, blue, amber, red, muted) with glow effect. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
221 lines
6.4 KiB
TypeScript
221 lines
6.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useCallback,
|
|
type ReactNode,
|
|
type HTMLAttributes,
|
|
type ReactElement,
|
|
} from "react";
|
|
|
|
export type ToastVariant = "success" | "error" | "warning" | "info";
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
message: string;
|
|
variant?: ToastVariant;
|
|
duration?: number;
|
|
}
|
|
|
|
export interface ToastContextValue {
|
|
showToast: (message: string, variant?: ToastVariant, duration?: number) => void;
|
|
removeToast: (id: string) => void;
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
|
|
export function useToast(): ToastContextValue {
|
|
const context = useContext(ToastContext);
|
|
if (!context) {
|
|
throw new Error("useToast must be used within a ToastProvider");
|
|
}
|
|
return context;
|
|
}
|
|
|
|
export interface ToastProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function ToastProvider({ children }: ToastProviderProps): ReactElement {
|
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
|
|
const removeToast = useCallback((id: string) => {
|
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
|
}, []);
|
|
|
|
const showToast = useCallback(
|
|
(message: string, variant: ToastVariant = "info", duration = 5000) => {
|
|
const id = `toast-${String(Date.now())}-${Math.random().toString(36).substring(2, 11)}`;
|
|
const newToast: Toast = { id, message, variant, duration };
|
|
|
|
setToasts((prev) => [...prev, newToast]);
|
|
|
|
if (duration > 0) {
|
|
setTimeout(() => {
|
|
removeToast(id);
|
|
}, duration);
|
|
}
|
|
},
|
|
[removeToast]
|
|
);
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ showToast, removeToast }}>
|
|
{children}
|
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
|
</ToastContext.Provider>
|
|
);
|
|
}
|
|
|
|
export interface ToastContainerProps extends HTMLAttributes<HTMLDivElement> {
|
|
toasts: Toast[];
|
|
onRemove: (id: string) => void;
|
|
}
|
|
|
|
function ToastContainer({
|
|
toasts,
|
|
onRemove,
|
|
className = "",
|
|
}: ToastContainerProps): ReactElement | null {
|
|
if (toasts.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className={`fixed bottom-4 right-4 z-50 flex flex-col gap-2 ${className}`}
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
{toasts.map((toast) => (
|
|
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ToastItemProps {
|
|
toast: Toast;
|
|
onRemove: (id: string) => void;
|
|
}
|
|
|
|
interface ToastVariantStyle {
|
|
background: string;
|
|
border: string;
|
|
color: string;
|
|
}
|
|
|
|
const variantStyles: Record<ToastVariant, ToastVariantStyle> = {
|
|
success: {
|
|
background: "rgba(20,184,166,0.15)",
|
|
border: "1px solid rgba(20,184,166,0.35)",
|
|
color: "var(--success)",
|
|
},
|
|
error: {
|
|
background: "rgba(229,72,77,0.15)",
|
|
border: "1px solid rgba(229,72,77,0.35)",
|
|
color: "var(--danger)",
|
|
},
|
|
warning: {
|
|
background: "rgba(245,158,11,0.15)",
|
|
border: "1px solid rgba(245,158,11,0.35)",
|
|
color: "var(--warn)",
|
|
},
|
|
info: {
|
|
background: "rgba(47,128,255,0.15)",
|
|
border: "1px solid rgba(47,128,255,0.35)",
|
|
color: "var(--info)",
|
|
},
|
|
};
|
|
|
|
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
|
const vStyle = variantStyles[toast.variant ?? "info"];
|
|
|
|
const icon: Record<ToastVariant, ReactNode> = {
|
|
success: (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
),
|
|
error: (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
),
|
|
warning: (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
),
|
|
info: (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="rounded-md px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md"
|
|
style={{
|
|
background: vStyle.background,
|
|
border: vStyle.border,
|
|
color: vStyle.color,
|
|
}}
|
|
role="alert"
|
|
>
|
|
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
|
|
<span className="flex-1 text-sm font-medium">{toast.message}</span>
|
|
<button
|
|
onClick={() => {
|
|
onRemove(toast.id);
|
|
}}
|
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded"
|
|
aria-label="Close notification"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
|
<path
|
|
fillRule="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"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Helper function to show toasts outside of React components
|
|
let toastContextValue: ToastContextValue | null = null;
|
|
|
|
export function setToastContext(context: ToastContextValue | null): void {
|
|
toastContextValue = context;
|
|
}
|
|
|
|
export interface ToastOptions {
|
|
variant?: ToastVariant;
|
|
duration?: number;
|
|
}
|
|
|
|
export function toast(message: string, options?: ToastOptions): void {
|
|
if (!toastContextValue) {
|
|
console.warn("Toast context not available. Make sure ToastProvider is mounted.");
|
|
return;
|
|
}
|
|
toastContextValue.showToast(message, options?.variant ?? "info", options?.duration ?? 5000);
|
|
}
|