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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/11685-toast-clears-drawer-chrome.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

The console's success toast no longer covers an open drawer's expand and close buttons and title (objectui#11685).

The toaster stays in the top-right corner (objectui#7482 moved it there, away from the assistant composer). That corner is also where every right-side drawer keeps its chrome, so a toast raised with the record drawer open, after creating a record or running a record action such as Approve, sat on the drawer's expand and close buttons, its title and the record's own header actions. While a right-edge drawer is open, `ConsoleToaster` now offsets the toaster instead:

- When the page left of the drawer is wide enough for a toast, the toaster moves into that strip, against the drawer's left edge. It covers no part of the drawer, and the strip is under the drawer's overlay, so no control sits there.
- When that strip is too narrow (a phone, or a drawer nearly as wide as the window), the toaster drops below the drawer's header.

When the drawer closes, the toaster returns to the corner. It follows the drawer when the window is resized or the drawer is dragged wider or narrower. Every `side="right"` sheet in the console gets the same treatment, the record drawer included. Centred dialogs, popovers, and left or bottom sheets leave the toaster where it is. An `offset` passed to `ConsoleToaster` by its caller still wins.

Nothing is added to the package entry: no export, prop, type member or language-pack key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* 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#11685 — with a drawer open, the success toast landed on the
* drawer's expand and close buttons and its title: the toaster's `top-right`
* corner (objectui#7482) is also where every right-side drawer keeps its
* chrome.
*
* The fix keeps the corner and offsets the toaster while a right-edge drawer is
* open: into the strip of page left of the drawer when a toast fits there,
* otherwise below the drawer's header. This file pins that MECHANISM — which
* dialogs count as a right-edge drawer, and which sonner offset each case
* produces, appearing on open and gone on close.
*
* ⚠️ What it cannot pin is the geometry itself. happy-dom has no layout
* engine, so every size here is assigned, not measured: the drawer's
* `offsetWidth` and its header's `offsetTop` / `offsetHeight` are set to the
* values Chromium measured for the record drawer (`NavigationOverlay` drawer
* mode, header 45px tall). The real-layout reading — toast and drawer header
* bounding boxes before and after, across viewports and drawer sizes — was a
* one-off Chromium measurement recorded on the pull request that landed this
* file; nothing re-runs it.
*/
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';

/** The record drawer's header height, as Chromium lays it out. */
const HEADER_HEIGHT = 45;

/** Inline layout of a `side="right"` sheet: fixed to the top, right and bottom edges. */
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 });
}

/**
* A Radix-shaped dialog: `role="dialog"`, `data-state`, and an
* `aria-labelledby` title nested inside a header block, the way
* `NavigationOverlay` nests its `SheetTitle` inside its `SheetHeader`.
*/
function mountDialog({ layout, width }: { layout: string; width: number }): HTMLElement {
const n = mounted.length;
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('data-state', 'open');
dialog.setAttribute('aria-labelledby', `probe-title-${n}`);
dialog.setAttribute('style', layout);
const header = document.createElement('div');
const titleColumn = document.createElement('div');
const title = document.createElement('h2');
title.id = `probe-title-${n}`;
title.textContent = 'Product';
titleColumn.append(title);
header.append(titleColumn);
const body = document.createElement('div');
dialog.append(header, body);
Object.defineProperty(dialog, 'offsetWidth', { configurable: true, value: width });
Object.defineProperty(header, 'offsetTop', { configurable: true, value: 0 });
Object.defineProperty(header, 'offsetHeight', { configurable: true, value: HEADER_HEIGHT });
document.body.append(dialog);
mounted.push(dialog);
return dialog;
}

async function renderWithToast(props: Record<string, unknown> = {}): Promise<HTMLElement> {
render(
<ThemeProvider>
<ConsoleToaster {...props} />
</ThemeProvider>,
);
act(() => {
toast.success('Product created');
});
return waitFor(() => {
const el = document.querySelector<HTMLElement>('[data-sonner-toaster]');
expect(el, 'sonner did not mount its toaster region').toBeTruthy();
return el as HTMLElement;
});
}

const offsets = (toaster: HTMLElement) => ({
top: toaster.style.getPropertyValue('--offset-top'),
right: toaster.style.getPropertyValue('--offset-right'),
mobileTop: toaster.style.getPropertyValue('--mobile-offset-top'),
});

