feat(sonner): implement custom toast component with enhanced features

- Add custom toast component with support for info, success, warning, error, and loading states
- Implement action buttons, links, colored messages, and dismissable toasts
- Add TypeScript type definitions and Storybook documentation
- Export toast API for programmatic usage
This commit is contained in:
icarus 2025-12-13 00:41:52 +08:00
parent 17d9da6f4e
commit 8794f2b3ac
No known key found for this signature in database
GPG Key ID: D4AF089AAEC25D18
4 changed files with 885 additions and 42 deletions

View File

@ -73,6 +73,6 @@ export * from './primitives/popover'
export * from './primitives/radioGroup'
export * from './primitives/select'
export * from './primitives/shadcn-io/dropzone'
export { Toaster } from './primitives/sonner'
export * from './primitives/sonner'
export * from './primitives/tabs'
export * as Textarea from './primitives/textarea'

View File

@ -0,0 +1,146 @@
import type { ReactNode } from 'react'
/**
* Toast type variants
*/
type ToastType = 'info' | 'warning' | 'error' | 'success' | 'loading'
/**
* Button configuration for toast actions
*/
interface ToastButton {
/** Icon to display in the button */
icon?: ReactNode
/** Button label text */
label: string
/** Click handler for the button */
onClick: () => void
}
/**
* Link configuration for toast navigation
*/
interface ToastLink {
/** Link label text */
label: string
/** URL to navigate to */
href?: string
/** Click handler for the link */
onClick?: () => void
}
/**
* Base toast properties
*/
interface ToastProps {
/** Unique identifier for the toast */
id: string | number
/** Type of toast notification */
type: ToastType
/** Main title text */
title: string
/** Optional description text */
description?: string
/** Optional colored message text */
coloredMessage?: string
/** Whether to use colored background for the toast */
coloredBackground?: boolean
/** Whether the toast can be dismissed */
dismissable?: boolean
/** Callback when toast is dismissed */
onDismiss?: () => void
/** Optional action button */
button?: ToastButton
/** Optional navigation link */
link?: ToastLink
/** Promise to track for loading state */
promise?: Promise<unknown>
}
/**
* Props for quick toast API methods (without type field)
*/
interface QuickToastProps extends Omit<ToastProps, 'type' | 'id'> {}
/**
* Props for loading toast (requires promise)
*/
interface QuickLoadingProps extends QuickToastProps {
promise: ToastProps['promise']
}
/**
* Toast notification interface with type-safe methods
*/
interface toast {
/**
* Display a custom toast notification
* @param props - Toast configuration (must include type)
* @returns Toast ID
* @example
* toast({
* type: 'info',
* title: 'Hello',
* description: 'This is a toast'
* })
*/
(props: Omit<ToastProps, 'id'>): string | number
/**
* Display an info toast notification
* @param props - Toast configuration (type is automatically set to 'info')
* @example
* toast.info({
* title: 'Information',
* description: 'This is an info message'
* })
*/
info: (props: QuickToastProps) => void
/**
* Display a success toast notification
* @param props - Toast configuration (type is automatically set to 'success')
* @example
* toast.success({
* title: 'Success!',
* description: 'Operation completed successfully'
* })
*/
success: (props: QuickToastProps) => void
/**
* Display a warning toast notification
* @param props - Toast configuration (type is automatically set to 'warning')
* @example
* toast.warning({
* title: 'Warning',
* description: 'Please be careful'
* })
*/
warning: (props: QuickToastProps) => void
/**
* Display an error toast notification
* @param props - Toast configuration (type is automatically set to 'error')
* @example
* toast.error({
* title: 'Error',
* description: 'Something went wrong'
* })
*/
error: (props: QuickToastProps) => void
/**
* Display a loading toast notification with promise tracking
* @param props - Toast configuration (type is automatically set to 'loading', requires promise)
* @example
* toast.loading({
* title: 'Loading...',
* promise: fetchData()
* })
*/
loading: (props: QuickLoadingProps) => void
}
// Export types for external use
export type { QuickLoadingProps, QuickToastProps, ToastButton, ToastLink, ToastProps, ToastType }

