mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 10:38:32 +08:00
fix: prevent exiting toasts from blocking page clicks (#38063)
This commit is contained in:
parent
a14310fc62
commit
484633d261
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user