/** sonner's defaults, i.e. the toaster with no drawer to clear. */
const DEFAULTS = { top: '24px', right: '24px', mobileTop: '16px' };

afterEach(() => {
act(() => {
toast.dismiss();
});
cleanup();
for (const el of mounted.splice(0)) el.remove();
if (innerWidthDescriptor) Object.defineProperty(window, 'innerWidth', innerWidthDescriptor);
});

describe('ConsoleToaster clears an open right-edge drawer (objectui#11685)', () => {
it('the control: with no drawer open it keeps sonner`s own offsets', async () => {
setViewportWidth(1440);
const toaster = await renderWithToast();
expect(toaster.getAttribute('data-x-position')).toBe('right');
expect(toaster.getAttribute('data-y-position')).toBe('top');
expect(offsets(toaster)).toEqual(DEFAULTS);
});

it('moves into the strip left of the drawer when a toast fits there, corner unchanged', async () => {
// 1440px window, 864px drawer: 576px of page left of it, room for a 356px toast.
setViewportWidth(1440);
const toaster = await renderWithToast();
mountDialog({ layout: RIGHT_SHEET, width: 864 });
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));
expect(offsets(toaster).top).toBe('24px');
expect(toaster.getAttribute('data-x-position')).toBe('right');
expect(toaster.getAttribute('data-y-position')).toBe('top');
});

it('drops below the drawer header when the strip beside it is too narrow', async () => {
// 1024px window, 720px drawer: 304px left of it, narrower than a toast.
setViewportWidth(1024);
const toaster = await renderWithToast();
mountDialog({ layout: RIGHT_SHEET, width: 720 });
await waitFor(() => expect(offsets(toaster).top).toBe(`${HEADER_HEIGHT + 24}px`));
expect(offsets(toaster).right).toBe('24px');
// Phones: sonner spans the toaster across the viewport, so always below.
expect(offsets(toaster).mobileTop).toBe(`${HEADER_HEIGHT + 16}px`);
});

it('goes back to the corner when the drawer closes, and when it unmounts', async () => {
setViewportWidth(1440);
const toaster = await renderWithToast();
const drawer = mountDialog({ layout: RIGHT_SHEET, width: 864 });
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));

drawer.setAttribute('data-state', 'closed');
await waitFor(() => expect(offsets(toaster)).toEqual(DEFAULTS));

drawer.setAttribute('data-state', 'open');
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));

drawer.remove();
await waitFor(() => expect(offsets(toaster)).toEqual(DEFAULTS));
});

it('ignores dialogs that are not right-edge drawers', async () => {
setViewportWidth(1440);
const toaster = await renderWithToast();
// A centred modal, a left sheet, and a popover (Radix popover content is
// `role="dialog"` too, positioned by a wrapper rather than fixed itself).
mountDialog({ layout: 'position: fixed; top: 50%; left: 50%;', width: 512 });
mountDialog({ layout: 'position: fixed; top: 0px; bottom: 0px; left: 0px; right: 400px;', width: 320 });
mountDialog({ layout: 'position: relative;', width: 288 });
// Give the observers a frame to have run, then confirm nothing moved.
await act(() => new Promise((resolve) => window.requestAnimationFrame(() => resolve(undefined))));
await act(() => new Promise((resolve) => window.requestAnimationFrame(() => resolve(undefined))));
expect(offsets(toaster)).toEqual(DEFAULTS);

// Non-vacuity: the observers were live all along — a right sheet beside
// those three moves the toaster at once.
mountDialog({ layout: RIGHT_SHEET, width: 864 });
await waitFor(() => expect(offsets(toaster).right).toBe('888px'));
});

it('a caller`s own `offset` still wins — the spread contract is unchanged', async () => {
setViewportWidth(1440);
const toaster = await renderWithToast({ offset: 8 });
mountDialog({ layout: RIGHT_SHEET, width: 864 });
await act(() => new Promise((resolve) => window.requestAnimationFrame(() => resolve(undefined))));
await act(() => new Promise((resolve) => window.requestAnimationFrame(() => resolve(undefined))));
expect(offsets(toaster).right).toBe('8px');
expect(offsets(toaster).top).toBe('8px');
});
});
124 changes: 124 additions & 0 deletions packages/app-shell/src/chrome/ConsoleToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,135 @@
* @module
*/

