diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index a17c9cbf96..1cede94148 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -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' diff --git a/packages/ui/src/components/primitives/sonner.d.ts b/packages/ui/src/components/primitives/sonner.d.ts new file mode 100644 index 0000000000..3026be7915 --- /dev/null +++ b/packages/ui/src/components/primitives/sonner.d.ts @@ -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 +} + +/** + * Props for quick toast API methods (without type field) + */ +interface QuickToastProps extends Omit {} + +/** + * 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): 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 } diff --git a/packages/ui/src/components/primitives/sonner.tsx b/packages/ui/src/components/primitives/sonner.tsx index a07294e5e6..c85b87ac40 100644 --- a/packages/ui/src/components/primitives/sonner.tsx +++ b/packages/ui/src/components/primitives/sonner.tsx @@ -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) => ( - - + +
@@ -129,19 +128,12 @@ const WarningIcon = ({ className }: SVGProps) => ( ) const SuccessIcon = ({ className }: SVGProps) => ( - - + + - +
) => ( ) -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = 'system' } = useTheme() +const CloseIcon = ({ className }: SVGProps) => ( + + + +) +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 +} + +function toast(props: Omit) { + return sonnerToast.custom((id) => ) +} + +interface QuickApiProps extends Omit {} + +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 + case 'error': + return + case 'loading': + return + case 'success': + return + case 'warning': + return + } + }, [type]) + + const handleDismiss = useCallback(() => { + sonnerToast.dismiss(id) + onDismiss?.() + }, [id, onDismiss]) return ( - , - info: , - warning: , - error: , - loading: - }} - style={ - { - '--normal-bg': 'var(--popover)', - '--normal-text': 'var(--popover-foreground)', - '--normal-border': 'var(--border)', - '--border-radius': 'var(--radius)' - } as React.CSSProperties - } - {...props} - /> +
+ {dismissable && ( + + )} +
+ {icon} +
+
+ {title} +
+
+

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

+
+ {link && ( + // FIXME: missing typography/typography components/p/letter-spacing + + )} +
+
+ {button !== undefined && ( + + )} +
) } -export { Toaster } +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 new file mode 100644 index 0000000000..0c13d500a1 --- /dev/null +++ b/packages/ui/stories/components/primitives/Sonner.stories.tsx @@ -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 = { + 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) => ( +
+ + +
+ ) + ] +} + +export default meta +type Story = StoryObj + +// Basic Toast Types +export const Info: Story = { + render: () => ( +
+ +
+ ) +} + +export const Success: Story = { + render: () => ( +
+ +
+ ) +} + +export const ErrorToast: Story = { + render: () => ( +
+ +
+ ) +} + +export const Warning: Story = { + render: () => ( +
+ +
+ ) +} + +export const Loading: Story = { + render: () => { + const mockPromise = new Promise((resolve) => { + setTimeout(() => resolve('Data loaded'), 2000) + }) + + return ( +
+ +
+ ) + } +} + +// All Toast Types Together +export const AllTypes: Story = { + render: () => ( +
+ + + + + +
+ ) +} + +// With Description +export const WithDescription: Story = { + render: () => ( +
+ +
+ ) +} + +// With Colored Message +export const WithColoredMessage: Story = { + render: () => ( +
+ + +
+ ) +} + +// With Colored Background +export const WithColoredBackground: Story = { + render: () => ( +
+ + + + +
+ ) +} + +// Colored Background with Actions +export const ColoredBackgroundWithActions: Story = { + render: () => ( +
+ + + +
+ ) +} + +// 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: () => ( +
+ +
+ ) +} + +// 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 ( +
+ +
+ ) + } +} + +// 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 ( +
+ +
+ ) + } +} + +// 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: , + 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 ( +
+
+

File Operations

+
+ + +
+
+ +
+

Form Submissions

+
+ +
+
+ +
+

Error Handling

+
+ +
+
+ +
+

Updates & Notifications

+
+ +
+
+
+ ) + } +}