View File

@ -1,18 +1,17 @@
import { cn } from '@cherrystudio/ui/utils'
import { cva } from 'class-variance-authority'
import { Loader2Icon } from 'lucide-react'
import { useTheme } from 'next-themes'
import type { SVGProps } from 'react'
import { Toaster as Sonner, type ToasterProps } from 'sonner'
import { type ReactNode, type SVGProps, useCallback, useMemo } from 'react'
import { toast as sonnerToast, Toaster as Sonner, type ToasterProps } from 'sonner'
const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<foreignObject x="0" y="0" width="29.0476" height="30">
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<foreignObject x="0" y="0">
<div
// xmlns="http://www.w3.org/1999/xhtml"
style={{
backdropFilter: 'blur(2px)',
clipPath: 'url(#bgblur_0_1669_13486_clip_path)',
height: '100%',
width: '100%'
clipPath: 'url(#bgblur_0_1669_13486_clip_path)'
}}></div>
</foreignObject>
<g filter="url(#filter0_dd_1669_13486)" data-figma-bg-blur-radius="4">
@ -129,19 +128,12 @@ const WarningIcon = ({ className }: SVGProps<SVGSVGElement>) => (
)
const SuccessIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<mask
id="mask0_1669_13491"
style={{ maskType: 'luminance' }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<mask id="mask0_1669_13491" style={{ maskType: 'luminance' }} maskUnits="userSpaceOnUse" x="0" y="0">
<path d="M24 0H0V24H24V0Z" fill="white" />
</mask>
<g mask="url(#mask0_1669_13491)">
<foreignObject x="-3" y="-2" width="30" height="30">
<foreignObject x="-3" y="-2">
<div
// xmlns="http://www.w3.org/1999/xhtml"
style={{
@ -275,31 +267,202 @@ const ErrorIcon = ({ className }: SVGProps<SVGSVGElement>) => (
</svg>
)
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
const CloseIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className={className}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.4419 5.44194C15.686 5.19786 15.686 4.80214 15.4419 4.55806C15.1979 4.31398 14.8021 4.31398 14.5581 4.55806L10 9.11612L5.44194 4.55806C5.19786 4.31398 4.80214 4.31398 4.55806 4.55806C4.31398 4.80214 4.31398 5.19786 4.55806 5.44194L9.11612 10L4.55806 14.5581C4.31398 14.8021 4.31398 15.1979 4.55806 15.4419C4.80214 15.686 5.19786 15.686 5.44194 15.4419L10 10.8839L14.5581 15.4419C14.8021 15.686 15.1979 15.686 15.4419 15.4419C15.686 15.1979 15.686 14.8021 15.4419 14.5581L10.8839 10L15.4419 5.44194Z"
fill="black"
fill-opacity="0.4"
/>
</svg>
)
interface ToastProps {
id: string | number
type: 'info' | 'warning' | 'error' | 'success' | 'loading'
title: string
description?: string
coloredMessage?: string
coloredBackground?: boolean
dismissable?: boolean
onDismiss?: () => void
button?: {
icon?: ReactNode
label: string
onClick: () => void
}
link?: {
label: string
href?: string
onClick?: () => void
}
promise?: Promise<unknown>
}
function toast(props: Omit<ToastProps, 'id'>) {
return sonnerToast.custom((id) => <Toast id={id} {...props} />)
}
interface QuickApiProps extends Omit<ToastProps, 'type' | 'id'> {}
interface QuickLoadingProps extends QuickApiProps {
promise: ToastProps['promise']
}
toast.info = (props: QuickApiProps) => {
toast({
type: 'info',
...props
})
}
toast.success = (props: QuickApiProps) => {
toast({
type: 'success',
...props
})
}
toast.warning = (props: QuickApiProps) => {
toast({
type: 'warning',
...props
})
}
toast.error = (props: QuickApiProps) => {
toast({
type: 'error',
...props
})
}
toast.loading = (props: QuickLoadingProps) => {
toast({
type: 'loading',
...props
})
}
const toastColorVariants = cva(undefined, {
variants: {
type: {
info: 'text-blue-500',
warning: 'text-warning-base',
error: 'text-error-base',
success: 'text-success-base',
loading: 'text-foreground-muted'
}
}
})
const toastBgColorVariants = cva(undefined, {
variants: {
type: {
info: 'bg-blue-50 border-blue-400',
warning: 'bg-warning-bg border-warning-base',
error: 'bg-error-bg border-error-base',
success: 'bg-success-bg border-success-base',
loading: undefined
}
}
})
function Toast({
id,
type,
title,
description,
coloredMessage,
coloredBackground,
dismissable,
onDismiss,
button,
link
}: ToastProps) {
const icon = useMemo(() => {
switch (type) {
case 'info':
return <InfoIcon className="size-6" />
case 'error':
return <ErrorIcon className="size-6" />
case 'loading':
return <Loader2Icon className="size-6 animate-spin" />
case 'success':
return <SuccessIcon className="size-6" />
case 'warning':
return <WarningIcon className="size-6" />
}
}, [type])
const handleDismiss = useCallback(() => {
sonnerToast.dismiss(id)
onDismiss?.()
}, [id, onDismiss])
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
icons={{
success: <SuccessIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <WarningIcon className="size-4" />,
error: <ErrorIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)'
} as React.CSSProperties
}
{...props}
/>
<div
id={String(id)}
className={cn(
'flex p-4 rounded-xs bg-background items-center shadow-lg',
coloredBackground && toastBgColorVariants({ type })
)}
aria-label="Toast">
{dismissable && (
<button type="button" aria-label="Dismiss the toast" onClick={handleDismiss}>
<CloseIcon className="size-5 absolute top-[5px] right-1.5" />
</button>
)}
<div className={cn('flex items-start flex-1', button !== undefined ? 'gap-3' : 'gap-4')}>
{icon}
<div className="cs-toast-content flex flex-col gap-1">
<div className="cs-toast-title font-medium leading-4.5" role="heading">
{title}
</div>
<div className="cs-toast-description">
<p className="text-foreground-secondary text-xs leading-3.5 tracking-normal">
{coloredMessage && <span className={toastColorVariants({ type })}>{coloredMessage} </span>}
{description}
</p>
</div>
{link && (
// FIXME: missing typography/typography components/p/letter-spacing
<div className="cs-toast-link text-foreground-muted text-xs leading-3.5 tracking-normal">
<a
href={link.href}
onClick={link.onClick}
className={cn(
'underline decoration-foreground-muted cursor-pointer',
'hover:text-foreground-secondary',
// FIXME: missing active style in design
'active:text-black'
)}>
{link.label}
</a>
</div>
)}
</div>
</div>
{button !== undefined && (
<button
type="button"
className={cn(
'py-1 px-2 rounded-3xs flex items-center h-7 bg-background-subtle border-[0.5px] border-border',
'text-foreground text-sm leading-4 tracking-normal',
button.icon !== undefined && 'gap-2'
)}
onClick={button.onClick}>
<div>{button.icon}</div>
<div>{button.label}</div>
</button>
)}
</div>
)
}
export { Toaster }
const Toaster = ({ ...props }: ToasterProps) => {
return <Sonner className="toaster group" {...props} />
}
export { toast, Toaster }

