mirror of https://github.com/langgenius/dify.git
feat(web): add base ui toast (#33569)
This commit is contained in:
parent
3db1ba36e0
commit
7c6d0bedc0
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ const Toast = ({
|
|||
return (
|
||||
<div className={cn(
|
||||
className,
|
||||
'fixed z-[9999] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
// Keep legacy toast above highPriority modals until overlay migration completes.
|
||||
'fixed z-[1101] mx-8 my-4 w-[360px] grow overflow-hidden rounded-xl',
|
||||
'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
|
||||
'top-0',
|
||||
'right-0',
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
// During migration, z-[1002] is chosen to sit above all legacy overlays
|
||||
// (Modal z-[60], PortalToFollowElem callers up to z-[1001]).
|
||||
// Once all legacy overlays are migrated, this can be reduced back to z-50.
|
||||
// Toast — z-[9999], always on top (defined in toast component)
|
||||
// Toast uses z-[1101] during migration so it stays above legacy highPriority modals.
|
||||
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import * as React from 'react'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { toast, ToastHost } from '../index'
|
||||
|
||||
describe('base/ui/toast', () => {
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost limit={1} />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost timeout={3000} />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
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(<ToastHost />)
|
||||
|
||||
let resolvePromise: ((value: string) => void) | undefined
|
||||
const promise = new Promise<string>((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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 (
|
||||
<section className={cardClassName}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold leading-6 text-text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm leading-6 text-text-secondary">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ExampleCard
|
||||
eyebrow="Variants"
|
||||
title="Tone-specific notifications"
|
||||
description="Trigger the four supported tones from the shared viewport to validate iconography, gradient treatment, and copy density."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('success')}>
|
||||
Success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('info')}>
|
||||
Info
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('warning')}>
|
||||
Warning
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={() => createVariantToast('error')}>
|
||||
Error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ExampleCard
|
||||
eyebrow="Stack"
|
||||
title="Stacked viewport behavior"
|
||||
description="These examples mirror the Base UI docs pattern: repeated triggers should compress into a single shared stack at the top-right corner."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createStack}>
|
||||
Create 3 stacked toasts
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createBurst}>
|
||||
Stress the stack
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const PromiseExamples = () => {
|
||||
const createPromiseToast = () => {
|
||||
const request = new Promise<string>((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<string>((_, 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 (
|
||||
<ExampleCard
|
||||
eyebrow="Promise"
|
||||
title="Async lifecycle"
|
||||
description="The promise helper should swap the same toast through loading, success, and error states instead of growing the stack unnecessarily."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createPromiseToast}>
|
||||
Promise success
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createRejectingPromiseToast}>
|
||||
Promise error
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ExampleCard
|
||||
eyebrow="Action"
|
||||
title="Actionable toasts"
|
||||
description="Use these to verify the secondary action button, multi-line content, and the close affordance under tighter layouts."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createActionToast}>
|
||||
Undo action
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={createLongCopyToast}>
|
||||
Long content
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<ExampleCard
|
||||
eyebrow="Update"
|
||||
title="Programmatic lifecycle"
|
||||
description="This example exercises manual updates on an existing toast and keeps a clear-all control nearby for repeated interaction during review."
|
||||
>
|
||||
<button type="button" className={buttonClassName} onClick={createUpdatableToast}>
|
||||
Add then update
|
||||
</button>
|
||||
<button type="button" className={buttonClassName} onClick={clearAll}>
|
||||
Clear all
|
||||
</button>
|
||||
</ExampleCard>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastDocsDemo = () => {
|
||||
return (
|
||||
<>
|
||||
<ToastHost timeout={5000} limit={5} />
|
||||
<div className="min-h-screen bg-background-default-subtle px-6 py-12">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
Base UI toast docs
|
||||
</div>
|
||||
<h2 className="text-[24px] font-semibold leading-8 text-text-primary">
|
||||
Shared stacked toast examples
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-text-secondary">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<VariantExamples />
|
||||
<StackExamples />
|
||||
<PromiseExamples />
|
||||
<ActionExamples />
|
||||
<UpdateExamples />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof ToastHost>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const DocsPattern: Story = {
|
||||
render: () => <ToastDocsDemo />,
|
||||
}
|
||||
|
|
@ -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<string, never>
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
type ToastAddOptions = Omit<ToastManagerAddOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastUpdateOptions = Omit<ToastManagerUpdateOptions<ToastData>, 'data' | 'positionerProps' | 'type'> & {
|
||||
type?: ToastType
|
||||
}
|
||||
|
||||
type ToastPromiseOptions<Value> = {
|
||||
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<ToastData>()
|
||||
|
||||
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<Value>(promiseValue: Promise<Value>, options: ToastPromiseOptions<Value>) {
|
||||
return toastManager.promise(promiseValue, options as ToastManagerPromiseOptions<Value, ToastData>)
|
||||
},
|
||||
}
|
||||
|
||||
function ToastIcon({ type }: { type?: string }) {
|
||||
if (type === 'success') {
|
||||
return <span aria-hidden="true" className="i-ri-checkbox-circle-fill h-5 w-5 text-text-success" />
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return <span aria-hidden="true" className="i-ri-error-warning-fill h-5 w-5 text-text-destructive" />
|
||||
}
|
||||
|
||||
if (type === 'warning') {
|
||||
return <span aria-hidden="true" className="i-ri-alert-fill h-5 w-5 text-text-warning-secondary" />
|
||||
}
|
||||
|
||||
if (type === 'info') {
|
||||
return <span aria-hidden="true" className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getToneGradientClasses(type?: string) {
|
||||
if (type === 'success')
|
||||
return 'from-components-badge-status-light-success-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'error')
|
||||
return 'from-components-badge-status-light-error-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'warning')
|
||||
return 'from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent'
|
||||
|
||||
if (type === 'info')
|
||||
return 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent'
|
||||
|
||||
return 'from-background-default-subtle to-background-gradient-mask-transparent'
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
toast: toastItem,
|
||||
showHoverBridge = false,
|
||||
}: {
|
||||
toast: ToastObject<ToastData>
|
||||
showHoverBridge?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
return (
|
||||
<BaseToast.Root
|
||||
toast={toastItem}
|
||||
className={cn(
|
||||
'pointer-events-auto absolute right-0 top-0 w-[360px] max-w-[calc(100vw-2rem)] origin-top-right cursor-default select-none outline-none',
|
||||
'[--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))]',
|
||||
'[height:var(--toast-current-height)] [z-index:calc(100-var(--toast-index))]',
|
||||
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
|
||||
'translate-x-[var(--toast-swipe-movement-x)] translate-y-[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]:translate-x-[var(--toast-swipe-movement-x)] data-[expanded]:translate-y-[calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*8px))] data-[expanded]:scale-100 data-[expanded]:[height:var(--toast-height)]',
|
||||
'data-[limited]:pointer-events-none data-[ending-style]:translate-y-[calc(var(--toast-swipe-movement-y)-150%)] data-[starting-style]:-translate-y-[150%] data-[ending-style]:opacity-0 data-[limited]:opacity-0 data-[starting-style]:opacity-0',
|
||||
)}
|
||||
>
|
||||
<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
|
||||
aria-hidden="true"
|
||||
className={cn('absolute inset-[-1px] bg-gradient-to-r opacity-40', getToneGradientClasses(toastItem.type))}
|
||||
/>
|
||||
<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">
|
||||
<div className="flex shrink-0 items-center justify-center p-0.5">
|
||||
<ToastIcon type={toastItem.type} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 p-1">
|
||||
<div className="flex w-full items-center gap-1">
|
||||
{toastItem.title != null && (
|
||||
<BaseToast.Title className="break-words text-text-primary system-sm-semibold">
|
||||
{toastItem.title}
|
||||
</BaseToast.Title>
|
||||
)}
|
||||
</div>
|
||||
{toastItem.description != null && (
|
||||
<BaseToast.Description className="mt-1 break-words text-text-secondary system-xs-regular">
|
||||
{toastItem.description}
|
||||
</BaseToast.Description>
|
||||
)}
|
||||
{toastItem.actionProps && (
|
||||
<div className="flex w-full items-start gap-1 pb-1 pt-2">
|
||||
<BaseToast.Action
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center overflow-hidden rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px] system-sm-medium',
|
||||
'hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center rounded-md p-0.5">
|
||||
<BaseToast.Close
|
||||
aria-label={t('toast.close')}
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-components-input-border-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</BaseToast.Close>
|
||||
</div>
|
||||
</BaseToast.Content>
|
||||
</div>
|
||||
{showHoverBridge && (
|
||||
<div aria-hidden="true" className="absolute inset-x-0 -bottom-2 h-2" />
|
||||
)}
|
||||
</BaseToast.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastViewport() {
|
||||
const { t } = useTranslation('common')
|
||||
const { toasts } = BaseToast.useToastManager<ToastData>()
|
||||
|
||||
return (
|
||||
<BaseToast.Viewport
|
||||
aria-label={t('toast.notifications')}
|
||||
className={cn(
|
||||
// During overlay migration, toast must stay above legacy highPriority modals (z-[1100]).
|
||||
'group/toast-viewport pointer-events-none fixed inset-0 z-[1101] overflow-visible',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-4 top-4 w-[360px] max-w-[calc(100vw-2rem)] sm:right-8',
|
||||
)}
|
||||
>
|
||||
{toasts.map((toastItem, index) => (
|
||||
<ToastCard
|
||||
key={toastItem.id}
|
||||
toast={toastItem}
|
||||
showHoverBridge={index < toasts.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BaseToast.Viewport>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastHost({
|
||||
timeout,
|
||||
limit,
|
||||
}: ToastHostProps) {
|
||||
return (
|
||||
<BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}>
|
||||
<BaseToast.Portal>
|
||||
<ToastViewport />
|
||||
</BaseToast.Portal>
|
||||
</BaseToast.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ import {
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { submitHumanInputForm } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Toast from '../../base/toast'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
} from '../hooks'
|
||||
|
|
@ -210,7 +210,7 @@ const WorkflowPreview = () => {
|
|||
copy(content)
|
||||
else
|
||||
copy(JSON.stringify(content))
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
toast.add({ type: 'success', title: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { TanstackQueryInitializer } from '@/context/query-client'
|
|||
import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { ToastProvider } from './components/base/toast'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
|
|
@ -70,6 +71,7 @@ const LocaleLayout = async ({
|
|||
<SentryInitializer>
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ This document tracks the migration away from legacy overlay APIs.
|
|||
- `@/app/components/base/popover`
|
||||
- `@/app/components/base/dropdown`
|
||||
- `@/app/components/base/dialog`
|
||||
- `@/app/components/base/toast` (including `context`)
|
||||
- Replacement primitives:
|
||||
- `@/app/components/base/ui/tooltip`
|
||||
- `@/app/components/base/ui/dropdown-menu`
|
||||
|
|
@ -21,6 +22,7 @@ This document tracks the migration away from legacy overlay APIs.
|
|||
- `@/app/components/base/ui/dialog`
|
||||
- `@/app/components/base/ui/alert-dialog`
|
||||
- `@/app/components/base/ui/select`
|
||||
- `@/app/components/base/ui/toast`
|
||||
- Tracking issue: https://github.com/langgenius/dify/issues/32767
|
||||
|
||||
## ESLint policy
|
||||
|
|
@ -42,6 +44,13 @@ This document tracks the migration away from legacy overlay APIs.
|
|||
- Remove remaining allowlist entries.
|
||||
- Remove legacy overlay implementations when import count reaches zero.
|
||||
|
||||
## Toast migration strategy
|
||||
|
||||
- During migration, `@/app/components/base/toast` and `@/app/components/base/ui/toast` may coexist.
|
||||
- All new toast usage must go through `@/app/components/base/ui/toast`.
|
||||
- When a file with legacy toast usage is touched, prefer migrating that call site in the same change; full-repo toast cleanup is not required in one PR.
|
||||
- `@/app/components/base/ui/toast` is the design-system stack toast host. Legacy `ToastContext`, `ToastProvider`, anchored toast behavior, and ad-hoc mount patterns stay in `base/toast` until their call sites are migrated away.
|
||||
|
||||
## Allowlist maintenance
|
||||
|
||||
- After each migration batch, run:
|
||||
|
|
@ -55,7 +64,8 @@ pnpm -C web lint:fix --prune-suppressions <changed-files>
|
|||
|
||||
## z-index strategy
|
||||
|
||||
All new overlay primitives in `base/ui/` share a single z-index value: **`z-[1002]`**.
|
||||
All new overlay primitives in `base/ui/` share a single z-index value:
|
||||
**`z-[1002]`**, except Toast which stays at **`z-[1101]`** during migration.
|
||||
|
||||
### Why z-[1002]?
|
||||
|
||||
|
|
@ -69,13 +79,17 @@ portal to `document.body` with explicit z-index values:
|
|||
| Legacy PortalToFollowElem callers | up to `z-[1001]` | various business components |
|
||||
| **New UI primitives** | **`z-[1002]`** | `base/ui/*` (Popover, Dialog, Tooltip, etc.) |
|
||||
| Legacy Modal (highPriority) | `z-[1100]` | `base/modal` (`highPriority={true}`) |
|
||||
| Toast | `z-[9999]` | `base/toast` |
|
||||
| Toast (legacy + new) | `z-[1101]` | `base/toast`, `base/ui/toast` |
|
||||
|
||||
`z-[1002]` sits above all common legacy overlays, so new primitives always
|
||||
render on top without needing per-call-site z-index hacks. Among themselves,
|
||||
new primitives share the same z-index and rely on **DOM order** for stacking
|
||||
(later portal = on top).
|
||||
|
||||
Toast intentionally stays one layer above the remaining legacy `highPriority`
|
||||
modal path (`z-[1100]`) so notifications keep their current visibility without
|
||||
falling back to `z-[9999]`.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do NOT add z-index overrides** (e.g. `className="z-[1003]"`) on new
|
||||
|
|
@ -91,7 +105,7 @@ new primitives share the same z-index and rely on **DOM order** for stacking
|
|||
Once all legacy overlays are removed:
|
||||
|
||||
1. Reduce `z-[1002]` back to `z-50` across all `base/ui/` primitives.
|
||||
1. Reduce Toast from `z-[9999]` to `z-[99]`.
|
||||
1. Reduce Toast from `z-[1101]` to `z-[51]`.
|
||||
1. Remove this section from the migration guide.
|
||||
|
||||
## React Refresh policy for base UI primitives
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -85,6 +85,15 @@ const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
|
|||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.',
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'**/base/toast',
|
||||
'**/base/toast/index',
|
||||
'**/base/toast/context',
|
||||
'**/base/toast/context/index',
|
||||
],
|
||||
message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.',
|
||||
},
|
||||
]
|
||||
|
||||
export default antfu(
|
||||
|
|
|
|||
|
|
@ -589,6 +589,8 @@
|
|||
"theme.dark": "dark",
|
||||
"theme.light": "light",
|
||||
"theme.theme": "Theme",
|
||||
"toast.close": "Dismiss notification",
|
||||
"toast.notifications": "Notifications",
|
||||
"unit.char": "chars",
|
||||
"userProfile.about": "About",
|
||||
"userProfile.community": "Community",
|
||||
|
|
|
|||
Loading…
Reference in New Issue