diff --git a/packages/ui/src/components/primitives/sonner.tsx b/packages/ui/src/components/primitives/sonner.tsx index bb9f35adc9..186d6a74bb 100644 --- a/packages/ui/src/components/primitives/sonner.tsx +++ b/packages/ui/src/components/primitives/sonner.tsx @@ -1,7 +1,8 @@ import { cn } from '@cherrystudio/ui/utils' import { cva } from 'class-variance-authority' -import { Loader2Icon } from 'lucide-react' -import { type ReactNode, type SVGProps, useCallback, useMemo } from 'react' +import { merge } from 'lodash' +import { type ReactNode, type SVGProps, useId } from 'react' +import type { Action, ExternalToast, ToastClassnames } from 'sonner' import { toast as sonnerToast, Toaster as Sonner, type ToasterProps } from 'sonner' const InfoIcon = ({ className }: SVGProps) => ( @@ -70,135 +71,159 @@ const InfoIcon = ({ className }: SVGProps) => ( ) -const WarningIcon = ({ className }: SVGProps) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - -) - -const SuccessIcon = ({ className }: SVGProps) => ( - - - - - - -
-
- +const WarningIcon = ({ className, ...props }: SVGProps) => { + // Remove colons to prevent ID recognition issues in some CSS environments + const id = useId().replace(/:/g, '') + const filterId = `filter_${id}` + const maskId = `mask_${id}` + const gradientId = `paint_${id}` + return ( + + + + {/* Show white part */} + + {/* Clip black part */} + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - -
-) + + ) +} + +const SuccessIcon = ({ className, ...props }: SVGProps) => { + const id = useId().replace(/:/g, '') + const maskId = `mask_${id}` + const filterId = `filter_${id}` + const gradientId = `paint_${id}` + const blurClipId = `blur_clip_${id}` + + const checkPathData = + 'M17.3974 8.39243C17.6596 8.65461 17.6596 9.0797 17.3974 9.34187L11.1314 15.6078C11.0055 15.7338 10.8347 15.8045 10.6567 15.8045C10.4787 15.8045 10.3079 15.7338 10.182 15.6078L6.60142 12.0273C6.33924 11.7651 6.33924 11.3401 6.60142 11.0779C6.8636 10.8157 7.28868 10.8157 7.55086 11.0779L10.6567 14.1837L16.448 8.39243C16.7102 8.13026 17.1352 8.13026 17.3974 8.39243Z' + + const polygonPathData = + 'M13.2121 2.57414C12.5853 1.80862 11.4146 1.80862 10.788 2.57414L9.90009 3.65856C9.83924 3.73288 9.73773 3.76009 9.64787 3.72614L8.33677 3.23092C7.41123 2.88134 6.39741 3.46667 6.23738 4.44301L6.0107 5.82606C5.99517 5.92086 5.92086 5.99516 5.82606 6.0107L4.44301 6.23738C3.46668 6.39741 2.88134 7.41122 3.23092 8.33676L3.72614 9.64787C3.76009 9.73773 3.73288 9.83924 3.65856 9.90009L2.57414 10.7879C1.80862 11.4147 1.80862 12.5854 2.57414 13.2121L3.65856 14.0999C3.73288 14.1608 3.76009 14.2623 3.72614 14.3522L3.23092 15.6633C2.88135 16.5888 3.46667 17.6026 4.44301 17.7627L5.82606 17.9893C5.92086 18.0049 5.99517 18.0792 6.0107 18.174L6.23738 19.557C6.39741 20.5333 7.41122 21.1186 8.33677 20.7691L9.64787 20.2739C9.73773 20.24 9.83924 20.2671 9.90009 20.3415L10.788 21.4259C11.4146 22.1914 12.5853 22.1914 13.2121 21.4259L14.0999 20.3415C14.1608 20.2671 14.2623 20.24 14.3521 20.2739L15.6633 20.7691C16.5888 21.1186 17.6027 20.5333 17.7626 19.557L17.9894 18.174C18.0049 18.0792 18.0791 18.0049 18.1739 17.9893L19.557 17.7627C20.5334 17.6026 21.1187 16.5888 20.7691 15.6633L20.2739 14.3522C20.2399 14.2623 20.2671 14.1608 20.3414 14.0999L21.4259 13.2121C22.1914 12.5854 22.1914 11.4147 21.4259 10.7879L20.3414 9.90009C20.2671 9.83924 20.2399 9.73773 20.2739 9.64787L20.7691 8.33676C21.1187 7.41122 20.5334 6.39741 19.557 6.23738L18.1739 6.0107C18.0791 5.99516 18.0791 5.92086 17.9894 5.82606L17.7626 4.44301C17.6027 3.46668 16.5888 2.88134 15.6633 3.23092L14.3521 3.72614C14.2623 3.76009 14.1608 3.73288 14.0999 3.65856L13.2121 2.57414Z' + + return ( + + + + {/* Show white part */} + + {/* Clip black part */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + ) +} const ErrorIcon = ({ className }: SVGProps) => ( @@ -267,83 +292,137 @@ const ErrorIcon = ({ className }: SVGProps) => ( ) -const CloseIcon = ({ className }: SVGProps) => ( - - - -) -interface ToastProps { +// const CloseIcon = ({ className }: SVGProps) => ( +// +// +// +// ) +interface ToastProps { id: string | number - type: 'info' | 'warning' | 'error' | 'success' | 'loading' - title: string - description?: string - coloredMessage?: string - coloredBackground?: boolean + type?: 'info' | 'warning' | 'error' | 'success' | 'loading' | 'custom' + title: ReactNode + description?: ReactNode + colored?: boolean + duration?: number dismissable?: boolean onDismiss?: () => void - button?: { - icon?: ReactNode - label: string - onClick: () => void - } - link?: { - label: string - href?: string - onClick?: () => void - } - promise?: Promise + button?: Action | ReactNode + promise?: Promise + classNames?: ToastClassnames + jsx?: (id: number | string) => React.ReactElement } function toast(props: Omit) { - return sonnerToast.custom((id) => , { - classNames: { toast: props.coloredBackground ? 'backdrop-blur-md rounded-xs' : undefined } - }) + const type = props.type ?? 'info' + + const baseClassNames: ToastClassnames = { + toast: cn( + 'flex rounded-xs p-4 bg-background border-border border-[0.5px] shadow-lg items-center', + props.button ? 'gap-3' : 'gap-4', + props.colored && type !== 'custom' && toastBgColorVariants({ type }), + props.classNames?.toast + ), + content: cn('flex flex-col', props.description && (props.button ? 'gap-1' : 'gap-2')), + title: cn( + 'text-md font-medium leading-4.5', + props.description === undefined && 'text-xs leading-3.5 tracking-normal', + props.classNames?.title + ), + description: cn('text-foreground-secondary text-xs leading-3.5 tracking-normal', props.classNames?.description), + actionButton: cn( + 'py-1 px-2 rounded-3xs flex items-center h-7 max-h-7 bg-background-subtle border-[0.5px] border-border', + 'text-foreground text-sm leading-4 tracking-normal min-w-fit', + props.colored && 'bg-white/10', + props.classNames?.actionButton + ), + icon: cn('size-6 min-w-6', props.description && 'self-start'), + loader: cn('!static ![--size:24px]') + } + const { classNames: externalClassNames, ...rest } = props + delete externalClassNames?.toast + const classNames = merge(baseClassNames, externalClassNames) + const data = { + classNames, + unstyled: true, + description: rest.description, + duration: rest.duration, + action: rest.button, + dismissible: rest.dismissable, + onDismiss: rest.onDismiss + } satisfies ExternalToast + switch (type) { + case 'info': + return sonnerToast.info(props.title, data) + case 'warning': + return sonnerToast.warning(props.title, data) + case 'error': + return sonnerToast.error(props.title, data) + case 'success': + return sonnerToast.success(props.title, data) + case 'loading': + const id = sonnerToast.loading(props.title, data) + if (props.promise) { + // Auto dismiss when promise is settled + props.promise.finally(() => { + sonnerToast.dismiss(id) + }) + } + return id + default: + console.warn('Using custom toast without a jsx.') + return sonnerToast.custom(props.jsx ?? ((id) =>
{props.title}
)) + } } -interface QuickApiProps extends Omit {} +interface QuickApiProps extends Omit {} interface QuickLoadingProps extends QuickApiProps { promise: ToastProps['promise'] } -toast.info = (props: QuickApiProps) => { +toast.info = (message: ReactNode, data?: QuickApiProps) => { toast({ type: 'info', - ...props + title: message, + ...data }) } -toast.success = (props: QuickApiProps) => { +toast.success = (message: ReactNode, data?: QuickApiProps) => { toast({ type: 'success', - ...props + title: message, + ...data }) } -toast.warning = (props: QuickApiProps) => { +toast.warning = (message: ReactNode, data?: QuickApiProps) => { toast({ type: 'warning', - ...props + title: message, + ...data }) } -toast.error = (props: QuickApiProps) => { +toast.error = (message: ReactNode, data?: QuickApiProps) => { toast({ type: 'error', - ...props + title: message, + ...data }) } -toast.loading = (props: QuickLoadingProps) => { +toast.loading = (message: ReactNode, data: QuickLoadingProps) => { toast({ type: 'loading', - ...props + title: message, + ...data }) } @@ -351,125 +430,136 @@ toast.dismiss = (id: ToastProps['id']) => { sonnerToast.dismiss(id) } -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 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-500/10 border-blue-500/20', - warning: 'bg-orange-500/10 border-orange-500/20', - error: 'bg-red-500/10 border-red-500/20', - success: 'bg-primary/10 border-primary/20', + info: 'backdrop-blur-md bg-blue-500/10 border-blue-500/20', + warning: 'backdrop-blur-md bg-orange-500/10 border-orange-500/20', + error: 'backdrop-blur-md bg-red-500/10 border-red-500/20', + success: 'backdrop-blur-md bg-primary/10 border-primary/20', loading: 'backdrop-blur-none' } } }) -function Toast({ - id, - type, - title, - description, - coloredMessage, - coloredBackground, - dismissable, - onDismiss, - button, - link -}: ToastProps) { - const icon = useMemo(() => { - switch (type) { - case 'info': - return - case 'error': - return - case 'loading': - return - case 'success': - return - case 'warning': - return - } - }, [type]) +// function Toast({ +// id, +// type, +// title, +// description, +// coloredMessage, +// colored: coloredBackground, +// dismissable, +// onDismiss, +// button, +// link +// }: ToastProps) { +// const icon = useMemo(() => { +// switch (type) { +// case 'info': +// return +// case 'error': +// return +// case 'loading': +// return +// case 'success': +// return +// case 'warning': +// return +// } +// }, [type]) - const handleDismiss = useCallback(() => { - sonnerToast.dismiss(id) - onDismiss?.() - }, [id, onDismiss]) +// const handleDismiss = useCallback(() => { +// sonnerToast.dismiss(id) +// onDismiss?.() +// }, [id, onDismiss]) +// return ( +//
+// {dismissable && ( +// +// )} +//
+// {icon} +//
+//
+// {title} +//
+//
+//

+// {coloredMessage && {coloredMessage} } +// {description} +//

+//
+// {link && ( +// // FIXME: missing typography/typography components/p/letter-spacing +// +// )} +//
+//
+// {button !== undefined && ( +// +// )} +//
+// ) +// } + +const Toaster = ({ ...props }: ToasterProps) => { return ( -
- {dismissable && ( - - )} -
- {icon} -
-
- {title} -
-
-

- {coloredMessage && {coloredMessage} } - {description} -

-
- {link && ( - // FIXME: missing typography/typography components/p/letter-spacing - - )} -
-
- {button !== undefined && ( - - )} -
+ , + success: , + warning: , + error: + }} + {...props} + /> ) } -const Toaster = ({ ...props }: ToasterProps) => { - return -} - export { toast, Toaster } diff --git a/packages/ui/stories/components/primitives/Sonner.stories.tsx b/packages/ui/stories/components/primitives/Sonner.stories.tsx index 0c13d500a1..f561fd72d0 100644 --- a/packages/ui/stories/components/primitives/Sonner.stories.tsx +++ b/packages/ui/stories/components/primitives/Sonner.stories.tsx @@ -1,7 +1,16 @@ import { Button } from '@cherrystudio/ui' import { toast, Toaster } from '@cherrystudio/ui' import type { Meta, StoryObj } from '@storybook/react' -import { RefreshCwIcon } from 'lucide-react' + +interface PlaygroundArgs { + type: 'info' | 'success' | 'warning' | 'error' | 'loading' + title: string + description: string + colored: boolean + duration: number + withButton: boolean + buttonLabel: string +} const meta: Meta = { title: 'Components/Primitives/Sonner', @@ -29,14 +38,113 @@ const meta: Meta = { export default meta type Story = StoryObj +// Playground +export const Playground: StoryObj = { + args: { + type: 'info', + title: 'Notification Title', + description: 'This is a description that provides more details about the notification.', + colored: false, + duration: 4000, + withButton: false, + buttonLabel: 'Action' + }, + argTypes: { + type: { + control: 'select', + options: ['info', 'success', 'warning', 'error', 'loading'], + description: 'Type of toast notification' + }, + title: { + control: 'text', + description: 'Main message of the toast' + }, + description: { + control: 'text', + description: 'Optional detailed description' + }, + colored: { + control: 'boolean', + description: 'Enable colored background' + }, + duration: { + control: { type: 'number', min: 1000, max: 10000, step: 1000 }, + description: 'Duration in milliseconds (use Infinity for persistent)' + }, + withButton: { + control: 'boolean', + description: 'Show action button' + }, + buttonLabel: { + control: 'text', + description: 'Label for the action button', + if: { arg: 'withButton', truthy: true } + } + }, + render: (args: PlaygroundArgs) => { + const handleToast = () => { + const toastOptions: { + description?: string + colored: boolean + duration: number + button?: { + label: string + onClick: () => void + } + promise?: Promise + } = { + description: args.description || undefined, + colored: args.colored, + duration: args.duration + } + + if (args.withButton) { + toastOptions.button = { + label: args.buttonLabel || 'Action', + onClick: () => toast.info('Button clicked!') + } + } + + switch (args.type) { + case 'info': + toast.info(args.title, toastOptions) + break + case 'success': + toast.success(args.title, toastOptions) + break + case 'warning': + toast.warning(args.title, toastOptions) + break + case 'error': + toast.error(args.title, toastOptions) + break + case 'loading': + toast.loading(args.title, { + ...toastOptions, + promise: new Promise((resolve) => setTimeout(resolve, 2000)) + }) + break + } + } + + return ( +
+ +
+ Use the controls panel below to customize the toast properties and click the button to preview. +
+
+ ) + } +} + // Basic Toast Types export const Info: Story = { render: () => (
- - - + + + + + + +
+ ) +} + +// With Action Button +export const WithActionButton: Story = { + render: () => ( +
+ + +
) @@ -189,40 +350,36 @@ export const WithColoredBackground: Story = {
-
- ) -} - -// With Action Button -export const WithActionButton: Story = { - render: () => ( -
- -
- ) -} - -// With Link -export const WithLink: Story = { - render: () => ( -
- - -
- ) -} - -// With Button and Link -export const WithButtonAndLink: Story = { - render: () => ( -
- -
- ) -} - -// Dismissable Toast -export const DismissableToast: Story = { - render: () => ( -
-
) @@ -392,10 +439,10 @@ export const DismissableToast: Story = { 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) + toast.success('First notification', { description: 'This is the first message' }) + setTimeout(() => toast.info('Second notification', { description: 'This is the second message' }), 100) + setTimeout(() => toast.warning('Third notification', { description: 'This is the third message' }), 200) + setTimeout(() => toast.error('Fourth notification', { description: 'This is the fourth message' }), 300) } return ( @@ -416,8 +463,7 @@ export const PromiseExample: Story = { }, 2000) }) - toast.loading({ - title: 'Fetching data...', + toast.loading('Fetching data...', { description: 'Please wait while we load your information.', promise }) @@ -436,60 +482,54 @@ export const RealWorldExamples: Story = { render: () => { const handleFileSave = () => { const promise = new Promise((resolve) => setTimeout(resolve, 1500)) - toast.loading({ - title: 'Saving file...', + toast.loading('Saving file...', { promise }) promise.then(() => { - toast.success({ - title: 'File saved', - description: 'Your file has been saved successfully.' + toast.success('File saved', { + description: 'Your file has been saved successfully.', + button: { + label: 'View', + onClick: () => toast.info('Opening file...') + } }) }) } const handleFormSubmit = () => { - toast.success({ - title: 'Form submitted', + toast.success('Form submitted', { description: 'Your changes have been saved successfully.', button: { - label: 'View', - onClick: () => toast.info({ title: 'Opening form...' }) + label: 'Undo', + onClick: () => toast.info('Undoing changes...') } }) } const handleDelete = () => { - toast.error({ - title: 'Failed to delete', + toast.error('Failed to delete', { description: 'You do not have permission to delete this item.', button: { - icon: , label: 'Retry', - onClick: () => toast.info({ title: 'Retrying...' }) + onClick: () => toast.info('Retrying...') } }) } const handleCopy = () => { navigator.clipboard.writeText('https://example.com') - toast.success({ - title: 'Copied to clipboard', + toast.success('Copied to clipboard', { description: 'The link has been copied to your clipboard.' }) } const handleUpdate = () => { - toast.info({ - title: 'Update available', + toast.info('Update available', { description: 'A new version of the application is ready to install.', + colored: true, button: { label: 'Update Now', - onClick: () => toast.info({ title: 'Starting update...' }) - }, - link: { - label: 'Release Notes', - onClick: () => toast.info({ title: 'Opening release notes...' }) + onClick: () => toast.info('Starting update...') } }) }