diff --git a/.changeset/11723-toast-clicks-under-modal.md b/.changeset/11723-toast-clicks-under-modal.md new file mode 100644 index 0000000000..f0436461f9 --- /dev/null +++ b/.changeset/11723-toast-clicks-under-modal.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +--- + +A console toast's Undo and close buttons work while a drawer or dialog is open, and a click on a toast no longer reaches what lies under it (objectui#11723). + +Creating a record or running a record action such as Approve raises a success toast with an Undo button, often while the record drawer is still open. The drawer, like every Radix modal, turns pointer events off for the rest of the page, the toaster included, so the toast could not be clicked. The click went through it: onto the drawer's overlay, which closed the drawer and left the action in place; onto the drawer's expand or close button; or onto a record action under the toast. + +`ConsoleToaster` now makes each toast take its own clicks while a modal is open, and a press on a toast no longer counts as a click outside the modal: + +- With the record drawer open, Undo runs and the drawer stays open, and the close button dismisses the toast and nothing else. The same holds under a centred dialog: the change is in the toaster, not in any one modal. +- A click anywhere on a toast stays on the toast. +- Only the toasts take the pointer, not the area around them, so the dismiss timer still runs while the pointer rests beside a toast (objectui#7482). As anywhere else, a toast pauses while the pointer is on it. + +Nothing is added to the package entry: no export, prop, type member or language-pack key. diff --git a/e2e/toast-under-modal.spec.ts b/e2e/toast-under-modal.spec.ts new file mode 100644 index 0000000000..457aecef19 --- /dev/null +++ b/e2e/toast-under-modal.spec.ts @@ -0,0 +1,495 @@ +import { test, expect, type Page } from '@playwright/test'; +import { compile } from 'tailwindcss'; +import { build, type Plugin, type Rollup } from 'vite'; +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * objectui#11723: a toast's Undo and close buttons were dead while the record + * drawer that raised them was open, and the click went through the toast to + * whatever lay under it. + * + * ## The defect, two mechanisms + * + * 1. A Radix modal sets `pointer-events: none` on `` and turns pointer + * events back on for its own layer only. The sonner toaster inherits the + * `none`, so the browser's hit test skips every toast: a click on Undo landed + * on the drawer's overlay (which closes the drawer), on the drawer header's + * expand and close buttons, or on a record action such as Approve. + * 2. Once a toast does take the click, the drawer's outside-click handling still + * sees a `pointerdown` outside its own content, so the same click that runs + * Undo also closes the drawer. + * + * `ConsoleToaster` answers both, for every Radix layer at once: visible toasts + * take pointer events, and a `pointerdown` inside the toaster stops at the + * toaster, so no document-level outside-click handler sees it. + * + * ## Why a Playwright spec and not a vitest test + * + * Mechanism 1 is a hit-test question, and jsdom and happy-dom have no hit + * testing: `elementFromPoint` there answers nothing about which element a real + * click reaches. This file clicks with the real pointer in Chromium, at the + * toast's real position, the reason `e2e/record-header-title-width.spec.ts` and + * `e2e/sidebar-offcanvas-geometry.spec.ts` give for themselves. Mechanism 2 is + * also pinned without layout, through dispatched events, in + * `packages/app-shell/src/chrome/ConsoleToaster.underModal-11723.test.tsx`. + * + * ## What is real here and what is a fixture + * + * REAL, built from source at run time and running in the page: `ConsoleToaster` + * (with its objectui#11685 drawer clearance), `ThemeProvider`, the record + * overlay host `NavigationOverlay` with the props `ObjectView` gives it, and + * through it the Shadcn `Sheet` (drawer mode: both objectui#11685 paths, beside + * the drawer and below its header) and `Dialog` (modal mode: the centred + * dialog, toaster in its own corner), both on `@radix-ui/react-dialog`; sonner + * itself; React. The stylesheet is real Tailwind, compiled from the installed + * `tailwindcss` for the class strings in those source files. + * + * FIXTURES, declared so nobody reads more into a green run than it says: + * + * - `@object-ui/i18n` is a stub that returns each key's English default. It + * is on no hit-test path; only labels go through it. + * - `@object-ui/react` is a stub exporting a `SchemaRenderer` that renders + * nothing. The components' `lib/utils` imports it for its slot helpers, + * which the drawer does not call. + * - The overlay's content is a stand-in for `RecordDetailView`: the record + * title and two `record_header` actions in its first row, then filler. + * Every control in it logs its clicks, so a click that falls through a toast + * onto the record is caught. + * - Toasts are raised by calling sonner's `toast.success` with an `Undo` + * action, the shape `RecordDetailView` and `useConsoleActionRuntime` raise. + * - Theme colour tokens are not compiled (the console's theme stylesheet is + * not loaded), so surfaces are unpainted. Colour is on no hit-test path. + * - The page asks for reduced motion, which sonner honours by dropping its + * transitions, so every position is read at rest. + * + * Workspace packages are not imported by name: this lane builds no workspace + * `dist/`, and `scripts/__tests__/e2e-type-check.test.ts` pins that no spec + * under `e2e/` imports one. + */ + +const REPO_ROOT = (() => { + // Rooted on this file, never on `process.cwd()` (AGENTS.md: tests read the + // filesystem relative to their own file). Bare `import.meta.url`, walked up. + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (let i = 0; i < 10; i++) { + try { + readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf8'); + return dir; + } catch { + dir = path.dirname(dir); + } + } + throw new Error('could not locate the repo root from ' + import.meta.url); +})(); + +const source = (rel: string) => path.join(REPO_ROOT, rel); + +const CONSOLE_TOASTER = source('packages/app-shell/src/chrome/ConsoleToaster.tsx'); +const THEME_PROVIDER = source('packages/app-shell/src/chrome/ThemeProvider.tsx'); +const NAVIGATION_OVERLAY = source('packages/components/src/custom/navigation-overlay.tsx'); + +/** The source files whose class strings the page draws with. */ +const STYLED_SOURCES = [ + CONSOLE_TOASTER, + NAVIGATION_OVERLAY, + source('packages/components/src/ui/sheet.tsx'), + source('packages/components/src/ui/dialog.tsx'), +]; + +const STUB_I18N = '\0toast-under-modal:i18n'; +const STUB_REACT = '\0toast-under-modal:react'; +const ENTRY = '\0toast-under-modal:entry'; + +/** Classes the stand-in record content uses; compiled with the sources above. */ +const HARNESS_CLASSES = 'flex items-center justify-between gap-4 gap-2 px-6 py-4 p-6 py-2 h-9 px-4 rounded-md border border-b text-xl font-semibold'; + +/** The stub and the entry described in the header, as one Vite plugin. */ +function fixtures(): Plugin { + const at = (file: string) => JSON.stringify(file); + const modules: Record = { + [STUB_I18N]: ` +const fallback = (key, options) => + options && typeof options.defaultValue === 'string' ? options.defaultValue : key; +export const useObjectTranslation = () => ({ t: fallback }); +export const createSafeTranslation = (defaults) => () => ({ + t: (key, options) => { + let text = defaults[key] ?? fallback(key, options); + for (const [name, value] of Object.entries(options ?? {})) { + text = text.split('{{' + name + '}}').join(String(value)); + } + return text; + }, +}); +`, + [STUB_REACT]: 'export const SchemaRenderer = () => null;\n', + [ENTRY]: ` +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { toast } from 'sonner'; +import { ConsoleToaster } from ${at(CONSOLE_TOASTER)}; +import { ThemeProvider } from ${at(THEME_PROVIDER)}; +import { NavigationOverlay } from ${at(NAVIGATION_OVERLAY)}; + +const h = React.createElement; +const log = []; +const record = (what) => () => log.push(what); +let openOverlay = () => {}; + +const button = (label, what) => + h('button', { type: 'button', className: 'h-9 px-4 rounded-md border', onClick: record(what) }, label); + +/** RecordDetailView's first row (title, record_header actions), then filler. */ +function RecordContent() { + return h('div', { onClick: record('record') }, + h('div', { className: 'flex items-center justify-between gap-4 px-6 py-4 border-b' }, + h('h1', { className: 'text-xl font-semibold' }, 'Widget 42'), + h('div', { className: 'flex items-center gap-2' }, button('Approve', 'approve'), button('Reject', 'reject')), + ), + h('div', { className: 'p-6' }, + ...Array.from({ length: 16 }, (_, i) => h('p', { key: i, className: 'py-2' }, 'Field ' + i)), + ), + ); +} + +function Harness() { + const [open, setOpen] = React.useState(false); + openOverlay = () => setOpen(true); + return h(ThemeProvider, null, + h('main', { className: 'p-6', onClick: record('page') }, button('New', 'new')), + h(NavigationOverlay, { + mode: window.__overlayMode, + isOverlay: true, + isOpen: open, + selectedRecord: open ? { id: 'widget-42' } : null, + close: () => { log.push('drawer:close'); setOpen(false); }, + setIsOpen: (next) => { if (!next) log.push('drawer:close'); setOpen(next); }, + width: window.__drawerWidth, + title: 'Product', + onExpand: record('expand'), + expandLabel: 'Open as full page', + children: () => h(RecordContent), + }), + h(ConsoleToaster), + ); +} + +window.__harness = { + log, + open: () => openOverlay(), + raise: (title) => toast.success(title, { action: { label: 'Undo', onClick: record('undo:' + title) } }), +}; +createRoot(document.getElementById('root')).render(h(Harness)); +`, + }; + return { + name: 'toast-under-modal-fixtures', + enforce: 'pre', + async resolveId(id, importer) { + if (id === '@object-ui/i18n') return STUB_I18N; + if (id === '@object-ui/react') return STUB_REACT; + if (id === 'toast-under-modal:entry') return ENTRY; + // The entry's bare imports resolve exactly as `ConsoleToaster`'s own do, + // so `toast` and the toaster share one sonner module: its queue is + // module state, and a second copy would raise toasts nobody renders. + if (importer === ENTRY && !path.isAbsolute(id)) { + return this.resolve(id, CONSOLE_TOASTER, { skipSelf: true }); + } + return null; + }, + load: (id) => modules[id] ?? null, + }; +} + +/** Bundle the entry once, from source, into one classic script. */ +async function bundle(): Promise { + const result = await build({ + configFile: false, + root: REPO_ROOT, + // Out of the tree: this run must leave nothing behind in the checkout. + cacheDir: path.join(os.tmpdir(), 'objectui-toast-under-modal'), + logLevel: 'error', + publicDir: false, + plugins: [fixtures()], + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + write: false, + outDir: path.join(os.tmpdir(), 'objectui-toast-under-modal-out'), + emptyOutDir: false, + minify: false, + modulePreload: false, + // Not `lib`: lib mode resolves its entry as a file path, before any + // plugin can answer for a virtual id. + rollupOptions: { + input: 'toast-under-modal:entry', + output: { format: 'iife', inlineDynamicImports: true }, + }, + }, + }); + const outputs = (Array.isArray(result) ? result : [result]) as Rollup.RollupOutput[]; + const chunks = outputs.flatMap((o) => o.output).filter((o): o is Rollup.OutputChunk => o.type === 'chunk'); + if (chunks.length !== 1) throw new Error(`expected one bundled chunk, got ${chunks.length}`); + return chunks[0].code; +} + +async function tailwind(): Promise { + const twEntry = fileURLToPath(import.meta.resolve('tailwindcss/index.css')); + const compiler = await compile('@import "tailwindcss";', { + base: path.dirname(twEntry), + loadStylesheet: async (id: string, base: string) => { + const file = id === 'tailwindcss' ? twEntry : path.resolve(base, id); + return { path: file, base: path.dirname(file), content: readFileSync(file, 'utf8') }; + }, + }); + // Candidates are every token in the sources, the way Tailwind's own scanner + // reads them; tokens that are not classes compile to nothing. + const tokens = [...STYLED_SOURCES.map((f) => readFileSync(f, 'utf8')), HARNESS_CLASSES] + .flatMap((text) => text.split(/[\s'"`]+/)) + .filter(Boolean); + const css = compiler.build(tokens); + // A stylesheet missing the drawer's own layout would measure an unpositioned + // tree, and the toaster could then pass for the wrong reason. + for (const needle of ['.fixed', '.inset-y-0', '.right-0', '.z-50', '--ov-w']) { + if (!css.includes(needle)) throw new Error(`Tailwind emitted no rule for ${needle}; the drawer would be unstyled.`); + } + return css; +} + +/** Never resolved by DNS: every request to it is answered by `page.route`. */ +const HARNESS_ORIGIN = 'http://toast-under-modal.test'; + +let script = ''; +let css = ''; + +test.beforeAll(async () => { + [script, css] = await Promise.all([bundle(), tailwind()]); +}); + +interface Configuration { + name: string; + viewport: { width: number; height: number }; + /** `NavigationOverlay`'s mode: the record drawer, or the centred record dialog. */ + mode: 'drawer' | 'modal'; + /** What `ObjectView` passes: `overlayWidthFor(...)`, `min(92vw, BUCKET px)`. */ + drawerWidth: string; + /** + * Where objectui#11685 puts the toaster for this window and overlay: beside + * the drawer, below its header, or (no right-edge drawer) its own corner. + * Read back off the toaster in every test, so a configuration that stopped + * exercising its path fails instead of silently testing another one. + */ + path: 'beside' | 'below header' | 'corner'; +} + +const CONFIGURATIONS: Configuration[] = [ + { name: '1440 sm drawer', viewport: { width: 1440, height: 900 }, mode: 'drawer', drawerWidth: 'min(92vw, 480px)', path: 'beside' }, + { name: '1024 md drawer', viewport: { width: 1024, height: 768 }, mode: 'drawer', drawerWidth: 'min(92vw, 720px)', path: 'below header' }, + { name: '390 phone drawer', viewport: { width: 390, height: 844 }, mode: 'drawer', drawerWidth: 'min(92vw, 480px)', path: 'below header' }, + { name: '1440 centred dialog', viewport: { width: 1440, height: 900 }, mode: 'modal', drawerWidth: 'min(92vw, 720px)', path: 'corner' }, +]; +const BELOW_HEADER = CONFIGURATIONS[1]; + +interface Point { + x: number; + y: number; +} + +async function openOverlay(page: Page, configuration: Configuration) { + // A harness that never comes up must say why, not run into the test timeout. + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + const ready = async (what: string, wait: Promise) => { + try { + await wait; + } catch (cause) { + throw new Error(`the harness did not reach "${what}"; page errors: ${JSON.stringify(errors)}`, { cause }); + } + }; + await page.setViewportSize(configuration.viewport); + await page.emulateMedia({ reducedMotion: 'reduce' }); + // A real origin, answered by the route below and never by the network: + // `ThemeProvider` reads `localStorage`, which an `about:blank` document + // (what `setContent` gives) is denied. + await page.route(`${HARNESS_ORIGIN}/**`, (route) => { + const url = new URL(route.request().url()); + if (url.pathname === '/harness.js') return route.fulfill({ contentType: 'text/javascript', body: script }); + return route.fulfill({ + contentType: 'text/html', + body: + `` + + `` + + `
`, + }); + }); + await page.goto(`${HARNESS_ORIGIN}/`); + await ready('mounted', page.waitForFunction(() => '__harness' in window, null, { timeout: 10_000 })); + await page.evaluate(() => (window as unknown as { __harness: { open(): void } }).__harness.open()); + await ready('overlay open', page.waitForSelector('[role="dialog"][data-state="open"]', { timeout: 10_000 })); +} + +const log = (page: Page) => page.evaluate(() => [...(window as unknown as { __harness: { log: string[] } }).__harness.log]); + +const overlayIsOpen = (page: Page) => + page.evaluate(() => document.querySelector('[role="dialog"][data-state="open"]') !== null); + +const toastShowing = (page: Page, title: string) => + page.evaluate( + (t) => [...document.querySelectorAll('[data-sonner-toast]')].some((li) => li.textContent?.includes(t)), + title, + ); + +/** Which objectui#11685 path the toaster took, read off the offsets it was given. */ +const pathOf = (page: Page) => + page.evaluate(() => { + const toaster = document.querySelector('[data-sonner-toaster]'); + if (!toaster) return 'no toaster'; + if (toaster.style.getPropertyValue('--offset-right') !== '24px') return 'beside'; + if (toaster.style.getPropertyValue('--offset-top') !== '24px') return 'below header'; + return 'corner'; + }); + +interface ToastReading { + box: { left: number; top: number; right: number; bottom: number }; + action: Point; + close: Point; + /** A point on the toast's title text, clear of both buttons. */ + body: Point; +} + +/** Raise a toast and read it once it is at rest. */ +async function raise(page: Page, title: string): Promise { + await page.evaluate((t) => (window as unknown as { __harness: { raise(t: string): void } }).__harness.raise(t), title); + return page.waitForFunction( + (t) => { + const li = [...document.querySelectorAll('[data-sonner-toast][data-mounted="true"]')].find((el) => + el.textContent?.includes(t), + ); + if (!li) return null; + const r = li.getBoundingClientRect(); + const w = window as unknown as { __rest?: string; __restFrames?: number }; + const key = `${t}:${r.left},${r.top},${r.width},${r.height}`; + w.__restFrames = w.__rest === key ? (w.__restFrames ?? 0) + 1 : 0; + w.__rest = key; + // Unmoved over three frames: the drawer clearance and the stack have settled. + if (w.__restFrames < 3) return null; + const centre = (el: Element) => { + const b = el.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; + }; + const action = li.querySelector('[data-action]'); + const close = li.querySelector('[data-close-button]'); + const titleEl = li.querySelector('[data-title]'); + if (!action || !close || !titleEl) return null; + const tb = titleEl.getBoundingClientRect(); + return { + box: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + action: centre(action), + close: centre(close), + body: { x: tb.left + Math.min(12, tb.width / 2), y: tb.top + tb.height / 2 }, + }; + }, + title, + { polling: 'raf', timeout: 10_000 }, + ).then((handle) => handle.jsonValue() as Promise); +} + +/** What the real hit test returns at a point: the topmost element, then the stack. */ +const hitAt = (page: Page, point: Point) => + page.evaluate(({ x, y }) => { + const describe = (el: Element | null) => { + if (!el) return 'nothing'; + if (el.closest('[data-action]')) return 'toast action'; + if (el.closest('[data-close-button]')) return 'toast close'; + if (el.closest('[data-sonner-toast]')) return 'toast'; + if (el.closest('[role="dialog"]')) return 'modal'; + if (el.matches('[data-state][class*="bg-black"]')) return 'overlay'; + return el.tagName.toLowerCase(); + }; + const stack = document.elementsFromPoint(x, y); + return { top: describe(stack[0] ?? null), beneath: stack.slice(1).map(describe) }; + }, point); + +/** The modal's own surfaces: what a click falling through a toast reaches. */ +const isModalSurface = (what: string) => what === 'overlay' || what === 'modal'; + +test.describe('toast actions under an open Radix modal (objectui#11723)', () => { + for (const configuration of CONFIGURATIONS) { + test(`${configuration.name}: Undo is the click target, runs, and leaves the modal open`, async ({ page }) => { + await openOverlay(page, configuration); + const reading = await raise(page, 'Approved'); + expect(await pathOf(page), 'the objectui#11685 path this configuration exercises').toBe(configuration.path); + + const hit = await hitAt(page, reading.action); + expect(hit.top, 'the hit test at the Undo button').toBe('toast action'); + // Non-vacuity: the toast sits over something a stray click would reach. + expect(hit.beneath.some(isModalSurface), `beneath Undo: ${hit.beneath}`).toBe(true); + + await page.mouse.click(reading.action.x, reading.action.y); + await expect.poll(() => log(page)).toEqual(['undo:Approved']); + expect(await overlayIsOpen(page), 'the modal stays open').toBe(true); + await expect.poll(() => toastShowing(page, 'Approved')).toBe(false); + }); + + test(`${configuration.name}: the close button dismisses the toast, and only the toast`, async ({ page }) => { + await openOverlay(page, configuration); + const reading = await raise(page, 'Saved'); + expect(await pathOf(page)).toBe(configuration.path); + + expect((await hitAt(page, reading.close)).top, 'the hit test at the close button').toBe('toast close'); + await page.mouse.click(reading.close.x, reading.close.y); + await expect.poll(() => toastShowing(page, 'Saved')).toBe(false); + expect(await log(page)).toEqual([]); + expect(await overlayIsOpen(page)).toBe(true); + }); + + test(`${configuration.name}: a click on the toast reaches nothing under it`, async ({ page }) => { + await openOverlay(page, configuration); + const reading = await raise(page, 'Updated'); + expect(await pathOf(page)).toBe(configuration.path); + + const hit = await hitAt(page, reading.body); + expect(hit.top, 'the hit test on the toast body').toBe('toast'); + expect(hit.beneath.some(isModalSurface), `beneath the toast: ${hit.beneath}`).toBe(true); + + await page.mouse.click(reading.body.x, reading.body.y); + // Give a fall-through click every chance to land before reading the log. + await page.waitForTimeout(250); + expect(await log(page)).toEqual([]); + expect(await overlayIsOpen(page)).toBe(true); + expect(await toastShowing(page, 'Updated')).toBe(true); + }); + } + + test(`${BELOW_HEADER.name}: the dismiss timer pauses only while the pointer is on the toast, not beside it (objectui#7482)`, async ({ + page, + }) => { + // objectui#7482: sonner pauses dismissal while the pointer is inside the + // toaster, so a toaster over a control the pointer rests on stays up for + // good. Only the toast takes the pointer now, not the toaster around it. + // The below-header path is the one where a toast covers the record's own + // first row, so it is the path where that matters. + await openOverlay(page, BELOW_HEADER); + + // The control: on the toast, the pause is real and observable here, so the + // second half's dismissal is not just a timer that never pauses. + const held = await raise(page, 'Held'); + expect(await pathOf(page)).toBe(BELOW_HEADER.path); + await page.mouse.move(held.body.x, held.body.y); + await page.waitForTimeout(5_000); + expect(await toastShowing(page, 'Held'), 'past its 4s while the pointer is on it').toBe(true); + + // Beside the toast, over the record's first row: the timer runs. + const beside = { x: held.box.left - 8, y: (held.box.top + held.box.bottom) / 2 }; + expect((await hitAt(page, beside)).top, 'the point beside the toast').toBe('modal'); + await page.mouse.move(beside.x, beside.y); + // Sonner's 4s default (objectui#7482 pins it), plus its unmount delay. + await expect.poll(() => toastShowing(page, 'Held'), { timeout: 6_000 }).toBe(false); + expect(await log(page)).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/chrome/ConsoleToaster.tsx b/packages/app-shell/src/chrome/ConsoleToaster.tsx index ade5562728..d76d47836c 100644 --- a/packages/app-shell/src/chrome/ConsoleToaster.tsx +++ b/packages/app-shell/src/chrome/ConsoleToaster.tsx @@ -131,69 +131,110 @@ function useDrawerClearance(): DrawerClearance | null { return clearance; } +/** + * objectui#11723 — a toast stays clickable, and its click stays its own, while + * a Radix modal (the record drawer, a dialog) is open. + * + * Two mechanisms made Undo and × dead under a modal; each needs its own answer. + * + * - A modal sets `pointer-events: none` on `` and turns pointer events + * back on for its own layer only. The toaster inherited the `none`, so the + * hit test skipped every toast and the click landed on whatever lay under + * it: the overlay, which closes the drawer, or a drawer control. Each toast + * now carries `pointer-events-auto` (`toastOptions.classNames.toast` below). + * The toast only, not the toaster: a pointer resting beside a toast is still + * not inside the toaster, so objectui#7482's dismiss timer keeps running. A + * toast hidden beyond `visibleToasts` keeps sonner's own + * `pointer-events: none`, which is unlayered CSS and so outranks the utility. + * - A toast that takes the click is still outside the modal's content, and + * Radix closes a modal on a `pointerdown` outside it, heard by a listener on + * `document`. The `pointerdown` now stops at the toaster, so no + * document-level outside-click handler hears it: not the drawer's, not a + * dialog's, not an open popover's. React delegates at its root, so the stop + * runs after sonner's own handlers (swipe, and the timer pause while a toast + * is pressed) and before the event can reach `document`. + * + * Not Radix's `DismissableLayerBranch`, the answer Radix's own Toast uses: a + * branch registers in the module state of `@radix-ui/react-dismissable-layer`, + * which `@radix-ui/react-dialog` pins to an exact version. A copy imported here + * registers in a different module whenever the two resolve differently, and + * the branch then silently does nothing. Not each modal's `onInteractOutside` + * either: every modal host would have to know about the toaster, and one that + * forgot would close on Undo. + */ +const stopAtToaster = (event: React.PointerEvent) => event.stopPropagation(); + export function ConsoleToaster(props: ToasterProps) { const { theme = 'system' } = useTheme(); const { t } = useObjectTranslation(); const clearance = useDrawerClearance(); return ( - , - info: , - warning: , - error: , - loading: , - }} - toastOptions={{ - // 4s default keeps actionable toasts visible long enough to - // click an Undo button without feeling sticky. objectui#7482 asked for - // 3–5s on success toasts and this already sits in that band; it is - // pinned in `__tests__/ConsoleToaster.autoDismiss-7482` because - // nothing checked it, and a success toast that outlives its own - // information is what that card was reported as. + // objectui#11723 — `contents`: the wrapper draws no box, it only bounds + // where a toast's `pointerdown` stops (see `stopAtToaster`). +
+ + // objectui#7482 — `top-right` is load-bearing, not cosmetic. The + // bottom-right corner belongs to the ChatDock's composer and the FAB + // that launches it (ADR-0057 P3a/P3b), and a toaster anchored there does + // more than overlap them: sonner pauses a toast's dismiss timer while + // the pointer is inside the toaster region (`expanded || interacting || + // isDocumentHidden` in its Toast effect), so a pointer resting on the + // composer underneath keeps the toast on screen until the user clicks ×. + // Override the position only onto a corner nothing interactive occupies. + position="top-right" + // objectui#11685 — while a right-edge drawer is open, the same corner + // shifted clear of the drawer's chrome (see `useDrawerClearance`). + // `undefined` when none is open, which is sonner's own default. + offset={clearance?.offset} + mobileOffset={clearance?.mobileOffset} + closeButton + richColors + expand + visibleToasts={4} + containerAriaLabel={t('notifications.regionLabel', { defaultValue: 'Notifications' })} + icons={{ + success: , + info: , + warning: , + error: , + loading: , + }} + toastOptions={{ + // 4s default keeps actionable toasts visible long enough to + // click an Undo button without feeling sticky. objectui#7482 asked for + // 3–5s on success toasts and this already sits in that band; it is + // pinned in `__tests__/ConsoleToaster.autoDismiss-7482` because + // nothing checked it, and a success toast that outlives its own + // information is what that card was reported as. + // + // NOT split per intent ("errors may persist"): sonner has no per-type + // duration on `Toaster`, so the only way to say it is a `duration` at + // each of the ~100 `toast.error(...)` call sites. `closeButton` below + // already gives every toast a manual exit. + duration: 4000, + classNames: { + // objectui#11723 — `pointer-events-auto`: a toast takes its own + // clicks under an open Radix modal (see `stopAtToaster`). A plain + // utility on purpose: a `group-[…]:` form would outrank sonner's + // `pointer-events: none` on a hidden toast and make it clickable. + toast: + 'group toast pointer-events-auto group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + description: 'group-[.toast]:text-muted-foreground', + actionButton: + 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', + cancelButton: + 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground', + }, + }} + {...props} + /> +
); } diff --git a/packages/app-shell/src/chrome/ConsoleToaster.underModal-11723.test.tsx b/packages/app-shell/src/chrome/ConsoleToaster.underModal-11723.test.tsx new file mode 100644 index 0000000000..3532ca75a8 --- /dev/null +++ b/packages/app-shell/src/chrome/ConsoleToaster.underModal-11723.test.tsx @@ -0,0 +1,153 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11723 — with the record drawer open, a toast's Undo ran (once the + * toast could take the click at all) and the same click closed the drawer. + * + * The drawer is a Radix modal, and Radix closes a modal on a `pointerdown` + * outside its content, heard by a listener on `document`. A toast is outside + * the drawer's content, so every press on it read as an outside click. The fix + * stops a toast's `pointerdown` at `ConsoleToaster`, before it can reach + * `document`. This file pins that half with dispatched events and the real + * record drawer host (`NavigationOverlay` in drawer mode, on the Shadcn + * `Sheet`), so it runs in the unit lane. + * + * ⚠️ What it cannot pin is the other half: whether the click reaches the toast + * at all. That is a hit-test question (the modal sets `pointer-events: none` on + * ``), and happy-dom has no hit testing: an event dispatched at an + * element arrives there whatever its computed `pointer-events`. The real-pointer + * reading lives in `e2e/toast-under-modal.spec.ts`, in Chromium. The class + * assertion at the end is only the cheap echo of it. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, act, cleanup, screen, fireEvent, waitFor } from '@testing-library/react'; +import { toast } from 'sonner'; +import { NavigationOverlay } from '@object-ui/components'; +import { ConsoleToaster } from './ConsoleToaster.js'; +import { ThemeProvider } from './ThemeProvider.js'; + +const outside: HTMLElement[] = []; + +afterEach(() => { + act(() => { + toast.dismiss(); + }); + cleanup(); + for (const el of outside.splice(0)) el.remove(); +}); + +/** The record drawer, open, with the console toaster beside it. */ +async function openDrawer() { + const onClose = vi.fn(); + function Harness() { + const [open, setOpen] = React.useState(true); + return ( + + setOpen(false)} + setIsOpen={(next) => { + if (!next) onClose(); + setOpen(next); + }} + title="Product" + > + {() =>

