diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index 9560d6fbf2e..ddcdb4735ba 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -3,19 +3,16 @@ import { toast, ToastHost } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement +type BaseUIAnimationGlobal = typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean +} + const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => { element.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, })) } -const dispatchToastMouseOut = (element: HTMLElement | SVGElement) => { - element.dispatchEvent(new MouseEvent('mouseout', { - bubbles: true, - relatedTarget: document.body, - })) -} - describe('@langgenius/dify-ui/toast', () => { beforeEach(() => { vi.clearAllMocks() @@ -38,13 +35,10 @@ describe('@langgenius/dify-ui/toast', () => { await expect.element(screen.getByText('Saved')).toBeInTheDocument() await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument() - await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite') - await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60') - expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4') - expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none') - expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden') + const viewport = screen.getByRole('region', { name: 'Notifications' }) + await expect.element(viewport).toHaveAttribute('aria-live', 'polite') + await expect.element(viewport).toHaveClass('z-60') expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() - expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument() }) it('should keep multiple toast roots mounted in a collapsed stack', async () => { @@ -58,37 +52,6 @@ describe('@langgenius/dify-ui/toast', () => { await expect.element(screen.getByText('Third toast')).toBeInTheDocument() expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3) - expect(document.body.querySelectorAll('button[aria-label="Close notification"][aria-hidden="true"]')).toHaveLength(3) - - const viewport = screen.getByRole('region', { name: 'Notifications' }).element() - dispatchToastMouseOver(viewport) - - await vi.waitFor(() => { - expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).not.toBeInTheDocument() - }) - dispatchToastMouseOut(viewport) - }) - - it('should clamp varying-height toasts to the frontmost stack height when collapsed', async () => { - const screen = await render() - - toast.info('Long background toast', { - description: 'This longer toast intentionally spans multiple lines so it would overflow the collapsed stack without matching the frontmost toast height.', - }) - toast.success('Short front toast', { - description: 'Short message.', - }) - - await expect.element(screen.getByText('Short front toast')).toBeInTheDocument() - await expect.element(screen.getByText('Long background toast')).toBeInTheDocument() - await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite') - await expect.element(screen.getByRole('dialog', { name: 'Short front toast' })).toBeInTheDocument() - await expect.element(screen.getByRole('dialog', { name: 'Long background toast' })).toBeInTheDocument() - - const longToastContent = screen.getByText('Long background toast').element().closest('[class*="transition-opacity"]') - expect(longToastContent).toHaveAttribute('data-behind') - expect(longToastContent).toHaveClass('h-full') - expect(longToastContent?.parentElement).toHaveClass('h-full') }) it('should render a neutral toast when called directly', async () => { @@ -157,7 +120,6 @@ describe('@langgenius/dify-ui/toast', () => { dispatchToastMouseOver(viewport) await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() - dispatchToastMouseOut(viewport) asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click() await vi.waitFor(() => { @@ -166,15 +128,112 @@ describe('@langgenius/dify-ui/toast', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('should keep zero-timeout toasts persistent', async () => { - const screen = await render() + it('should let pointer events pass through a toast while it is exiting', async () => { + const onClick = vi.fn() + const baseUIAnimationGlobal = globalThis as BaseUIAnimationGlobal + const animationState = baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED + baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = false + + try { + const screen = await render( + <> + + + + , + ) + + toast('Dismiss me', { + timeout: 0, + }) + + await expect.element(screen.getByRole('dialog', { name: 'Dismiss me' })).toBeInTheDocument() + asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click() + + const viewport = screen.getByRole('region', { name: 'Notifications' }).element() + dispatchToastMouseOver(viewport) + + await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() + asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click() + + await vi.waitFor(() => { + expect(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).toHaveAttribute('data-ending-style') + }) + + asHTMLElement(screen.getByRole('dialog', { name: 'Dismiss me' }).element()).click() + + const underlyingAction = asHTMLElement(screen.getByRole('button', { name: 'Underlying action' }).element()) + const rect = underlyingAction.getBoundingClientRect() + const x = rect.left + rect.width / 2 + const y = rect.top + rect.height / 2 + + document.elementFromPoint(x, y)?.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + })) + + expect(onClick).toHaveBeenCalledTimes(1) + } + finally { + baseUIAnimationGlobal.BASE_UI_ANIMATIONS_DISABLED = animationState + } + }) + + it('should pass the host timeout to added toasts', async () => { + const screen = await render() + + toast('Auto dismiss') + await expect.element(screen.getByText('Auto dismiss')).toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(999) + expect(document.body).toHaveTextContent('Auto dismiss') + + await vi.advanceTimersByTimeAsync(1) + await vi.waitFor(() => { + expect(document.body).not.toHaveTextContent('Auto dismiss') + }) + }) + + it('should keep a toast persistent when its timeout is zero', async () => { + const screen = await render() toast('Persistent', { timeout: 0, }) + await expect.element(screen.getByText('Persistent')).toBeInTheDocument() - await vi.advanceTimersByTimeAsync(10000) + await vi.advanceTimersByTimeAsync(5000) expect(document.body).toHaveTextContent('Persistent') }) @@ -185,6 +244,7 @@ describe('@langgenius/dify-ui/toast', () => { description: 'Preparing your data…', }) await expect.element(screen.getByText('Loading')).toBeInTheDocument() + expect(document.body.querySelector('[aria-hidden="true"].i-ri-information-2-fill')).toBeInTheDocument() toast.update(toastId, { title: 'Done', @@ -195,27 +255,28 @@ describe('@langgenius/dify-ui/toast', () => { await expect.element(screen.getByText('Done')).toBeInTheDocument() await expect.element(screen.getByText('Your data is ready.')).toBeInTheDocument() expect(document.body).not.toHaveTextContent('Loading') + expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() }) it('should upsert an existing toast when add is called with the same id', async () => { const screen = await render() - toast('Syncing', { - id: 'sync-job', - description: 'Uploading changes…', + toast('Draft saving', { + id: 'draft-save-status', + description: 'Saving changes…', }) - await expect.element(screen.getByText('Syncing')).toBeInTheDocument() + await expect.element(screen.getByText('Draft saving')).toBeInTheDocument() - toast.success('Synced', { - id: 'sync-job', - description: 'All changes are uploaded.', + toast.success('Draft saved', { + id: 'draft-save-status', + description: 'All changes are saved.', }) await vi.waitFor(() => { - expect(document.body).not.toHaveTextContent('Syncing') + expect(document.body).not.toHaveTextContent('Draft saving') }) - await expect.element(screen.getByText('Synced')).toBeInTheDocument() - await expect.element(screen.getByText('All changes are uploaded.')).toBeInTheDocument() + await expect.element(screen.getByText('Draft saved')).toBeInTheDocument() + await expect.element(screen.getByText('All changes are saved.')).toBeInTheDocument() expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1) }) @@ -261,5 +322,6 @@ describe('@langgenius/dify-ui/toast', () => { await expect.element(screen.getByText('Saved')).toBeInTheDocument() await expect.element(screen.getByText('Your changes are available now.')).toBeInTheDocument() + expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument() }) }) diff --git a/packages/dify-ui/src/toast/index.tsx b/packages/dify-ui/src/toast/index.tsx index 9dd18518184..0c471b48e1c 100644 --- a/packages/dify-ui/src/toast/index.tsx +++ b/packages/dify-ui/src/toast/index.tsx @@ -153,13 +153,13 @@ function ToastCard({
{toastItem.title != null && ( - + {toastItem.title} )}
{toastItem.description != null && ( - + {toastItem.description} )} @@ -222,21 +222,15 @@ function ToastViewport() { -
- {toasts.map(toastItem => ( - - ))} -
+ {toasts.map(toastItem => ( + + ))}
) }