import { useEffect, useState } from 'react';
import { Toaster as Sonner } from 'sonner';
import { CircleCheck, Info, LoaderCircle, OctagonX, TriangleAlert } from 'lucide-react';
import { useTheme } from './ThemeProvider.js';
import { useObjectTranslation } from '@object-ui/i18n';

type ToasterProps = React.ComponentProps<typeof Sonner>;

/**
* objectui#11685 — the toaster steps clear of an open right-edge drawer.
*
* `top-right` (objectui#7482, below) is also the corner every right-side
* drawer puts its chrome in: the record drawer's expand and close buttons and
* its title, the record's own header with its Approve-style actions under it.
* A success toast raised while one is open, which is exactly what creating a
* record or running a record action does, landed on top of all of them.
*
* The corner stays. While such a drawer is open the toaster is offset, never
* re-anchored:
*
* - When the strip of page left of the drawer is wide enough for a toast, the
* toaster moves into that strip, to the drawer's left edge. The strip is
* under the drawer's modal overlay, so nothing interactive lives there, and
* 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.
*
* "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
* top, right and bottom edges. Every `side="right"` sheet in the console
* 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.
*/
const VIEWPORT_GAP = 24; // sonner's VIEWPORT_OFFSET
const MOBILE_VIEWPORT_GAP = 16; // sonner's MOBILE_VIEWPORT_OFFSET
const TOAST_WIDTH = 356; // sonner's TOAST_WIDTH

interface DrawerClearance {
offset: ToasterProps['offset'];
mobileOffset: ToasterProps['mobileOffset'];
}

const isZero = (length: string) => parseFloat(length) === 0;

function openRightDrawers(): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]')).filter((el) => {
const style = window.getComputedStyle(el);
return style.position === 'fixed' && isZero(style.top) && isZero(style.right) && isZero(style.bottom);
});
}

function headerOf(drawer: HTMLElement): HTMLElement | null {
const titleId = drawer.getAttribute('aria-labelledby');
const title = titleId ? document.getElementById(titleId) : null;
if (!title || !drawer.contains(title) || title === drawer) return null;
let block = title;
while (block.parentElement && block.parentElement !== drawer) block = block.parentElement;
return block;
}

function clearanceFor(drawers: HTMLElement[]): DrawerClearance | null {
if (drawers.length === 0) return null;
let width = 0;
let headerBottom = 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);
}
const fitsBeside = window.innerWidth - width >= TOAST_WIDTH + 2 * VIEWPORT_GAP;
return {
offset: fitsBeside ? { right: width + VIEWPORT_GAP } : { top: headerBottom + 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 },
};
}

function useDrawerClearance(): DrawerClearance | null {
const [clearance, setClearance] = useState<DrawerClearance | null>(null);

useEffect(() => {
let frame = 0;
const sizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => schedule());
const measure = () => {
frame = 0;
const drawers = openRightDrawers();
sizes?.disconnect();
for (const drawer of drawers) {
sizes?.observe(drawer);
const header = headerOf(drawer);
if (header) sizes?.observe(header);
}
const next = clearanceFor(drawers);
// Compared by value: a fresh-but-equal object would re-render the
// toaster on every DOM mutation the observers below report.
setClearance((prev) => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
};
function schedule() {
if (!frame) frame = window.requestAnimationFrame(measure);
}
// Radix portals each dialog straight into <body>, so its mount and
// unmount are child-list changes of <body> itself; opening or closing one
// that stays mounted flips `data-state` somewhere below it.
const portals = new MutationObserver(schedule);
portals.observe(document.body, { childList: true });
const states = new MutationObserver(schedule);
states.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-state'] });
window.addEventListener('resize', schedule);
schedule();
return () => {
if (frame) window.cancelAnimationFrame(frame);
portals.disconnect();
states.disconnect();
sizes?.disconnect();
window.removeEventListener('resize', schedule);
};
}, []);

return clearance;
}

export function ConsoleToaster(props: ToasterProps) {
const { theme = 'system' } = useTheme();
const { t } = useObjectTranslation();
const clearance = useDrawerClearance();

return (
<Sonner
Expand All @@ -34,6 +153,11 @@ export function ConsoleToaster(props: ToasterProps) {
// 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
Expand Down
Loading