toaster.tsx 1.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
  1. import { create } from "zustand";
  2. import { Button } from "./button";
  3. type ToastKind = "success" | "error" | "info";
  4. interface ToastMessage {
  5. id: string;
  6. title: string;
  7. kind: ToastKind;
  8. }
  9. const useToastStore = create<{
  10. items: ToastMessage[];
  11. push: (title: string, kind?: ToastKind) => void;
  12. remove: (id: string) => void;
  13. }>((set) => ({
  14. items: [],
  15. push: (title, kind = "info") => {
  16. const id = crypto.randomUUID();
  17. set((state) => ({ items: [...state.items, { id, title, kind }] }));
  18. window.setTimeout(() => set((state) => ({ items: state.items.filter((item) => item.id !== id) })), 3200);
  19. },
  20. remove: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
  21. }));
  22. export const toast = {
  23. success: (title: string) => useToastStore.getState().push(title, "success"),
  24. error: (title: string) => useToastStore.getState().push(title, "error"),
  25. info: (title: string) => useToastStore.getState().push(title, "info"),
  26. };
  27. export function Toaster() {
  28. const { items, remove } = useToastStore();
  29. return (
  30. <div className="fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
  31. {items.map((item) => (
  32. <div
  33. key={item.id}
  34. className={[
  35. "rounded-md border bg-surface-elevated p-3 text-sm shadow-glow",
  36. item.kind === "success" ? "border-emerald-500/25" : item.kind === "error" ? "border-red-500/25" : "border-border",
  37. ].join(" ")}
  38. role={item.kind === "error" ? "alert" : "status"}
  39. >
  40. <div className="flex items-center justify-between gap-3">
  41. <span>{item.title}</span>
  42. <Button variant="ghost" size="sm" onClick={() => remove(item.id)}>
  43. Dismiss
  44. </Button>
  45. </div>
  46. </div>
  47. ))}
  48. </div>
  49. );
  50. }