From 8680d114d5070a5a9f484eb6494b868bf077daeb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:43:30 +0000 Subject: [PATCH 1/4] fix(app-shell): toast actions are clickable under an open Radix modal, and the click stays the toast's (objectui#11723) While the record drawer (or any Radix modal) was open, a toast's Undo and close buttons were dead and the click went through to whatever lay under the toast. Two mechanisms, two answers, both in ConsoleToaster: - Radix sets pointer-events: none on body; the toaster inherited it. Each toast now carries pointer-events-auto (the toast, not the toaster, so a pointer beside a toast does not pause objectui#7482's dismiss timer). - A toast that takes the click is still outside the modal, so Radix's document-level outside-pointerdown closed the drawer. A toast's pointerdown now stops at the toaster. e2e/toast-under-modal.spec.ts pins it in Chromium with the real toaster, the real NavigationOverlay drawer and real Tailwind. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- e2e/toast-under-modal.spec.ts | 456 ++++++++++++++++++ .../app-shell/src/chrome/ConsoleToaster.tsx | 155 +++--- 2 files changed, 554 insertions(+), 57 deletions(-) create mode 100644 e2e/toast-under-modal.spec.ts diff --git a/e2e/toast-under-modal.spec.ts b/e2e/toast-under-modal.spec.ts new file mode 100644 index 0000000000..659ac7eb1f --- /dev/null +++ b/e2e/toast-under-modal.spec.ts @@ -0,0 +1,456 @@ +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 drawer + * host `NavigationOverlay` in drawer mode with the props `ObjectView` gives it, + * and through it the Shadcn `Sheet` 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 drawer'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'), +]; + +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 openDrawer = () => {}; + +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); + openDrawer = () => setOpen(true); + return h(ThemeProvider, null, + h('main', { className: 'p-6', onClick: record('page') }, button('New', 'new')), + h(NavigationOverlay, { + mode: 'drawer', + 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: () => openDrawer(), + 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 }; + /** What `ObjectView` passes: `overlayWidthFor(...)`, `min(92vw, px)`. */ + drawerWidth: string; + /** objectui#11685's path for this window and drawer. */ + path: 'beside' | 'below header'; +} + +const CONFIGURATIONS: Configuration[] = [ + { name: '1440 sm drawer', viewport: { width: 1440, height: 900 }, drawerWidth: 'min(92vw, 480px)', path: 'beside' }, + { name: '1024 md drawer', viewport: { width: 1024, height: 768 }, drawerWidth: 'min(92vw, 720px)', path: 'below header' }, + { name: '390 phone', viewport: { width: 390, height: 844 }, drawerWidth: 'min(92vw, 480px)', path: 'below header' }, +]; + +interface Point { + x: number; + y: number; +} + +async function openDrawer(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('drawer 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 drawerIsOpen = (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, + ); + +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 'drawer'; + 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); + +test.describe('toast actions under an open record drawer (objectui#11723)', () => { + for (const configuration of CONFIGURATIONS) { + test(`${configuration.name}: Undo is the click target, runs, and leaves the drawer open`, async ({ page }) => { + await openDrawer(page, configuration); + const reading = await raise(page, 'Approved'); + + 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((what) => what === 'overlay' || what === 'drawer'), `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 drawerIsOpen(page), 'the drawer 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 openDrawer(page, configuration); + const reading = await raise(page, 'Saved'); + + 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 drawerIsOpen(page)).toBe(true); + }); + + test(`${configuration.name}: a click on the toast reaches nothing under it`, async ({ page }) => { + await openDrawer(page, configuration); + const reading = await raise(page, 'Updated'); + + const hit = await hitAt(page, reading.body); + expect(hit.top, 'the hit test on the toast body').toBe('toast'); + expect(hit.beneath.some((what) => what === 'overlay' || what === 'drawer'), `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 drawerIsOpen(page)).toBe(true); + expect(await toastShowing(page, 'Updated')).toBe(true); + }); + } + + test(`${CONFIGURATIONS[1].name}: the dismiss timer still runs with the pointer resting just outside the toast (objectui#7482)`, async ({ + page, + }) => { + // objectui#7482: sonner pauses dismissal while the pointer is inside the + // toaster, so a pointer resting on a control the toaster covered kept the + // toast up for good. Only the toast itself takes the pointer: a pointer + // resting beside it, here over the record's own first row, is not inside. + const configuration = CONFIGURATIONS[1]; + await openDrawer(page, configuration); + const reading = await raise(page, 'Approved'); + const beside = { x: reading.box.left - 8, y: (reading.box.top + reading.box.bottom) / 2 }; + expect((await hitAt(page, beside)).top, 'the point beside the toast').toBe('drawer'); + 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, 'Approved'), { timeout: 6_000 }).toBe(false); + }); +}); 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} + /> +
); } From 0d54ff2e964e743182409625be66a6986717d81f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:44:49 +0000 Subject: [PATCH 2/4] test(app-shell): pin that a toast press under the record drawer is not an outside click (objectui#11723) Dispatched-event pin in the unit lane, against the real NavigationOverlay drawer: Undo runs and the drawer stays open, close dismisses only the toast, a press on the toast body reaches no outside-click handler, with a control showing the drawer's outside-click listener is armed. The hit-test half is pinned in Chromium by e2e/toast-under-modal.spec.ts. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../ConsoleToaster.underModal-11723.test.tsx | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 packages/app-shell/src/chrome/ConsoleToaster.underModal-11723.test.tsx 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'); + }); +}); From 06ca27912824bf138765e00879dd08a3e4469038 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:49:26 +0000 Subject: [PATCH 3/4] test(e2e): cover the centred record dialog, read each configuration's clearance path back, and give the objectui#7482 leg its control (objectui#11723) The timer leg was green on the base tree too, so it could not show that sonner's pause is observable in the harness at all. It now holds a toast past its 4s with the pointer on it before showing the timer runs with the pointer beside it. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- e2e/toast-under-modal.spec.ts | 119 ++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 40 deletions(-) diff --git a/e2e/toast-under-modal.spec.ts b/e2e/toast-under-modal.spec.ts index 659ac7eb1f..457aecef19 100644 --- a/e2e/toast-under-modal.spec.ts +++ b/e2e/toast-under-modal.spec.ts @@ -39,10 +39,12 @@ import { fileURLToPath } from 'node:url'; * ## 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 drawer - * host `NavigationOverlay` in drawer mode with the props `ObjectView` gives it, - * and through it the Shadcn `Sheet` on `@radix-ui/react-dialog`, sonner itself, - * React. The stylesheet is real Tailwind, compiled from the installed + * (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: @@ -52,7 +54,7 @@ import { fileURLToPath } from 'node:url'; * - `@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 drawer's content is a stand-in for `RecordDetailView`: the record + * - 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. @@ -94,6 +96,7 @@ 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'; @@ -133,7 +136,7 @@ import { NavigationOverlay } from ${at(NAVIGATION_OVERLAY)}; const h = React.createElement; const log = []; const record = (what) => () => log.push(what); -let openDrawer = () => {}; +let openOverlay = () => {}; const button = (label, what) => h('button', { type: 'button', className: 'h-9 px-4 rounded-md border', onClick: record(what) }, label); @@ -153,11 +156,11 @@ function RecordContent() { function Harness() { const [open, setOpen] = React.useState(false); - openDrawer = () => setOpen(true); + openOverlay = () => setOpen(true); return h(ThemeProvider, null, h('main', { className: 'p-6', onClick: record('page') }, button('New', 'new')), h(NavigationOverlay, { - mode: 'drawer', + mode: window.__overlayMode, isOverlay: true, isOpen: open, selectedRecord: open ? { id: 'widget-42' } : null, @@ -175,7 +178,7 @@ function Harness() { window.__harness = { log, - open: () => openDrawer(), + open: () => openOverlay(), raise: (title) => toast.success(title, { action: { label: 'Undo', onClick: record('undo:' + title) } }), }; createRoot(document.getElementById('root')).render(h(Harness)); @@ -267,24 +270,33 @@ test.beforeAll(async () => { interface Configuration { name: string; viewport: { width: number; height: number }; - /** What `ObjectView` passes: `overlayWidthFor(...)`, `min(92vw, px)`. */ + /** `NavigationOverlay`'s mode: the record drawer, or the centred record dialog. */ + mode: 'drawer' | 'modal'; + /** What `ObjectView` passes: `overlayWidthFor(...)`, `min(92vw, BUCKET px)`. */ drawerWidth: string; - /** objectui#11685's path for this window and drawer. */ - path: 'beside' | 'below header'; + /** + * 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 }, drawerWidth: 'min(92vw, 480px)', path: 'beside' }, - { name: '1024 md drawer', viewport: { width: 1024, height: 768 }, drawerWidth: 'min(92vw, 720px)', path: 'below header' }, - { name: '390 phone', viewport: { width: 390, height: 844 }, drawerWidth: 'min(92vw, 480px)', path: 'below header' }, + { 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 openDrawer(page: Page, configuration: Configuration) { +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)); @@ -310,19 +322,20 @@ async function openDrawer(page: Page, configuration: Configuration) { 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('drawer open', page.waitForSelector('[role="dialog"][data-state="open"]', { timeout: 10_000 })); + 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 drawerIsOpen = (page: Page) => +const overlayIsOpen = (page: Page) => page.evaluate(() => document.querySelector('[role="dialog"][data-state="open"]') !== null); const toastShowing = (page: Page, title: string) => @@ -331,6 +344,16 @@ const toastShowing = (page: Page, title: string) => 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; @@ -384,7 +407,7 @@ const hitAt = (page: Page, point: Point) => 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 'drawer'; + if (el.closest('[role="dialog"]')) return 'modal'; if (el.matches('[data-state][class*="bg-black"]')) return 'overlay'; return el.tagName.toLowerCase(); }; @@ -392,65 +415,81 @@ const hitAt = (page: Page, point: Point) => return { top: describe(stack[0] ?? null), beneath: stack.slice(1).map(describe) }; }, point); -test.describe('toast actions under an open record drawer (objectui#11723)', () => { +/** 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 drawer open`, async ({ page }) => { - await openDrawer(page, configuration); + 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((what) => what === 'overlay' || what === 'drawer'), `beneath Undo: ${hit.beneath}`).toBe(true); + 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 drawerIsOpen(page), 'the drawer stays open').toBe(true); + 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 openDrawer(page, configuration); + 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 drawerIsOpen(page)).toBe(true); + expect(await overlayIsOpen(page)).toBe(true); }); test(`${configuration.name}: a click on the toast reaches nothing under it`, async ({ page }) => { - await openDrawer(page, configuration); + 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((what) => what === 'overlay' || what === 'drawer'), `beneath the toast: ${hit.beneath}`).toBe(true); + 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 drawerIsOpen(page)).toBe(true); + expect(await overlayIsOpen(page)).toBe(true); expect(await toastShowing(page, 'Updated')).toBe(true); }); } - test(`${CONFIGURATIONS[1].name}: the dismiss timer still runs with the pointer resting just outside the toast (objectui#7482)`, async ({ + 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 pointer resting on a control the toaster covered kept the - // toast up for good. Only the toast itself takes the pointer: a pointer - // resting beside it, here over the record's own first row, is not inside. - const configuration = CONFIGURATIONS[1]; - await openDrawer(page, configuration); - const reading = await raise(page, 'Approved'); - const beside = { x: reading.box.left - 8, y: (reading.box.top + reading.box.bottom) / 2 }; - expect((await hitAt(page, beside)).top, 'the point beside the toast').toBe('drawer'); + // 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, 'Approved'), { timeout: 6_000 }).toBe(false); + await expect.poll(() => toastShowing(page, 'Held'), { timeout: 6_000 }).toBe(false); + expect(await log(page)).toEqual([]); }); }); From e46b9a959f186e40d9bb879f2fb343e0af838399 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:53:31 +0000 Subject: [PATCH 4/4] chore(changeset): app-shell patch for toast clicks under an open modal (objectui#11723) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11723-toast-clicks-under-modal.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/11723-toast-clicks-under-modal.md 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.