dodil/erp-ui
src/components/toast/Toaster.tsx
/**
* Toasts — "Deal moved to proposal", "Quote sent". The confirmation of a step that worked, said once and
* gone. A refusal is NOT a toast: it stays where the person acted (FormDialog, ConfirmDialog, ErrorState),
* because a reason that disappears after four seconds is a reason nobody read.
*
* Built on the Radix Toast primitive (announced to screen readers, swipe / Escape to dismiss), styled with
* Radix Themes tokens.
*/
import { Card, Flex, IconButton, Text } from "@radix-ui/themes";
import { Toast } from "radix-ui";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
export interface ToastMessage {
title: string;
description?: string;
tone?: "success" | "neutral" | "warning";
}
const Ctx = createContext<(m: ToastMessage) => void>(() => {});
/** `const toast = useToast(); toast({ title: "Quote sent" })`. */
export function useToast() {
return useContext(Ctx);
}
const COLOR = { success: "green", neutral: "gray", warning: "amber" } as const;
export function Toaster({ children, duration = 4000 }: { children: ReactNode; duration?: number }) {
const [items, setItems] = useState<(ToastMessage & { id: number })[]>([]);
const push = useCallback((m: ToastMessage) => setItems((s) => [...s, { ...m, id: Date.now() + Math.random() }]), []);
return (
<Ctx.Provider value={push}>
<Toast.Provider duration={duration} swipeDirection="right" label="Notification">
{children}
{items.map((t) => (
<Toast.Root key={t.id} asChild
onOpenChange={(open) => { if (!open) setItems((s) => s.filter((x) => x.id !== t.id)); }}>
<Card style={{ borderLeft: `3px solid var(--${COLOR[t.tone ?? "success"]}-9)` }}>
<Flex gap="3" align="start" justify="between">
<Flex direction="column" gap="1">
<Toast.Title asChild><Text weight="bold" size="2">{t.title}</Text></Toast.Title>
{t.description && <Toast.Description asChild><Text size="2" color="gray">{t.description}</Text></Toast.Description>}
</Flex>
<Toast.Close asChild>
<IconButton size="1" variant="ghost" color="gray" aria-label="Dismiss">×</IconButton>
</Toast.Close>
</Flex>
</Card>
</Toast.Root>
))}
<Toast.Viewport style={{
position: "fixed", bottom: "var(--space-4)", right: "var(--space-4)", zIndex: 2147483647,
display: "flex", flexDirection: "column", gap: "var(--space-2)", width: "min(380px, calc(100vw - 32px))",
margin: 0, padding: 0, listStyle: "none", outline: "none",
}} />
</Toast.Provider>
</Ctx.Provider>
);
}