From 7c6d0bedc0c6f235775744c6fa4c76d0a3a3168f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:56:36 +0800 Subject: [PATCH] feat(web): add base ui toast (#33569) --- .../base/toast/__tests__/index.spec.tsx | 1 + web/app/components/base/toast/index.tsx | 3 +- web/app/components/base/ui/dialog/index.tsx | 2 +- .../base/ui/toast/__tests__/index.spec.tsx | 313 ++++++++ .../base/ui/toast/index.stories.tsx | 332 ++++++++ web/app/components/base/ui/toast/index.tsx | 202 +++++ .../workflow/panel/workflow-preview.tsx | 4 +- web/app/layout.tsx | 2 + web/docs/overlay-migration.md | 20 +- web/eslint-suppressions.json | 755 +++++++++++++++--- web/eslint.config.mjs | 9 + web/i18n/en-US/common.json | 2 + 12 files changed, 1547 insertions(+), 98 deletions(-) create mode 100644 web/app/components/base/ui/toast/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/toast/index.stories.tsx create mode 100644 web/app/components/base/ui/toast/index.tsx diff --git a/web/app/components/base/toast/__tests__/index.spec.tsx b/web/app/components/base/toast/__tests__/index.spec.tsx index 0cf25a72e7..8e60ebf827 100644 --- a/web/app/components/base/toast/__tests__/index.spec.tsx +++ b/web/app/components/base/toast/__tests__/index.spec.tsx @@ -55,6 +55,7 @@ describe('Toast', () => { ) const successToast = getToastElementByMessage('Success message') + expect(successToast).toHaveClass('z-[1101]') const successIcon = within(successToast).getByTestId('toast-icon-success') expect(successIcon).toHaveClass('text-text-success') diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx index c66be8da15..897b6039ba 100644 --- a/web/app/components/base/toast/index.tsx +++ b/web/app/components/base/toast/index.tsx @@ -28,7 +28,8 @@ const Toast = ({ return (
{ + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + act(() => { + toast.close() + }) + }) + + afterEach(() => { + act(() => { + toast.close() + vi.runOnlyPendingTimers() + }) + vi.useRealTimers() + }) + + // Core host and manager integration. + it('should render a toast when add is called', async () => { + render() + + act(() => { + toast.add({ + title: 'Saved', + description: 'Your changes are available now.', + type: 'success', + }) + }) + + expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(screen.getByText('Your changes are available now.')).toBeInTheDocument() + const viewport = screen.getByRole('region', { name: 'common.toast.notifications' }) + expect(viewport).toHaveAttribute('aria-live', 'polite') + expect(viewport).toHaveClass('z-[1101]') + expect(viewport.firstElementChild).toHaveClass('top-4') + expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() + expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).toBeInTheDocument() + }) + + // Collapsed stacks should keep multiple toast roots mounted for smooth stack animation. + it('should keep multiple toast roots mounted in a collapsed stack', async () => { + render() + + act(() => { + toast.add({ + title: 'First toast', + }) + }) + + expect(await screen.findByText('First toast')).toBeInTheDocument() + + act(() => { + toast.add({ + title: 'Second toast', + }) + toast.add({ + title: 'Third toast', + }) + }) + + expect(await screen.findByText('Third toast')).toBeInTheDocument() + expect(screen.getAllByRole('dialog')).toHaveLength(3) + expect(document.body.querySelectorAll('button[aria-label="common.toast.close"][aria-hidden="true"]')).toHaveLength(3) + + fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' })) + + await waitFor(() => { + expect(document.body.querySelector('button[aria-label="common.toast.close"][aria-hidden="true"]')).not.toBeInTheDocument() + }) + }) + + // Base UI limit should cap the visible stack and mark overflow toasts as limited. + it('should mark overflow toasts as limited when the stack exceeds the configured limit', async () => { + render() + + act(() => { + toast.add({ title: 'First toast' }) + toast.add({ title: 'Second toast' }) + }) + + expect(await screen.findByText('Second toast')).toBeInTheDocument() + expect(document.body.querySelector('[data-limited]')).toBeInTheDocument() + }) + + // Closing should work through the public manager API. + it('should close a toast when close(id) is called', async () => { + render() + + let toastId = '' + act(() => { + toastId = toast.add({ + title: 'Closable', + description: 'This toast can be removed.', + }) + }) + + expect(await screen.findByText('Closable')).toBeInTheDocument() + + act(() => { + toast.close(toastId) + }) + + await waitFor(() => { + expect(screen.queryByText('Closable')).not.toBeInTheDocument() + }) + }) + + // User dismissal needs to remain accessible. + it('should close a toast when the dismiss button is clicked', async () => { + const onClose = vi.fn() + + render() + + act(() => { + toast.add({ + title: 'Dismiss me', + description: 'Manual dismissal path.', + onClose, + }) + }) + + fireEvent.mouseEnter(screen.getByRole('region', { name: 'common.toast.notifications' })) + + const dismissButton = await screen.findByRole('button', { name: 'common.toast.close' }) + + act(() => { + dismissButton.click() + }) + + await waitFor(() => { + expect(screen.queryByText('Dismiss me')).not.toBeInTheDocument() + }) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + // Base UI default timeout should apply when no timeout is provided. + it('should auto dismiss toasts with the Base UI default timeout', async () => { + render() + + act(() => { + toast.add({ + title: 'Default timeout', + }) + }) + + expect(await screen.findByText('Default timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(4999) + }) + + expect(screen.getByText('Default timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1) + }) + + await waitFor(() => { + expect(screen.queryByText('Default timeout')).not.toBeInTheDocument() + }) + }) + + // Provider timeout should apply to all toasts when configured. + it('should respect the host timeout configuration', async () => { + render() + + act(() => { + toast.add({ + title: 'Configured timeout', + }) + }) + + expect(await screen.findByText('Configured timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(2999) + }) + + expect(screen.getByText('Configured timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1) + }) + + await waitFor(() => { + expect(screen.queryByText('Configured timeout')).not.toBeInTheDocument() + }) + }) + + // Callers must be able to override or disable timeout per toast. + it('should respect custom timeout values including zero', async () => { + render() + + act(() => { + toast.add({ + title: 'Custom timeout', + timeout: 1000, + }) + }) + + expect(await screen.findByText('Custom timeout')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(1000) + }) + + await waitFor(() => { + expect(screen.queryByText('Custom timeout')).not.toBeInTheDocument() + }) + + act(() => { + toast.add({ + title: 'Persistent', + timeout: 0, + }) + }) + + expect(await screen.findByText('Persistent')).toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(10000) + }) + + expect(screen.getByText('Persistent')).toBeInTheDocument() + }) + + // Updates should flow through the same manager state. + it('should update an existing toast', async () => { + render() + + let toastId = '' + act(() => { + toastId = toast.add({ + title: 'Loading', + description: 'Preparing your data…', + type: 'info', + }) + }) + + expect(await screen.findByText('Loading')).toBeInTheDocument() + + act(() => { + toast.update(toastId, { + title: 'Done', + description: 'Your data is ready.', + type: 'success', + }) + }) + + expect(screen.getByText('Done')).toBeInTheDocument() + expect(screen.getByText('Your data is ready.')).toBeInTheDocument() + expect(screen.queryByText('Loading')).not.toBeInTheDocument() + }) + + // Action props should pass through to the Base UI action button. + it('should render and invoke toast action props', async () => { + const onAction = vi.fn() + + render() + + act(() => { + toast.add({ + title: 'Action toast', + actionProps: { + children: 'Undo', + onClick: onAction, + }, + }) + }) + + const actionButton = await screen.findByRole('button', { name: 'Undo' }) + + act(() => { + actionButton.click() + }) + + expect(onAction).toHaveBeenCalledTimes(1) + }) + + // Promise helpers are part of the public API and need a regression test. + it('should transition a promise toast from loading to success', async () => { + render() + + let resolvePromise: ((value: string) => void) | undefined + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + void act(() => toast.promise(promise, { + loading: 'Saving…', + success: result => ({ + title: 'Saved', + description: result, + type: 'success', + }), + error: 'Failed', + })) + + expect(await screen.findByText('Saving…')).toBeInTheDocument() + + await act(async () => { + resolvePromise?.('Your changes are available now.') + await promise + }) + + expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(screen.getByText('Your changes are available now.')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/ui/toast/index.stories.tsx b/web/app/components/base/ui/toast/index.stories.tsx new file mode 100644 index 0000000000..045ca96823 --- /dev/null +++ b/web/app/components/base/ui/toast/index.stories.tsx @@ -0,0 +1,332 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { ReactNode } from 'react' +import { toast, ToastHost } from '.' + +const buttonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-2 text-sm text-text-secondary shadow-xs transition-colors hover:bg-state-base-hover' +const cardClassName = 'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3' + +const ExampleCard = ({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string + title: string + description: string + children: ReactNode +}) => { + return ( +
+
+
+ {eyebrow} +
+

