This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import type { ReactNode } from 'react'
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import Toast, { ToastProvider, useToastContext } from '.'
import '@testing-library/jest-dom'
import { noop } from 'lodash-es'
// Mock timers for testing timeouts
jest.useFakeTimers()
const TestComponent = () => {
const { notify, close } = useToastContext()
return (
<div>
<button type="button" onClick={() => notify({ message: 'Notification message', type: 'info' })}>
Show Toast
</button>
<button type="button" onClick={close}>Close Toast</button>
</div>
)
}
describe('Toast', () => {
describe('Toast Component', () => {
test('renders toast with correct type and message', () => {
render(
<ToastProvider>
<Toast type="success" message="Success message" />
</ToastProvider>,
)
expect(screen.getByText('Success message')).toBeInTheDocument()
})
test('renders with different types', () => {
const { rerender } = render(
<ToastProvider>
<Toast type="success" message="Success message" />
</ToastProvider>,
)
expect(document.querySelector('.text-text-success')).toBeInTheDocument()
rerender(
<ToastProvider>
<Toast type="error" message="Error message" />
</ToastProvider>,
)
expect(document.querySelector('.text-text-destructive')).toBeInTheDocument()
})
test('renders with custom component', () => {
render(
<ToastProvider>
<Toast
message="Message with custom component"
customComponent={<span data-testid="custom-component">Custom</span>}
/>
</ToastProvider>,
)
expect(screen.getByTestId('custom-component')).toBeInTheDocument()
})
test('renders children content', () => {
render(
<ToastProvider>
<Toast message="Message with children">
<span>Additional information</span>
</Toast>
</ToastProvider>,
)
expect(screen.getByText('Additional information')).toBeInTheDocument()
})
test('does not render close button when close is undefined', () => {
// Create a modified context where close is undefined
const CustomToastContext = React.createContext({ notify: noop, close: undefined })
// Create a wrapper component using the custom context
const Wrapper = ({ children }: { children: ReactNode }) => (
<CustomToastContext.Provider value={{ notify: noop, close: undefined }}>
{children}
</CustomToastContext.Provider>
)
render(
<Wrapper>
<Toast message="No close button" type="info" />
</Wrapper>,
)
expect(screen.getByText('No close button')).toBeInTheDocument()
// Ensure the close button is not rendered
expect(document.querySelector('.h-4.w-4.shrink-0.text-text-tertiary')).not.toBeInTheDocument()
})
})
describe('ToastProvider and Context', () => {
test('shows and hides toast using context', async () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>,
)
// No toast initially
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
// Show toast
act(() => {
screen.getByText('Show Toast').click()
})
expect(screen.getByText('Notification message')).toBeInTheDocument()
// Close toast
act(() => {
screen.getByText('Close Toast').click()
})
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
})
test('automatically hides toast after duration', async () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>,
)
// Show toast
act(() => {
screen.getByText('Show Toast').click()
})
expect(screen.getByText('Notification message')).toBeInTheDocument()
// Fast-forward timer
act(() => {
jest.advanceTimersByTime(3000) // Default for info type is 3000ms
})
// Toast should be gone
await waitFor(() => {
expect(screen.queryByText('Notification message')).not.toBeInTheDocument()
})
})
})
describe('Toast.notify static method', () => {
test('creates and removes toast from DOM', async () => {
act(() => {
// Call the static method
Toast.notify({ message: 'Static notification', type: 'warning' })
})
// Toast should be in document
expect(screen.getByText('Static notification')).toBeInTheDocument()
// Fast-forward timer
act(() => {
jest.advanceTimersByTime(6000) // Default for warning type is 6000ms
})
// Toast should be removed
await waitFor(() => {
expect(screen.queryByText('Static notification')).not.toBeInTheDocument()
})
})
test('calls onClose callback after duration', async () => {
const onCloseMock = jest.fn()
act(() => {
Toast.notify({
message: 'Closing notification',
type: 'success',
onClose: onCloseMock,
})
})
// Fast-forward timer
act(() => {
jest.advanceTimersByTime(3000) // Default for success type is 3000ms
})
// onClose should be called
await waitFor(() => {
expect(onCloseMock).toHaveBeenCalled()
})
})
})
})

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useCallback } from 'react'
import Toast, { ToastProvider, useToastContext } from '.'
const ToastControls = () => {
const { notify } = useToastContext()
const trigger = useCallback((type: 'success' | 'error' | 'warning' | 'info') => {
notify({
type,
message: `This is a ${type} toast`,
children: type === 'info' ? 'Additional details can live here.' : undefined,
})
}, [notify])
return (
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('success')}
>
Success
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('info')}
>
Info
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('warning')}
>
Warning
</button>
<button
type="button"
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => trigger('error')}
>
Error
</button>
</div>
)
}
const ToastProviderDemo = () => {
return (
<ToastProvider>
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Toast provider</div>
<ToastControls />
</div>
</ToastProvider>
)
}
const StaticToastDemo = () => {
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Static API</div>
<button
type="button"
className="self-start rounded-md border border-divider-subtle bg-background-default px-3 py-1.5 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
onClick={() => {
const handle = Toast.notify({
type: 'success',
message: 'Saved changes',
duration: 2000,
})
setTimeout(() => handle.clear?.(), 2500)
}}
>
Trigger Toast.notify()
</button>
</div>
)
}
const meta = {
title: 'Base/Feedback/Toast',
component: ToastProviderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'ToastProvider based notifications and the static Toast.notify helper. Buttons showcase each toast variant.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ToastProviderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Provider: Story = {}
export const StaticApi: Story = {
render: () => <StaticToastDemo />,
}

View File

@@ -0,0 +1,175 @@
'use client'
import type { ReactNode } from 'react'
import React, { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import {
RiAlertFill,
RiCheckboxCircleFill,
RiCloseLine,
RiErrorWarningFill,
RiInformation2Fill,
} from '@remixicon/react'
import { createContext, useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button'
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
export type IToastProps = {
type?: 'success' | 'error' | 'warning' | 'info'
size?: 'md' | 'sm'
duration?: number
message: string
children?: ReactNode
onClose?: () => void
className?: string
customComponent?: ReactNode
}
type IToastContext = {
notify: (props: IToastProps) => void
close: () => void
}
export type ToastHandle = {
clear?: VoidFunction
}
export const ToastContext = createContext<IToastContext>({} as IToastContext)
export const useToastContext = () => useContext(ToastContext)
const Toast = ({
type = 'info',
size = 'md',
message,
children,
className,
customComponent,
}: IToastProps) => {
const { close } = useToastContext()
// sometimes message is react node array. Not handle it.
if (typeof message !== 'string')
return null
return <div className={cn(
className,
'fixed z-[9999] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
'top-0',
'right-0',
size === 'md' ? 'p-3' : 'p-2',
className,
)}>
<div className={cn(
'absolute inset-0 -z-10 opacity-40',
type === 'success' && 'bg-toast-success-bg',
type === 'warning' && 'bg-toast-warning-bg',
type === 'error' && 'bg-toast-error-bg',
type === 'info' && 'bg-toast-info-bg',
)}
/>
<div className={cn('flex', size === 'md' ? 'gap-1' : 'gap-0.5')}>
<div className={cn('flex items-center justify-center', size === 'md' ? 'p-0.5' : 'p-1')}>
{type === 'success' && <RiCheckboxCircleFill className={cn('text-text-success', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
{type === 'error' && <RiErrorWarningFill className={cn('text-text-destructive', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
{type === 'warning' && <RiAlertFill className={cn('text-text-warning-secondary', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
{type === 'info' && <RiInformation2Fill className={cn('text-text-accent', size === 'md' ? 'h-5 w-5' : 'h-4 w-4')} aria-hidden="true" />}
</div>
<div className={cn('flex grow flex-col items-start gap-1 py-1', size === 'md' ? 'px-1' : 'px-0.5')}>
<div className='flex items-center gap-1'>
<div className='system-sm-semibold text-text-primary [word-break:break-word]'>{message}</div>
{customComponent}
</div>
{children && <div className='system-xs-regular text-text-secondary'>
{children}
</div>
}
</div>
{close
&& (<ActionButton className='z-[1000]' onClick={close}>
<RiCloseLine className='h-4 w-4 shrink-0 text-text-tertiary' />
</ActionButton>)
}
</div>
</div>
}
export const ToastProvider = ({
children,
}: {
children: ReactNode
}) => {
const placeholder: IToastProps = {
type: 'info',
message: 'Toast message',
duration: 6000,
}
const [params, setParams] = React.useState<IToastProps>(placeholder)
const defaultDuring = (params.type === 'success' || params.type === 'info') ? 3000 : 6000
const [mounted, setMounted] = useState(false)
useEffect(() => {
if (mounted) {
setTimeout(() => {
setMounted(false)
}, params.duration || defaultDuring)
}
}, [defaultDuring, mounted, params.duration])
return <ToastContext.Provider value={{
notify: (props) => {
setMounted(true)
setParams(props)
},
close: () => setMounted(false),
}}>
{mounted && <Toast {...params} />}
{children}
</ToastContext.Provider>
}
Toast.notify = ({
type,
size = 'md',
message,
duration,
className,
customComponent,
onClose,
}: Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className' | 'customComponent' | 'onClose'>): ToastHandle => {
const defaultDuring = (type === 'success' || type === 'info') ? 3000 : 6000
const toastHandler: ToastHandle = {}
if (typeof window === 'object') {
const holder = document.createElement('div')
const root = createRoot(holder)
toastHandler.clear = () => {
if (holder) {
root.unmount()
holder.remove()
}
onClose?.()
}
root.render(
<ToastContext.Provider value={{
notify: noop,
close: () => {
if (holder) {
root.unmount()
holder.remove()
}
onClose?.()
},
}}>
<Toast type={type} size={size} message={message} duration={duration} className={className} customComponent={customComponent} />
</ToastContext.Provider>,
)
document.body.appendChild(holder)
const d = duration ?? defaultDuring
if (d > 0)
setTimeout(toastHandler.clear, d)
}
return toastHandler
}
export default Toast

View File

@@ -0,0 +1,44 @@
.toast {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
z-index: 99999999;
width: 1.84rem;
height: 1.80rem;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
background: #000000;
box-shadow: 0 -.04rem .1rem 1px rgba(255, 255, 255, 0.1);
border-radius: .1rem .1rem .1rem .1rem;
}
.main {
width: 2rem;
}
.icon {
margin-bottom: .2rem;
height: .4rem;
background: center center no-repeat;
background-size: contain;
}
/* .success {
background-image: url('./icons/success.svg');
}
.warning {
background-image: url('./icons/warning.svg');
}
.error {
background-image: url('./icons/error.svg');
} */
.text {
text-align: center;
font-size: .2rem;
color: rgba(255, 255, 255, 0.86);
}