94 lines
2.8 KiB
TypeScript
94 lines
2.8 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import type { BadgeVariant } from '@/components/ui/shared/Badge';
|
|
|
|
interface ToastProps {
|
|
children: ReactNode;
|
|
variant?: BadgeVariant;
|
|
className?: string;
|
|
}
|
|
|
|
const variantStyles: Record<BadgeVariant, string> = {
|
|
success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border',
|
|
warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border',
|
|
danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border',
|
|
default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border',
|
|
};
|
|
|
|
export function Toast({ children, variant = 'default', className = '' }: ToastProps) {
|
|
return (
|
|
<div
|
|
role="status"
|
|
className={`w-full rounded-[var(--radius-md)] border px-4 py-3 text-sm shadow-lg ${variantStyles[variant]} ${className}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type ToastMessages = {
|
|
error?: string;
|
|
success?: string;
|
|
info?: string;
|
|
default?: string;
|
|
};
|
|
|
|
export type ToastStackProps = ToastMessages & {
|
|
className?: string;
|
|
};
|
|
|
|
function hasToastMessages(messages: ToastMessages): boolean {
|
|
return Boolean(messages.error || messages.success || messages.info || messages.default);
|
|
}
|
|
|
|
/** Renders active toast messages with shared badge colors (success / warning / danger / default). */
|
|
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
|
|
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={`space-y-2 ${className}`.trim()} aria-live="polite">
|
|
{error && <Toast variant="danger">{error}</Toast>}
|
|
{info && <Toast variant="warning">{info}</Toast>}
|
|
{success && <Toast variant="success">{success}</Toast>}
|
|
{defaultMessage && <Toast variant="default">{defaultMessage}</Toast>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type ToastViewportPosition = 'inline' | 'top' | 'bottom';
|
|
|
|
export type ToastViewportProps = ToastStackProps & {
|
|
position?: ToastViewportPosition;
|
|
};
|
|
|
|
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
|
|
top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none',
|
|
bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none',
|
|
};
|
|
|
|
/**
|
|
* Positions a ToastStack on the page. Use `inline` below a heading; `bottom` / `top` for overlays.
|
|
*/
|
|
export function ToastViewport({
|
|
position = 'inline',
|
|
className = '',
|
|
...messages
|
|
}: ToastViewportProps) {
|
|
if (!hasToastMessages(messages)) {
|
|
return null;
|
|
}
|
|
|
|
const stack = <ToastStack {...messages} className={className} />;
|
|
|
|
if (position === 'inline') {
|
|
return stack;
|
|
}
|
|
|
|
return (
|
|
<div className={viewportPositionClass[position]}>
|
|
<div className="pointer-events-auto w-full">{stack}</div>
|
|
</div>
|
|
);
|
|
}
|