Files
stack/packages/ui/src/components/Toast.tsx
Jason Woltje f47dd8bc92 feat: add domains, ideas, layouts, widgets API modules
- Add DomainsModule with full CRUD, search, and activity logging
- Add IdeasModule with quick capture endpoint
- Add LayoutsModule for user dashboard layouts
- Add WidgetsModule for widget definitions (read-only)
- Update ActivityService with domain/idea logging methods
- Register all new modules in AppModule
2026-01-29 13:47:03 -06:00

189 lines
5.8 KiB
TypeScript

import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
type HTMLAttributes,
} 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() {
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) {
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: number = 5000) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
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) {
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;
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const variantStyles: Record<ToastVariant, string> = {
success: "bg-green-500 text-white border-green-600",
error: "bg-red-500 text-white border-red-600",
warning: "bg-yellow-500 text-white border-yellow-600",
info: "bg-blue-500 text-white border-blue-600",
};
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={`${variantStyles[toast.variant || "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
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 hover:bg-white/20"
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) {
toastContextValue = context;
}
export interface ToastOptions {
variant?: ToastVariant;
duration?: number;
}
export function toast(message: string, options?: ToastOptions) {
if (!toastContextValue) {
console.warn("Toast context not available. Make sure ToastProvider is mounted.");
return;
}
toastContextValue.showToast(
message,
options?.variant || "info",
options?.duration || 5000
);
}