From b0ffd360cf3dd54b85d9c52d0c2b7a4979b8494b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:21:00 +0000 Subject: [PATCH 1/2] fix(app-shell): narrow-window toast beside a right drawer clears the record's actions row The fallback for a window too narrow for a toast beside a right-edge drawer cleared only the drawer's own header, so on a 1024px window the create-success toast covered the record header's Edit and more buttons. It now also drops below the drawer's first row of controls, measured from layout offsets. Claude-Session: https://claude.ai/code/session_01ADHkmQQBJvECrfY7vVyetV Co-authored-by: Claude --- ...oleToaster.drawerActionsRow-26418.test.tsx | 196 ++++++++++++++++++ .../app-shell/src/chrome/ConsoleToaster.tsx | 78 ++++++- 2 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 ui/packages/app-shell/src/chrome/ConsoleToaster.drawerActionsRow-26418.test.tsx diff --git a/ui/packages/app-shell/src/chrome/ConsoleToaster.drawerActionsRow-26418.test.tsx b/ui/packages/app-shell/src/chrome/ConsoleToaster.drawerActionsRow-26418.test.tsx new file mode 100644 index 00000000000..7bd4d69575d --- /dev/null +++ b/ui/packages/app-shell/src/chrome/ConsoleToaster.drawerActionsRow-26418.test.tsx @@ -0,0 +1,196 @@ +/** + * 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. + */ + +/** + * objectstack#26418 — on a window too narrow for a toast beside the record + * drawer (1024px window, 720px drawer), the success toast cleared only the + * drawer's own header and landed on the record's Edit and ⋯ buttons right + * under it, for its whole 4s life. + * + * The fallback now drops below the drawer's first row of controls too. This + * file pins that: in the card's case the toast's rect stays off the actions + * row; the wide-window case keeps objectui#11685's beside-the-drawer + * placement; a row that renders after the drawer opens still moves the toast; + * and controls that are not a header row (hidden, or down in the drawer's + * lower half) do not. + * + * ⚠️ As in `ConsoleToaster.drawerClearance-11685`, happy-dom has no layout + * engine: every size here is assigned, not measured. The numbers are the + * card's Chromium reading of the record drawer at 1024×768: drawer header + * 0–45, Edit at 93–129, toast 54px tall. + */ +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, act, cleanup, waitFor } from '@testing-library/react'; +import { toast } from 'sonner'; +import { ConsoleToaster } from './ConsoleToaster.js'; +import { ThemeProvider } from './ThemeProvider.js'; + +const VIEWPORT_HEIGHT = 768; +const DRAWER_HEADER_HEIGHT = 45; +/** The record's actions row (Edit, ⋯), as the card measured it. */ +const ACTIONS_ROW = { top: 93, bottom: 129 }; +/** sonner's toast width, and the toast height the card measured. */ +const TOAST = { width: 356, height: 54 }; + +const RIGHT_SHEET = 'position: fixed; top: 0px; right: 0px; bottom: 0px;'; + +const mounted: HTMLElement[] = []; +const innerWidthDescriptor = Object.getOwnPropertyDescriptor(window, 'innerWidth'); + +function setViewportWidth(width: number) { + Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width }); +} + +function layout(el: HTMLElement, box: { offsetParent?: Element | null; offsetTop?: number; offsetHeight?: number }) { + for (const [key, value] of Object.entries(box)) Object.defineProperty(el, key, { configurable: true, value }); +} + +/** + * A right-edge drawer shaped like `NavigationOverlay`'s: a header block holding + * the `aria-labelledby` title and the expand/close buttons, then a scrolling + * body. The drawer is fixed, so it is every descendant's `offsetParent`. + */ +function mountDrawer(width: number): { drawer: HTMLElement; body: HTMLElement } { + const n = mounted.length; + const drawer = document.createElement('div'); + drawer.setAttribute('role', 'dialog'); + drawer.setAttribute('data-state', 'open'); + drawer.setAttribute('aria-labelledby', `drawer-title-${n}`); + drawer.setAttribute('style', RIGHT_SHEET); + const header = document.createElement('div'); + const title = document.createElement('h2'); + title.id = `drawer-title-${n}`; + title.textContent = 'Project'; + const close = document.createElement('button'); + close.textContent = 'Close'; + header.append(title, close); + const body = document.createElement('div'); + drawer.append(header, body); + layout(drawer, { offsetHeight: VIEWPORT_HEIGHT }); + Object.defineProperty(drawer, 'offsetWidth', { configurable: true, value: width }); + layout(header, { offsetTop: 0, offsetHeight: DRAWER_HEADER_HEIGHT }); + layout(close, { offsetParent: drawer, offsetTop: 8, offsetHeight: 28 }); + document.body.append(drawer); + mounted.push(drawer); + return { drawer, body }; +} + +/** + * The record's own header inside the drawer body: the title row with its + * small star button, and the actions row with Edit and ⋯, taller, beside it. + */ +function appendRecordHeader(drawer: HTMLElement, body: HTMLElement) { + const recordHeader = document.createElement('div'); + const star = document.createElement('button'); + star.setAttribute('aria-label', 'Add to favorites'); + const edit = document.createElement('button'); + edit.textContent = 'Edit'; + const more = document.createElement('button'); + more.setAttribute('aria-label', 'More'); + recordHeader.append(star, edit, more); + layout(star, { offsetParent: drawer, offsetTop: 97, offsetHeight: 24 }); + layout(edit, { offsetParent: drawer, offsetTop: ACTIONS_ROW.top, offsetHeight: ACTIONS_ROW.bottom - ACTIONS_ROW.top }); + layout(more, { offsetParent: drawer, offsetTop: ACTIONS_ROW.top, offsetHeight: ACTIONS_ROW.bottom - ACTIONS_ROW.top }); + body.append(recordHeader); +} + +async function renderWithToast(): Promise { + render( + + + , + ); + act(() => { + toast.success('Project created'); + }); + await waitFor(() => expect(document.querySelector('[data-sonner-toaster]')).toBeTruthy()); +} + +const region = (): HTMLElement => { + const el = document.querySelector('[data-sonner-toaster]'); + expect(el, 'sonner did not mount its toaster region').toBeTruthy(); + return el as HTMLElement; +}; + +const px = (name: string) => parseFloat(region().style.getPropertyValue(name)); + +/** The front toast's rect, from the top-right toaster's offsets. */ +function toastRect() { + const right = window.innerWidth - px('--offset-right'); + const top = px('--offset-top'); + return { left: right - TOAST.width, right, top, bottom: top + TOAST.height }; +} + +/** The actions row's rect: Edit and ⋯ sit at the drawer's right edge, inside its 16px padding. */ +function actionsRowRect() { + const right = window.innerWidth - 16; + return { left: right - 160, right, ...ACTIONS_ROW }; +} + +const intersects = (a: ReturnType, b: ReturnType) => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +afterEach(() => { + act(() => { + toast.dismiss(); + }); + cleanup(); + for (const el of mounted.splice(0)) el.remove(); + if (innerWidthDescriptor) Object.defineProperty(window, 'innerWidth', innerWidthDescriptor); +}); + +describe('ConsoleToaster clears the record header actions in a narrow window (objectstack#26418)', () => { + it('1024px window, 720px drawer: the toast stays off the Edit / ⋯ row', async () => { + setViewportWidth(1024); + await renderWithToast(); + const { drawer, body } = mountDrawer(720); + appendRecordHeader(drawer, body); + await waitFor(() => expect(region().getAttribute('data-x-position')).toBe('right')); + await waitFor(() => expect(px('--offset-top')).toBe(ACTIONS_ROW.bottom + 24)); + expect(intersects(toastRect(), actionsRowRect())).toBe(false); + // Phones span the toaster across the window: it drops below the row too. + expect(px('--mobile-offset-top')).toBe(ACTIONS_ROW.bottom + 16); + }); + + it('the control: a wide window keeps the toast in the strip beside the drawer', async () => { + setViewportWidth(1440); + await renderWithToast(); + const { drawer, body } = mountDrawer(864); + appendRecordHeader(drawer, body); + await waitFor(() => expect(px('--offset-right')).toBe(888)); + expect(px('--offset-top')).toBe(24); + expect(toastRect().right).toBeLessThanOrEqual(1440 - 864); + }); + + it('a record header that renders after the drawer opens still moves the toast', async () => { + setViewportWidth(1024); + await renderWithToast(); + const { drawer, body } = mountDrawer(720); + // Only the drawer's header so far: the toast sits just below it. + await waitFor(() => expect(px('--offset-top')).toBe(DRAWER_HEADER_HEIGHT + 24)); + appendRecordHeader(drawer, body); + await waitFor(() => expect(px('--offset-top')).toBe(ACTIONS_ROW.bottom + 24)); + }); + + it('a hidden control or one in the drawer`s lower half is not a header row', async () => { + setViewportWidth(1024); + await renderWithToast(); + const { drawer, body } = mountDrawer(720); + const hidden = document.createElement('button'); + layout(hidden, { offsetParent: null, offsetTop: 0, offsetHeight: 0 }); + const footer = document.createElement('button'); + layout(footer, { offsetParent: drawer, offsetTop: VIEWPORT_HEIGHT - 60, offsetHeight: 36 }); + body.append(hidden, footer); + await waitFor(() => expect(px('--offset-top')).toBe(DRAWER_HEADER_HEIGHT + 24)); + // Non-vacuity: the same drawer with a real header row moves the toast. + appendRecordHeader(drawer, body); + await waitFor(() => expect(px('--offset-top')).toBe(ACTIONS_ROW.bottom + 24)); + }); +}); diff --git a/ui/packages/app-shell/src/chrome/ConsoleToaster.tsx b/ui/packages/app-shell/src/chrome/ConsoleToaster.tsx index 252cf760b23..24ccab6cab7 100644 --- a/ui/packages/app-shell/src/chrome/ConsoleToaster.tsx +++ b/ui/packages/app-shell/src/chrome/ConsoleToaster.tsx @@ -50,7 +50,22 @@ const DEFAULT_OFFSET: ToasterProps['offset'] = { top: APP_HEADER_HEIGHT + APP_HE * no part of the drawer is covered. * - Otherwise (a phone, or a drawer nearly as wide as the window) it drops * below the drawer's header, the block that carries the dialog's title and - * its controls. + * its controls, and below the first row of controls under that header + * (objectstack#26418). In the record drawer that row is the record's own + * header: Edit, the header actions and the ⋯ menu. Clearing only the + * drawer's header put a 1024px window's toast straight onto Edit, the button + * a user reaches for right after creating a record. + * + * The row is measured, not assumed: the drawer's topmost visible control + * outside its header, widened by every control that overlaps it vertically + * (a title's star and copy buttons beside a taller Edit). Measuring keeps the + * offset right for any drawer content, without a pixel taken from one page. + * A first control in the drawer's lower half is body or footer content, not a + * header row, and dropping below it would push the toast off the window, so + * then only the header counts. Not the drawer's bottom-right corner instead: + * that is where the record's inline-edit Save / Cancel bar sticks + * (`InlineEditSaveBar`), and a failed save raises its toast exactly while that + * bar is up. * * "A right-edge drawer" is read off the DOM, not off a prop: an open Radix * dialog (`role="dialog"`, `data-state="open"`) laid out fixed against the @@ -58,7 +73,9 @@ const DEFAULT_OFFSET: ToasterProps['offset'] = { top: APP_HEADER_HEIGHT + APP_HE * matches, the record drawer included; popovers, centred dialogs, left and * bottom sheets do not. Its header is the dialog's top-level block that holds * its `aria-labelledby` title. Sizes are layout sizes (`offsetWidth`, - * `offsetTop`), so the drawer's slide-in transform does not skew them. + * `offsetTop`), so the drawer's slide-in transform does not skew them, and a + * control's `offsetTop` is where it sits with the drawer scrolled to the top: + * a toast raised mid-scroll still clears the row once the user scrolls back. */ const VIEWPORT_GAP = 24; // sonner's VIEWPORT_OFFSET const MOBILE_VIEWPORT_GAP = 16; // sonner's MOBILE_VIEWPORT_OFFSET @@ -87,21 +104,62 @@ function headerOf(drawer: HTMLElement): HTMLElement | null { return block; } +/** What counts as a control when finding the first row of them. */ +const CONTROLS = + 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [role="tab"]'; + +/** + * `el`'s layout top inside `drawer`, summed up its `offsetParent` chain; null + * when it is not laid out (`display: none` has no `offsetParent`) or its chain + * leaves the drawer (a nested fixed layer). + */ +function topWithin(drawer: HTMLElement, el: HTMLElement): number | null { + let top = 0; + for (let node = el; node !== drawer; ) { + top += node.offsetTop; + const parent = node.offsetParent; + if (!(parent instanceof HTMLElement) || !drawer.contains(parent)) return null; + node = parent; + } + return top; +} + +/** The bottom of the drawer's first row of controls below its header, or 0 when there is none. */ +function firstControlRowBottom(drawer: HTMLElement, header: HTMLElement | null): number { + const boxes: { top: number; bottom: number }[] = []; + for (const el of Array.from(drawer.querySelectorAll(CONTROLS))) { + if (header?.contains(el) || el.offsetHeight === 0) continue; + const top = topWithin(drawer, el); + if (top !== null) boxes.push({ top, bottom: top + el.offsetHeight }); + } + boxes.sort((a, b) => a.top - b.top); + const first = boxes[0]; + if (!first || first.top > drawer.offsetHeight / 2) return 0; + let bottom = first.bottom; + for (const box of boxes) { + if (box.top >= bottom) break; + bottom = Math.max(bottom, box.bottom); + } + return bottom; +} + function clearanceFor(drawers: HTMLElement[]): DrawerClearance | null { if (drawers.length === 0) return null; let width = 0; - let headerBottom = 0; + let clearBelow = 0; for (const drawer of drawers) { width = Math.max(width, drawer.offsetWidth); const header = headerOf(drawer); - if (header) headerBottom = Math.max(headerBottom, header.offsetTop + header.offsetHeight); + if (header) clearBelow = Math.max(clearBelow, header.offsetTop + header.offsetHeight); + clearBelow = Math.max(clearBelow, firstControlRowBottom(drawer, header)); } const fitsBeside = window.innerWidth - width >= TOAST_WIDTH + 2 * VIEWPORT_GAP; return { - offset: fitsBeside ? { right: width + VIEWPORT_GAP } : { top: headerBottom + VIEWPORT_GAP }, + offset: fitsBeside ? { right: width + VIEWPORT_GAP } : { top: clearBelow + VIEWPORT_GAP }, // Below 600px sonner spans the toaster across the viewport, so there is - // never a strip beside the drawer: always drop below its header. - mobileOffset: { top: headerBottom + MOBILE_VIEWPORT_GAP }, + // never a strip beside the drawer: always drop below its header and its + // first row of controls. + mobileOffset: { top: clearBelow + MOBILE_VIEWPORT_GAP }, }; } @@ -111,14 +169,19 @@ function useDrawerClearance(): DrawerClearance | null { useEffect(() => { let frame = 0; const sizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => schedule()); + const contents = new MutationObserver(() => schedule()); const measure = () => { frame = 0; const drawers = openRightDrawers(); sizes?.disconnect(); + contents.disconnect(); for (const drawer of drawers) { sizes?.observe(drawer); const header = headerOf(drawer); if (header) sizes?.observe(header); + // The record renders after the drawer opens (its header and actions + // arrive with the data), so the first control row can appear late. + contents.observe(drawer, { childList: true, subtree: true }); } const next = clearanceFor(drawers); // Compared by value: a fresh-but-equal object would re-render the @@ -141,6 +204,7 @@ function useDrawerClearance(): DrawerClearance | null { if (frame) window.cancelAnimationFrame(frame); portals.disconnect(); states.disconnect(); + contents.disconnect(); sizes?.disconnect(); window.removeEventListener('resize', schedule); }; From e8590370c2db5e08e25fcd590d9332176caa974c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:23:26 +0000 Subject: [PATCH 2/2] chore(changeset): app-shell patch for the narrow-window toast placement Claude-Session: https://claude.ai/code/session_01ADHkmQQBJvECrfY7vVyetV Co-authored-by: Claude --- .changeset/26418-toast-clears-drawer-actions-row.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/26418-toast-clears-drawer-actions-row.md diff --git a/.changeset/26418-toast-clears-drawer-actions-row.md b/.changeset/26418-toast-clears-drawer-actions-row.md new file mode 100644 index 00000000000..81bd60686c8 --- /dev/null +++ b/.changeset/26418-toast-clears-drawer-actions-row.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): on a narrow window, a toast raised beside the record drawer no longer covers the record's Edit and ⋯ buttons + +Clause-②: no +Metadata-protocol: none + +When a right-edge drawer is open and the window is too narrow for a toast beside it (a 720px record drawer on a 1024px window, a split screen, a phone), the console's toaster drops below the drawer's header. It cleared only the drawer's own header, so the success toast raised by creating a record sat on the record header's Edit button and ⋯ menu for its whole four seconds. The toast now also drops below the drawer's first row of controls, measured from the drawer's layout, so it clears the record's actions row for any drawer content. Wide windows keep the toast in the strip beside the drawer, as before. + +What to do: nothing.