Files
stack/packages/ui/src/components/Toast.tsx
Jason Woltje 44011f4e27 feat(ui): align design tokens and update component variants (MS15-UI-001, MS15-UI-002)
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>
2026-02-22 15:04:00 -06:00

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