Compare commits

...

1 Commits

Author SHA1 Message Date
Cursor Agent
b0fb4c8ec9 Add Sonner toast notifications with customizable configuration
Co-authored-by: hi <hi@llu.lu>
2025-07-09 11:02:17 +00:00
6 changed files with 481 additions and 0 deletions

View File

@@ -87,6 +87,7 @@
"react-shepherd": "6.1.8",
"recharts": "2.15.3",
"shepherd.js": "14.5.0",
"sonner": "2.0.6",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"uuid": "11.1.0",

View File

@@ -191,6 +191,9 @@ importers:
shepherd.js:
specifier: 14.5.0
version: 14.5.0
sonner:
specifier: 2.0.6
version: 2.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: 2.6.0
version: 2.6.0
@@ -6376,6 +6379,12 @@ packages:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
sonner@2.0.6:
resolution: {integrity: sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -14194,6 +14203,11 @@ snapshots:
slash@3.0.0: {}
sonner@2.0.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
source-map-js@1.2.1: {}
source-map-support@0.5.21:

View File

@@ -0,0 +1,303 @@
import { Button } from "@/components/atoms/Button/Button";
import type { Meta, StoryObj } from "@storybook/nextjs";
import { toast } from "sonner";
import { Toast } from "./Toast";
const meta: Meta<typeof Toast> = {
title: "Molecules/Toast",
component: Toast,
parameters: {
layout: "centered",
docs: {
description: {
component:
"A Toast component built with Sonner that provides beautiful, customizable notifications. Supports different types (success, error, info, warning, loading), themes, positioning, and rich content. The component handles all toast notifications globally when placed in your app.",
},
},
},
argTypes: {
position: {
control: "select",
options: ["top-left", "top-right", "bottom-left", "bottom-right", "top-center", "bottom-center"],
description: "Position of the toast notifications",
},
theme: {
control: "select",
options: ["light", "dark", "system"],
description: "Theme of the toast notifications",
},
richColors: {
control: "boolean",
description: "Enable rich colors for different toast types",
},
expand: {
control: "boolean",
description: "Whether toasts should expand on hover",
},
duration: {
control: "number",
description: "Default duration in milliseconds",
},
closeButton: {
control: "boolean",
description: "Show close button on toasts",
},
visibleToasts: {
control: "number",
description: "Maximum number of toasts visible at once",
},
className: {
control: "text",
description: "CSS class name for styling",
},
},
args: {
position: "top-right",
theme: "system",
richColors: true,
expand: false,
duration: 4000,
closeButton: false,
visibleToasts: 3,
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: renderToastWithButtons,
};
export const Success: Story = {
render: renderSuccessToast,
};
export const Error: Story = {
render: renderErrorToast,
};
export const Info: Story = {
render: renderInfoToast,
};
export const Warning: Story = {
render: renderWarningToast,
};
export const Loading: Story = {
render: renderLoadingToast,
};
export const Custom: Story = {
render: renderCustomToast,
};
export const WithCloseButton: Story = {
args: {
closeButton: true,
},
render: renderToastWithButtons,
};
export const DifferentPositions: Story = {
render: renderDifferentPositions,
};
export const DarkTheme: Story = {
args: {
theme: "dark",
},
render: renderToastWithButtons,
};
export const LightTheme: Story = {
args: {
theme: "light",
},
render: renderToastWithButtons,
};
export const WithExpand: Story = {
args: {
expand: true,
},
render: renderToastWithButtons,
};
export const LongDuration: Story = {
args: {
duration: 10000,
},
render: renderToastWithButtons,
};
function renderToastWithButtons(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<div className="flex flex-wrap gap-2">
<Button variant="primary" onClick={() => toast.success("Success! Operation completed.")}>
Success Toast
</Button>
<Button variant="secondary" onClick={() => toast.error("Error! Something went wrong.")}>
Error Toast
</Button>
<Button variant="ghost" onClick={() => toast.info("Info: Here's some information.")}>
Info Toast
</Button>
<Button variant="primary" onClick={() => toast.warning("Warning! Please check this.")}>
Warning Toast
</Button>
</div>
</div>
);
}
function renderSuccessToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="primary"
onClick={() => toast.success("Success! Your changes have been saved.", {
description: "All data has been successfully synchronized.",
})}
>
Show Success Toast
</Button>
</div>
);
}
function renderErrorToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="secondary"
onClick={() => toast.error("Error! Failed to save changes.", {
description: "Please try again or contact support.",
})}
>
Show Error Toast
</Button>
</div>
);
}
function renderInfoToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="ghost"
onClick={() => toast.info("New update available!", {
description: "Version 2.0.0 is now available for download.",
})}
>
Show Info Toast
</Button>
</div>
);
}
function renderWarningToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="primary"
onClick={() => toast.warning("Storage almost full!", {
description: "You have used 90% of your storage space.",
})}
>
Show Warning Toast
</Button>
</div>
);
}
function renderLoadingToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="primary"
onClick={() => {
const id = toast.loading("Saving changes...", {
description: "Please wait while we sync your data.",
});
setTimeout(() => {
toast.success("Changes saved successfully!", { id });
}, 3000);
}}
>
Show Loading Toast
</Button>
</div>
);
}
function renderCustomToast(args: any) {
return (
<div className="space-y-4">
<Toast {...args} />
<Button
variant="primary"
onClick={() => {
toast.custom((t) => (
<div className="flex items-center gap-3 p-4 bg-white border rounded-lg shadow-lg">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold">
🎉
</div>
<div>
<div className="font-semibold">Custom Toast!</div>
<div className="text-sm text-gray-600">This is a custom toast with rich content.</div>
</div>
<Button size="small" onClick={() => toast.dismiss(t)}>
Close
</Button>
</div>
));
}}
>
Show Custom Toast
</Button>
</div>
);
}
function renderDifferentPositions(args: any) {
const positions = [
{ label: "Top Left", value: "top-left" },
{ label: "Top Right", value: "top-right" },
{ label: "Bottom Left", value: "bottom-left" },
{ label: "Bottom Right", value: "bottom-right" },
{ label: "Top Center", value: "top-center" },
{ label: "Bottom Center", value: "bottom-center" },
];
return (
<div className="space-y-4">
<Toast {...args} />
<div className="grid grid-cols-2 gap-2">
{positions.map((pos) => (
<Button
key={pos.value}
variant="ghost"
size="small"
onClick={() => {
toast.success(`Toast from ${pos.label}!`, {
position: pos.value as any,
});
}}
>
{pos.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Toaster } from "sonner";
import { useToast } from "./useToast";
export interface ToastProps {
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center";
theme?: "light" | "dark" | "system";
richColors?: boolean;
expand?: boolean;
duration?: number;
closeButton?: boolean;
visibleToasts?: number;
style?: React.CSSProperties;
className?: string;
toastOptions?: {
style?: React.CSSProperties;
className?: string;
duration?: number;
unstyled?: boolean;
};
}
export function Toast({
position = "top-right",
theme = "system",
richColors = true,
expand = false,
duration = 4000,
closeButton = false,
visibleToasts = 3,
style,
className,
toastOptions,
}: ToastProps) {
const { toastConfig } = useToast({
position,
theme,
richColors,
expand,
duration,
closeButton,
visibleToasts,
style,
className,
toastOptions,
});
return <Toaster {...toastConfig} />;
}
Toast.displayName = "Toast";

View File

@@ -0,0 +1,46 @@
import type { ToastProps } from "./Toast";
export function getToastConfig(params: ToastProps) {
return {
position: params.position || "top-right",
theme: params.theme || "system",
richColors: params.richColors !== undefined ? params.richColors : true,
expand: params.expand !== undefined ? params.expand : false,
duration: params.duration || 4000,
closeButton: params.closeButton !== undefined ? params.closeButton : false,
visibleToasts: params.visibleToasts || 3,
style: params.style,
className: params.className,
toastOptions: params.toastOptions,
};
}
export function createToastMessage(
message: string,
type: "success" | "error" | "info" | "warning" | "loading" = "info",
) {
const iconMap = {
success: "✅",
error: "❌",
info: "",
warning: "⚠️",
loading: "⏳",
};
return {
message,
icon: iconMap[type],
type,
};
}
export function validateToastOptions(options: ToastProps["toastOptions"]) {
if (!options) return true;
if (options.duration && options.duration < 0) {
console.warn("Toast duration should be positive");
return false;
}
return true;
}

View File

@@ -0,0 +1,65 @@
import { useMemo } from "react";
import { toast } from "sonner";
import { getToastConfig } from "./helpers";
import type { ToastProps } from "./Toast";
export interface UseToastParams extends ToastProps {}
export interface UseToastReturn {
toastConfig: {
position: ToastProps["position"];
theme: ToastProps["theme"];
richColors: boolean;
expand: boolean;
duration: number;
closeButton: boolean;
visibleToasts: number;
style?: React.CSSProperties;
className?: string;
toastOptions?: ToastProps["toastOptions"];
};
showToast: {
success: typeof toast.success;
error: typeof toast.error;
info: typeof toast.info;
warning: typeof toast.warning;
loading: typeof toast.loading;
promise: typeof toast.promise;
custom: typeof toast.custom;
dismiss: typeof toast.dismiss;
};
}
export function useToast(params: UseToastParams): UseToastReturn {
const toastConfig = useMemo(() => getToastConfig(params), [
params.position,
params.theme,
params.richColors,
params.expand,
params.duration,
params.closeButton,
params.visibleToasts,
params.style,
params.className,
params.toastOptions,
]);
const showToast = useMemo(
() => ({
success: toast.success,
error: toast.error,
info: toast.info,
warning: toast.warning,
loading: toast.loading,
promise: toast.promise,
custom: toast.custom,
dismiss: toast.dismiss,
}),
[],
);
return {
toastConfig,
showToast,
};
}