Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions frontend/src/components/notification.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { flushSync } from 'react-dom'
import { UNSTABLE_ToastQueue as ToastQueue } from 'react-aria-components/Toast'
import { UNSTABLE_ToastQueue as ToastQueue, type QueuedToast } from 'react-aria-components/Toast'

export type ToastType = 'success' | 'error' | 'info' | 'warning'

interface ToastMessage {
export interface ToastMessage {
message: string
type: ToastType
description?: string
Expand All @@ -15,12 +15,49 @@ interface NotificationOptions {
duration?: number
}

// While a bottom sheet is open on a phone, index.css hides each toast but the newest. A hidden toast must not time out.
class ShownToastQueue<T> extends ToastQueue<T> {
// A toast has no entry until it renders. React Aria starts its timer when it mounts.
readonly #visibility = new WeakMap<QueuedToast<T>, 'shown' | 'hidden'>()
#isPaused = false

markHidden(toast: QueuedToast<T>): void {
this.#visibility.set(toast, 'hidden')
toast.timer?.pause()
}

markShown(toast: QueuedToast<T>): void {
const wasHidden = this.#visibility.get(toast) === 'hidden'
this.#visibility.set(toast, 'shown')
if (!wasHidden || !toast.timer || !toast.timeout) return
// React Aria starts the timer again when a toast mounts again, and reset does not clear a running timer.
toast.timer.pause()
toast.timer.reset(toast.timeout)
if (this.#isPaused) toast.timer.pause()
}

override pauseAll(): void {
this.#isPaused = true
super.pauseAll()
}

override resumeAll(): void {
this.#isPaused = false
for (const toast of this.visibleToasts) {
if (this.#visibility.get(toast) !== 'shown' || !toast.timer) continue
// A toast that mounts while the toasts pause starts its timer anyway, and resume does not clear a running timer.
toast.timer.pause()
toast.timer.resume()
}
}
}

/**
* The queue of toasts that `Toaster` shows. Each update runs in a view
* transition, so the toasts animate in and out. With reduced motion, the
* toasts show and close without an animation.
*/
export const toastQueue = new ToastQueue<ToastMessage>({
export const toastQueue = new ShownToastQueue<ToastMessage>({
maxVisibleToasts: 5,
wrapUpdate(fn) {
if (
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/components/toaster.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ beforeEach(() => {
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
toastQueue.clear()
})
Expand All @@ -32,6 +33,18 @@ async function animationsFinished(): Promise<void> {
await Promise.all(document.getAnimations().map((animation) => animation.finished))
}

// A toast pauses or restarts its timer in a ResizeObserver callback. The browser runs all the callbacks of one rendering
// step in one task, so the test goes on after all of them.
async function resizeObserverCallbacks(): Promise<void> {
await new Promise<void>((resolve) => {
const observer = new ResizeObserver(() => {
observer.disconnect()
resolve()
})
observer.observe(document.body)
})
}

function boxOf(text: string): DOMRect {
return page.getByRole('alertdialog').filter({ hasText: text }).element().getBoundingClientRect()
}
Expand Down Expand Up @@ -132,3 +145,32 @@ test.each(Object.entries(bottomPanels))(
expect(boxOf('Could not save the changes').bottom).toBe(window.innerHeight - 16)
},
)

test('on a phone on its side, a toast that came while the pointer was on the toasts keeps waiting when a newer toast hides it', async () => {
await page.viewport(667, 375)
await render(
<>
<OpenPanel panel={bottomPanels['bottom sheet']} />
<Toaster />
</>,
)
await expect.element(page.getByRole('dialog', { name: 'Edit filter' })).toBeVisible()
// Only the test moves the clock of the toast timers, so a slow browser does not use up their time.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
Notification.info('Upload started', { duration: 20_000 })
await expect.element(page.getByText('Upload started')).toBeVisible()
await animationsFinished()

await userEvent.hover(page.getByText('Upload started'))
Notification.error('Could not save the changes', { duration: 10_000 })
await expect.element(page.getByText('Could not save the changes')).toBeVisible()
await userEvent.hover(page.getByRole('heading', { name: 'Edit filter' }))
Notification.info('Upload finished', { duration: 20_000 })
await expect.element(page.getByText('Upload finished')).toBeVisible()
await animationsFinished()
await resizeObserverCallbacks()
vi.advanceTimersByTime(10_500)
await animationsFinished()

expect(page.getByRole('alertdialog').filter({ hasText: 'Could not save the changes' }).query()).not.toBeNull()
})
20 changes: 19 additions & 1 deletion frontend/src/components/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {
UNSTABLE_Toast as Toast,
UNSTABLE_ToastContent as ToastContent,
UNSTABLE_ToastRegion as ToastRegion,
type QueuedToast,
} from 'react-aria-components/Toast'
import { CheckmarkCircle02Icon, AlertCircleIcon, InformationCircleIcon, Alert02Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
import { HugeiconsIcon } from '@hugeicons/react'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { toastQueue } from '@/components/notification'
import { toastQueue, type ToastMessage } from '@/components/notification'

// Icons for each toast type
const icons = {
Expand All @@ -30,6 +31,22 @@ const iconColors = {
warning: 'text-chart-3', // orange from chart colors
} as const

// The size of a toast that index.css hides visually (sr-only).
const hiddenToastSizePx = 1

function pauseWhileHidden(toast: QueuedToast<ToastMessage>) {
return (element: HTMLElement | null) => {
if (!element) return
const observer = new ResizeObserver(([entry]) => {
const [{ inlineSize, blockSize }] = entry.borderBoxSize
if (inlineSize > hiddenToastSizePx && blockSize > hiddenToastSizePx) toastQueue.markShown(toast)
else toastQueue.markHidden(toast)
})
observer.observe(element)
return () => observer.disconnect()
}
}

/**
* The region that shows the toast notifications, styled with the theme of the app.
* main.tsx renders it once, at the root of the app.
Expand All @@ -55,6 +72,7 @@ export function Toaster() {
{({ toast }) => (
<Toast
toast={toast}
ref={pauseWhileHidden(toast)}
data-slot="toast"
style={{ viewTransitionName: toast.key, viewTransitionClass: 'toast' } as CSSProperties}
className={cn(
Expand Down
2 changes: 2 additions & 0 deletions frontend/tests/features/toasts/toasts.locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const toastDemo = (page: Page) => ({
showToast: (name: ToastButton) => page.getByRole('button', { name, exact: true }),
openDialog: page.getByRole('button', { name: 'Open Dialog', exact: true }),
dialog: page.getByRole('dialog', { name: 'Dialog Title' }),
dialogTitle: page.getByRole('dialog', { name: 'Dialog Title' }).getByRole('heading', { name: 'Dialog Title' }),
closeDialog: page.getByRole('dialog', { name: 'Dialog Title' }).getByRole('button', { name: 'Continue' }),
region: page.getByRole('region', { name: /notification/ }),
toasts: page.getByRole('alertdialog'),
toast: (message: string) => page.getByRole('alertdialog').filter({ hasText: message }),
Expand Down
58 changes: 58 additions & 0 deletions frontend/tests/features/toasts/toasts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,61 @@ test.describe('the toasts on a phone on its side while a bottom sheet is open',
expect(await demo.shownToasts()).toEqual(['A new version is available', 'Something went wrong'])
})
})

// The success toast closes after 5 s, and the error toast after 6 s.
test.describe('the toasts that a bottom sheet hides on a phone on its side', () => {
test.use({ viewport: { width: 667, height: 375 } })

test('show one after the other, each for its full time', async ({ page }) => {
const demo = toastDemoPage(page)
await demo.controlTheClock()
await demo.open()
await demo.showToastsByKeyboard('Success', 'Error', 'Info')

await demo.openBottomSheet()
const shown = await demo.shownToastsAsTimePasses(5_000, 5_000, 1_500)

expect(shown).toEqual([
{ afterMs: 0, toasts: ['A new version is available'] },
{ afterMs: 5_000, toasts: ['Something went wrong'] },
{ afterMs: 10_000, toasts: ['Something went wrong'] },
{ afterMs: 11_500, toasts: ['Changes saved'] },
])
})

test('keep waiting after the focus leaves the toasts', async ({ page }) => {
const demo = toastDemoPage(page)
await demo.controlTheClock()
await demo.open()
await demo.showToastsByKeyboard('Success', 'Error', 'Info')

await demo.openBottomSheet()
await demo.moveFocusToToasts()
await demo.pressTab(3)
await demo.moveFocusToBottomSheet()
const shown = await demo.shownToastsAsTimePasses(5_000, 6_500)

expect(shown).toEqual([
{ afterMs: 0, toasts: ['A new version is available'] },
{ afterMs: 5_000, toasts: ['Something went wrong'] },
{ afterMs: 11_500, toasts: ['Changes saved'] },
])
})

test('all show again when the sheet closes, so an error that waited still shows', async ({ page }) => {
const demo = toastDemoPage(page)
await demo.controlTheClock()
await demo.open()
await demo.showToastsByKeyboard('Error', 'Success')

await demo.openBottomSheet()
await demo.letTimePass(2_000)
await demo.closeBottomSheet()
const shown = await demo.shownToastsAsTimePasses(5_000)

expect(shown).toEqual([
{ afterMs: 0, toasts: ['Changes saved', 'Something went wrong'] },
{ afterMs: 5_000, toasts: ['Something went wrong'] },
])
})
})
49 changes: 49 additions & 0 deletions frontend/tests/features/toasts/toasts.tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,35 @@ import { expect, type Locator, type Page } from '@playwright/test'
import { takesUpSpace } from '@tests/support/screen'
import { toastDemo, type ToastButton } from './toasts.locators'

// The toasts pause and restart their timers in ResizeObserver callbacks. The browser runs all the callbacks of one
// rendering step in one task, so the next command of the test comes after all of them.
async function nextResizeObserverCallbacks(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
const observer = new ResizeObserver(() => {
observer.disconnect()
resolve()
})
observer.observe(document.body)
}),
)
}

export function toastDemoPage(page: Page) {
const at = toastDemo(page)

return {
at,

// The clock stands still from the start, so only letTimePass moves the timers of the toasts. Real time that a slow
// browser takes does not count. The animations wait too, so a sheet that opens stays transparent until time passes.
async controlTheClock() {
const start = new Date('2026-01-05T09:00:00Z')
await page.clock.install({ time: start })
await page.clock.pauseAt(start.getTime() + 60_000)
},

async open() {
await page.goto('/components')
await expect(at.heading).toBeVisible()
Expand All @@ -21,6 +44,23 @@ export function toastDemoPage(page: Page) {
async openBottomSheet() {
await at.openDialog.press('Enter')
await expect(at.dialog).toBeVisible()
await nextResizeObserverCallbacks(page)
},

async closeBottomSheet() {
await at.closeDialog.press('Enter')
await expect(at.dialog).toBeHidden()
await nextResizeObserverCallbacks(page)
},

async moveFocusToBottomSheet() {
await at.dialogTitle.click()
await expect(at.dialog).toBeFocused()
},

async letTimePass(ms: number) {
await page.clock.runFor(ms)
await nextResizeObserverCallbacks(page)
},

async moveFocusToToasts() {
Expand All @@ -40,6 +80,15 @@ export function toastDemoPage(page: Page) {
return shown
},

async shownToastsAsTimePasses(...stepsMs: number[]) {
const samples = [{ afterMs: 0, toasts: await this.shownToasts() }]
for (const ms of stepsMs) {
await this.letTimePass(ms)
samples.push({ afterMs: samples[samples.length - 1].afterMs + ms, toasts: await this.shownToasts() })
}
return samples
},

async hasFocusRing(element: Locator) {
return element.evaluate((node) => {
const { outlineStyle, outlineWidth, boxShadow } = getComputedStyle(node)
Expand Down
Loading