Widget 42

} +
+ +
+ ); + } + render(); + await screen.findByRole('dialog'); + // Radix arms its document `pointerdown` listener on a zero-delay timer after + // the layer mounts; a press before that would prove nothing either way. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + return { onClose }; +} + +/** A press as the browser delivers it: down, up, then the click. */ +function press(el: Element) { + fireEvent.pointerDown(el, { button: 0, pointerId: 1, pointerType: 'mouse' }); + fireEvent.pointerUp(el, { button: 0, pointerId: 1, pointerType: 'mouse' }); + fireEvent.click(el, { button: 0 }); +} + +async function raiseWithUndo(title: string) { + const undo = vi.fn(); + act(() => { + toast.success(title, { action: { label: 'Undo', onClick: undo } }); + }); + await screen.findByText(title); + return { undo }; +} + +describe('a toast under the open record drawer keeps its click (objectui#11723)', () => { + it('the control: a press outside both the drawer and the toaster closes the drawer', async () => { + // Non-vacuity. Without it, "the drawer stayed open" below could only mean + // that no outside-click listener was armed in this environment at all. + const { onClose } = await openDrawer(); + const elsewhere = document.createElement('div'); + document.body.append(elsewhere); + outside.push(elsewhere); + press(elsewhere); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('Undo runs, and the drawer stays open', async () => { + const { onClose } = await openDrawer(); + const { undo } = await raiseWithUndo('Approved'); + press(screen.getByRole('button', { name: 'Undo' })); + expect(undo).toHaveBeenCalledTimes(1); + // Past the zero-delay timer Radix's deferred verdict waits on. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('the close button dismisses the toast, and the drawer stays open', async () => { + const { onClose } = await openDrawer(); + await raiseWithUndo('Saved'); + press(screen.getByRole('button', { name: 'Close toast' })); + await waitFor(() => expect(screen.queryByText('Saved')).not.toBeInTheDocument()); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('a press on the toast body reaches no outside-click handler', async () => { + const { onClose } = await openDrawer(); + const { undo } = await raiseWithUndo('Updated'); + press(screen.getByText('Updated')); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(undo).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('each toast asks for pointer events, the echo of the Chromium pin', async () => { + await openDrawer(); + await raiseWithUndo('Approved'); + const li = screen.getByText('Approved').closest('[data-sonner-toast]'); + // The plain utility, not a `group-[…]:` form: that one would outrank + // sonner's `pointer-events: none` on a toast hidden beyond `visibleToasts`. + expect(li).toHaveClass('pointer-events-auto'); + }); +});