fix: prevent exiting toasts from blocking page clicks (#38063)

This commit is contained in:
yyh 2026-06-27 15:35:25 +08:00 committed by GitHub
parent a14310fc62
commit 484633d261
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 131 additions and 75 deletions

View File

@ -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(<ToastHost />)
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(<ToastHost />)
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(
<>
<style>
{`
[role="dialog"] {
transition: opacity 10000s, transform 10000s !important;
}
[role="dialog"][data-ending-style] {
opacity: 0 !important;
transform: translateY(-150%) !important;
}
.data-ending-style\\:pointer-events-none[data-ending-style] {
pointer-events: none;
}
.data-ending-style\\:after\\:pointer-events-none[data-ending-style]::after {
pointer-events: none;
}
`}
</style>
<button
type="button"
onClick={onClick}
style={{
position: 'fixed',
top: '16px',
right: '32px',
width: '360px',
height: '96px',
}}
>
Underlying action
</button>
<ToastHost />
</>,
)
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(<ToastHost timeout={1000} />)
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(<ToastHost timeout={1000} />)
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(<ToastHost />)
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()
})
})

View File

@ -153,13 +153,13 @@ function ToastCard({
<BaseToast.Root
toast={toastItem}
className={cn(
'pointer-events-auto absolute top-0 right-0 w-90 max-w-[calc(100vw-2rem)] origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
'z-[calc(100-var(--toast-index))] h-(--toast-current-height)',
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
'transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-current-height))))_scale(var(--toast-scale))]',
'data-expanded:h-(--toast-height) data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px)))_scale(1)]',
'data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
'data-ending-style:pointer-events-none data-ending-style:after:pointer-events-none data-ending-style:transform-[translateY(-150%)] data-ending-style:opacity-0',
'data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+150%))]',
'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))]',
'data-limited:pointer-events-none data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
@ -178,13 +178,13 @@ function ToastCard({
<div className="min-w-0 flex-1 p-1">
<div className="flex w-full min-w-0 items-center gap-1">
{toastItem.title != null && (
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word [overflow-wrap:anywhere] text-text-primary">
<BaseToast.Title className="min-w-0 flex-1 system-sm-semibold wrap-break-word text-text-primary">
{toastItem.title}
</BaseToast.Title>
)}
</div>
{toastItem.description != null && (
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word [overflow-wrap:anywhere] text-text-secondary">
<BaseToast.Description className="mt-1 min-w-0 system-xs-regular wrap-break-word text-text-secondary">
{toastItem.description}
</BaseToast.Description>
)}
@ -222,21 +222,15 @@ function ToastViewport() {
<BaseToast.Viewport
aria-label={toastViewportLabel}
className={cn(
'group/toast-viewport pointer-events-none fixed inset-0 z-60 overflow-visible',
'group/toast-viewport pointer-events-none fixed top-4 right-4 z-60 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8',
)}
>
<div
className={cn(
'pointer-events-none absolute top-4 right-4 w-90 max-w-[calc(100vw-2rem)] sm:right-8',
)}
>
{toasts.map(toastItem => (
<ToastCard
key={toastItem.id}
toast={toastItem}
/>
))}
</div>
{toasts.map(toastItem => (
<ToastCard
key={toastItem.id}
toast={toastItem}
/>
))}
</BaseToast.Viewport>
)
}