+ {title} +

+

+ {description} +

+
+
+ {children} +
+
+ ) +} + +const VariantExamples = () => { + const createVariantToast = (type: 'success' | 'error' | 'warning' | 'info') => { + const copy = { + success: { + title: 'Changes saved', + description: 'Your draft is available to collaborators.', + }, + error: { + title: 'Sync failed', + description: 'Check your network connection and try again.', + }, + warning: { + title: 'Storage almost full', + description: 'You have less than 10% of workspace quota remaining.', + }, + info: { + title: 'Invitation sent', + description: 'An email has been sent to the new teammate.', + }, + } as const + + toast.add({ + type, + ...copy[type], + }) + } + + return ( + + + + + + + ) +} + +const StackExamples = () => { + const createStack = () => { + ;[ + { + type: 'info' as const, + title: 'Generating preview', + description: 'The first toast compresses behind the newest notification.', + }, + { + type: 'warning' as const, + title: 'Review required', + description: 'A second toast should deepen the stack without breaking spacing.', + }, + { + type: 'success' as const, + title: 'Ready to publish', + description: 'The newest toast stays frontmost while older items tuck behind it.', + }, + ].forEach(item => toast.add(item)) + } + + const createBurst = () => { + Array.from({ length: 5 }).forEach((_, index) => { + toast.add({ + type: index % 2 === 0 ? 'info' : 'success', + title: `Background task ${index + 1}`, + description: 'Use this to inspect how the stack behaves near the host limit.', + }) + }) + } + + return ( + + + + + ) +} + +const PromiseExamples = () => { + const createPromiseToast = () => { + const request = new Promise((resolve) => { + window.setTimeout(() => resolve('The deployment is now available in production.'), 1400) + }) + + void toast.promise(request, { + loading: { + type: 'info', + title: 'Deploying workflow', + description: 'Provisioning runtime and publishing the latest version.', + }, + success: result => ({ + type: 'success', + title: 'Deployment complete', + description: result, + }), + error: () => ({ + type: 'error', + title: 'Deployment failed', + description: 'The release could not be completed.', + }), + }) + } + + const createRejectingPromiseToast = () => { + const request = new Promise((_, reject) => { + window.setTimeout(() => reject(new Error('intentional story failure')), 1200) + }) + + void toast.promise(request, { + loading: 'Validating model credentials…', + success: 'Credentials verified', + error: () => ({ + type: 'error', + title: 'Credentials rejected', + description: 'The model provider returned an authentication error.', + }), + }) + } + + return ( + + + + + ) +} + +const ActionExamples = () => { + const createActionToast = () => { + toast.add({ + type: 'warning', + title: 'Project archived', + description: 'You can restore it from workspace settings for the next 30 days.', + actionProps: { + children: 'Undo', + onClick: () => { + toast.add({ + type: 'success', + title: 'Project restored', + description: 'The workspace is active again.', + }) + }, + }, + }) + } + + const createLongCopyToast = () => { + toast.add({ + type: 'info', + title: 'Knowledge ingestion in progress', + description: 'This longer example helps validate line wrapping, close button alignment, and action button placement when the content spans multiple rows.', + actionProps: { + children: 'View details', + onClick: () => { + toast.add({ + type: 'info', + title: 'Job details opened', + }) + }, + }, + }) + } + + return ( + + + + + ) +} + +const UpdateExamples = () => { + const createUpdatableToast = () => { + const toastId = toast.add({ + type: 'info', + title: 'Import started', + description: 'Preparing assets and metadata for processing.', + timeout: 0, + }) + + window.setTimeout(() => { + toast.update(toastId, { + type: 'success', + title: 'Import finished', + description: '128 records were imported successfully.', + timeout: 5000, + }) + }, 1400) + } + + const clearAll = () => { + toast.close() + } + + return ( + + + + + ) +} + +const ToastDocsDemo = () => { + return ( + <> + +
+
+
+
+ Base UI toast docs +
+

+ Shared stacked toast examples +

+

+ Each example card below triggers the same shared toast viewport in the top-right corner, so you can review stacking, state transitions, actions, and tone variants the same way the official Base UI documentation demonstrates toast behavior. +

+
+
+ + + + + +
+
+
+ + ) +} + +const meta = { + title: 'Base/Feedback/UI Toast', + component: ToastHost, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Dify toast host built on Base UI Toast. The story is organized as multiple example panels that all feed the same shared toast viewport, matching the way the Base UI documentation showcases toast behavior.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const DocsPattern: Story = { + render: () => , +} diff --git a/web/app/components/base/ui/toast/index.tsx b/web/app/components/base/ui/toast/index.tsx new file mode 100644 index 0000000000..aed0c59b16 --- /dev/null +++ b/web/app/components/base/ui/toast/index.tsx @@ -0,0 +1,202 @@ +'use client' + +import type { + ToastManagerAddOptions, + ToastManagerPromiseOptions, + ToastManagerUpdateOptions, + ToastObject, +} from '@base-ui/react/toast' +import { Toast as BaseToast } from '@base-ui/react/toast' +import { useTranslation } from 'react-i18next' +import { cn } from '@/utils/classnames' + +type ToastData = Record +type ToastType = 'success' | 'error' | 'warning' | 'info' + +type ToastAddOptions = Omit, 'data' | 'positionerProps' | 'type'> & { + type?: ToastType +} + +type ToastUpdateOptions = Omit, 'data' | 'positionerProps' | 'type'> & { + type?: ToastType +} + +type ToastPromiseOptions = { + loading: string | ToastUpdateOptions + success: string | ToastUpdateOptions | ((result: Value) => string | ToastUpdateOptions) + error: string | ToastUpdateOptions | ((error: unknown) => string | ToastUpdateOptions) +} + +export type ToastHostProps = { + timeout?: number + limit?: number +} + +const toastManager = BaseToast.createToastManager() + +export const toast = { + add(options: ToastAddOptions) { + return toastManager.add(options) + }, + close(toastId?: string) { + toastManager.close(toastId) + }, + update(toastId: string, options: ToastUpdateOptions) { + toastManager.update(toastId, options) + }, + promise(promiseValue: Promise, options: ToastPromiseOptions) { + return toastManager.promise(promiseValue, options as ToastManagerPromiseOptions) + }, +} + +function ToastIcon({ type }: { type?: string }) { + if (type === 'success') { + return