diff --git a/frontend/src/components/notification.ts b/frontend/src/components/notification.ts index 82002f76..2c6173ed 100644 --- a/frontend/src/components/notification.ts +++ b/frontend/src/components/notification.ts @@ -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 @@ -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 extends ToastQueue { + // A toast has no entry until it renders. React Aria starts its timer when it mounts. + readonly #visibility = new WeakMap, 'shown' | 'hidden'>() + #isPaused = false + + markHidden(toast: QueuedToast): void { + this.#visibility.set(toast, 'hidden') + toast.timer?.pause() + } + + markShown(toast: QueuedToast): 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({ +export const toastQueue = new ShownToastQueue({ maxVisibleToasts: 5, wrapUpdate(fn) { if ( diff --git a/frontend/src/components/toaster.test.tsx b/frontend/src/components/toaster.test.tsx index e32107ee..dba63f15 100644 --- a/frontend/src/components/toaster.test.tsx +++ b/frontend/src/components/toaster.test.tsx @@ -21,6 +21,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() toastQueue.clear() }) @@ -32,6 +33,18 @@ async function animationsFinished(): Promise { 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 { + await new Promise((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() } @@ -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( + <> + + + , + ) + 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() +}) diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx index 9da6f566..ed9d8fd9 100644 --- a/frontend/src/components/toaster.tsx +++ b/frontend/src/components/toaster.tsx @@ -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 = { @@ -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) { + 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. @@ -55,6 +72,7 @@ export function Toaster() { {({ toast }) => ( ({ 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 }), diff --git a/frontend/tests/features/toasts/toasts.spec.ts b/frontend/tests/features/toasts/toasts.spec.ts index 288ec0a2..3b4a9823 100644 --- a/frontend/tests/features/toasts/toasts.spec.ts +++ b/frontend/tests/features/toasts/toasts.spec.ts @@ -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'] }, + ]) + }) +}) diff --git a/frontend/tests/features/toasts/toasts.tasks.ts b/frontend/tests/features/toasts/toasts.tasks.ts index 35033d1b..0dedb8d3 100644 --- a/frontend/tests/features/toasts/toasts.tasks.ts +++ b/frontend/tests/features/toasts/toasts.tasks.ts @@ -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((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() @@ -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() { @@ -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)