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