fix: align toast stack with Base UI (#37382)

This commit is contained in:
yyh 2026-06-12 17:48:56 +08:00 committed by GitHub
parent e0c6ca9930
commit e5d5931fec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 68 additions and 3 deletions

View File

@ -69,6 +69,28 @@ describe('@langgenius/dify-ui/toast', () => {
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 () => {
const screen = await render(<ToastHost />)

View File

@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ReactNode } from 'react'
import { useRef } 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'
@ -117,6 +118,15 @@ const StackExamples = () => {
})
}
const createVaryingHeightStack = () => {
toast.info('Long background toast', {
description: 'This longer toast intentionally spans multiple lines so the collapsed stack can be checked against the shorter frontmost toast height without panel overflow.',
})
toast.success('Short front toast', {
description: 'Short message.',
})
}
return (
<ExampleCard
eyebrow="Stack"
@ -129,6 +139,9 @@ const StackExamples = () => {
<button type="button" className={buttonClassName} onClick={createBurst}>
Stress the stack
</button>
<button type="button" className={buttonClassName} onClick={createVaryingHeightStack}>
Varying heights
</button>
</ExampleCard>
)
}
@ -192,11 +205,14 @@ const PromiseExamples = () => {
const ActionExamples = () => {
const createActionToast = () => {
toast.warning('Project archived', {
let archivedToastId = ''
archivedToastId = toast.warning('Project archived', {
description: 'You can restore it from workspace settings for the next 30 days.',
timeout: 10000,
actionProps: {
children: 'Undo',
onClick: () => {
toast.dismiss(archivedToastId)
toast.success('Project restored', {
description: 'The workspace is active again.',
})
@ -233,6 +249,32 @@ const ActionExamples = () => {
)
}
const DeduplicateExamples = () => {
const saveCountRef = useRef(0)
const saveDraft = () => {
saveCountRef.current += 1
toast.success('Draft saved', {
id: 'draft-save-status',
description: saveCountRef.current === 1
? 'Click again while this toast is visible to update the same mounted toast.'
: `Same toast updated ${saveCountRef.current} times.`,
})
}
return (
<ExampleCard
eyebrow="Deduplicated"
title="Same-id upsert"
description="Matches the Base UI deduplicated toast example: repeated triggers use a stable id, so the visible toast is updated instead of adding another stack item."
>
<button type="button" className={buttonClassName} onClick={saveDraft}>
Save draft
</button>
</ExampleCard>
)
}
const UpdateExamples = () => {
const createUpdatableToast = () => {
const toastId = toast.info('Import started', {
@ -292,6 +334,7 @@ const ToastDocsDemo = () => {
<StackExamples />
<PromiseExamples />
<ActionExamples />
<DeduplicateExamples />
<UpdateExamples />
</div>
</div>

View File

@ -166,12 +166,12 @@ function ToastCard({
'after:pointer-events-auto after:absolute after:top-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-[\'\']',
)}
>
<div className="relative overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div className="relative h-full overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
<div
aria-hidden="true"
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
/>
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<BaseToast.Content className="relative flex h-full items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
<div className="flex shrink-0 items-center justify-center p-0.5">
<ToastIcon type={toastType} />
</div>