View File

@ -0,0 +1,534 @@
import { Button } from '@cherrystudio/ui'
import { toast, Toaster } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { RefreshCwIcon } from 'lucide-react'
const meta: Meta<typeof Toaster> = {
title: 'Components/Primitives/Sonner',
component: Toaster,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'A custom toast notification component built on sonner. Features custom icons, action buttons, links, and support for info, success, warning, error, and loading states.'
}
}
},
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="flex min-h-[400px] w-full items-center justify-center">
<Story />
<Toaster />
</div>
)
]
}
export default meta
type Story = StoryObj<typeof meta>
// Basic Toast Types
export const Info: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.info({
title: 'Information',
description: 'This is an informational message.'
})
}>
Show Info Toast
</Button>
</div>
)
}
export const Success: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Success!',
description: 'Operation completed successfully.'
})
}>
Show Success Toast
</Button>
</div>
)
}
export const ErrorToast: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.error({
title: 'Error',
description: 'Something went wrong. Please try again.'
})
}>
Show Error Toast
</Button>
</div>
)
}
export const Warning: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.warning({
title: 'Warning',
description: 'Please be careful with this action.'
})
}>
Show Warning Toast
</Button>
</div>
)
}
export const Loading: Story = {
render: () => {
const mockPromise = new Promise((resolve) => {
setTimeout(() => resolve('Data loaded'), 2000)
})
return (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.loading({
title: 'Loading...',
description: 'Please wait while we process your request.',
promise: mockPromise
})
}>
Show Loading Toast
</Button>
</div>
)
}
}
// All Toast Types Together
export const AllTypes: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button onClick={() => toast.info({ title: 'Info Toast' })}>Info</Button>
<Button onClick={() => toast.success({ title: 'Success Toast' })}>Success</Button>
<Button onClick={() => toast.warning({ title: 'Warning Toast' })}>Warning</Button>
<Button onClick={() => toast.error({ title: 'Error Toast' })}>Error</Button>
<Button
onClick={() =>
toast.loading({
title: 'Loading Toast',
promise: new Promise((resolve) => setTimeout(resolve, 2000))
})
}>
Loading
</Button>
</div>
)
}
// With Description
export const WithDescription: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Event Created',
description: 'Your event has been created successfully. You can now share it with others.'
})
}>
Show Toast with Description
</Button>
</div>
)
}
// With Colored Message
export const WithColoredMessage: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'System Update',
coloredMessage: 'New version available!',
description: 'Click the button to update now.'
})
}>
Info with Colored Message
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Disk Space Low',
coloredMessage: '95% used',
description: 'Please free up some space.'
})
}>
Warning with Colored Message
</Button>
</div>
)
}
// With Colored Background
export const WithColoredBackground: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'Information',
description: 'This toast has a colored background.',
coloredBackground: true
})
}>
Info Background
</Button>
<Button
onClick={() =>
toast.success({
title: 'Success!',
description: 'This toast has a colored background.',
coloredBackground: true
})
}>
Success Background
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Warning',
description: 'This toast has a colored background.',
coloredBackground: true
})
}>
Warning Background
</Button>
<Button
onClick={() =>
toast.error({
title: 'Error',
description: 'This toast has a colored background.',
coloredBackground: true
})
}>
Error Background
</Button>
</div>
)
}
// Colored Background with Actions
export const ColoredBackgroundWithActions: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.success({
title: 'File Uploaded',
description: 'Your file has been uploaded successfully.',
coloredBackground: true,
button: {
label: 'View',
onClick: () => toast.info({ title: 'Opening file...' })
}
})
}>
Success with Button
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Action Required',
description: 'Please review the changes.',
coloredBackground: true,
link: {
label: 'Review',
onClick: () => toast.info({ title: 'Opening review...' })
}
})
}>
Warning with Link
</Button>
<Button
onClick={() =>
toast.error({
title: 'Update Failed',
description: 'Failed to update the record.',
coloredBackground: true,
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' })
},
link: {
label: 'Learn More',
onClick: () => toast.info({ title: 'Opening help...' })
}
})
}>
Error with Button & Link
</Button>
</div>
)
}
// With Action Button
export const WithActionButton: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Changes Saved',
description: 'Your changes have been saved successfully.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Undo',
onClick: () => toast.info({ title: 'Undoing changes...' })
}
})
}>
Show Toast with Action Button
</Button>
</div>
)
}
// With Link
export const WithLink: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'Update Available',
description: 'A new version is ready to install.',
link: {
label: 'View Details',
onClick: () => toast.info({ title: 'Opening details...' })
}
})
}>
Toast with Click Handler
</Button>
<Button
onClick={() =>
toast.success({
title: 'Documentation Updated',
description: 'Check out the new features.',
link: {
label: 'Read More',
href: 'https://example.com',
onClick: () => console.log('Link clicked')
}
})
}>
Toast with Link
</Button>
</div>
)
}
// With Button and Link
export const WithButtonAndLink: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.warning({
title: 'Action Required',
description: 'Please review the changes before proceeding.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Review',
onClick: () => toast.info({ title: 'Opening review...' })
},
link: {
label: 'Learn More',
onClick: () => toast.info({ title: 'Opening documentation...' })
}
})
}>
Show Toast with Button and Link
</Button>
</div>
)
}
// Dismissable Toast
export const DismissableToast: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.info({
title: 'Dismissable Toast',
description: 'You can close this toast by clicking the X button.',
dismissable: true,
onDismiss: () => console.log('Toast dismissed')
})
}>
Show Dismissable Toast
</Button>
</div>
)
}
// Multiple Toasts
export const MultipleToasts: Story = {
render: () => {
const showMultiple = () => {
toast.success({ title: 'First notification', description: 'This is the first message' })
setTimeout(() => toast.info({ title: 'Second notification', description: 'This is the second message' }), 100)
setTimeout(() => toast.warning({ title: 'Third notification', description: 'This is the third message' }), 200)
setTimeout(() => toast.error({ title: 'Fourth notification', description: 'This is the fourth message' }), 300)
}
return (
<div className="flex flex-col gap-3">
<Button onClick={showMultiple}>Show Multiple Toasts</Button>
</div>
)
}
}
// Promise Example
export const PromiseExample: Story = {
render: () => {
const handleAsyncOperation = () => {
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.5 ? resolve({ name: 'John Doe' }) : reject(new Error('Failed to fetch data'))
}, 2000)
})
toast.loading({
title: 'Fetching data...',
description: 'Please wait while we load your information.',
promise
})
}
return (
<div className="flex flex-col gap-3">
<Button onClick={handleAsyncOperation}>Show Promise Toast (Random Result)</Button>
</div>
)
}
}
// Real World Examples
export const RealWorldExamples: Story = {
render: () => {
const handleFileSave = () => {
const promise = new Promise((resolve) => setTimeout(resolve, 1500))
toast.loading({
title: 'Saving file...',
promise
})
promise.then(() => {
toast.success({
title: 'File saved',
description: 'Your file has been saved successfully.'
})
})
}
const handleFormSubmit = () => {
toast.success({
title: 'Form submitted',
description: 'Your changes have been saved successfully.',
button: {
label: 'View',
onClick: () => toast.info({ title: 'Opening form...' })
}
})
}
const handleDelete = () => {
toast.error({
title: 'Failed to delete',
description: 'You do not have permission to delete this item.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' })
}
})
}
const handleCopy = () => {
navigator.clipboard.writeText('https://example.com')
toast.success({
title: 'Copied to clipboard',
description: 'The link has been copied to your clipboard.'
})
}
const handleUpdate = () => {
toast.info({
title: 'Update available',
description: 'A new version of the application is ready to install.',
button: {
label: 'Update Now',
onClick: () => toast.info({ title: 'Starting update...' })
},
link: {
label: 'Release Notes',
onClick: () => toast.info({ title: 'Opening release notes...' })
}
})
}
return (
<div className="flex flex-col gap-4">
<div>
<h3 className="mb-3 text-sm font-semibold">File Operations</h3>
<div className="flex gap-2">
<Button onClick={handleFileSave}>Save File</Button>
<Button variant="outline" onClick={handleCopy}>
Copy Link
</Button>
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Form Submissions</h3>
<div className="flex gap-2">
<Button onClick={handleFormSubmit}>Submit Form</Button>
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Error Handling</h3>
<div className="flex gap-2">
<Button variant="destructive" onClick={handleDelete}>
Delete Item
</Button>
</div>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">Updates & Notifications</h3>
<div className="flex gap-2">
<Button onClick={handleUpdate}>Show Update</Button>
</div>
</div>
</div>
)
}
}