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
12 changes: 12 additions & 0 deletions .changeset/26418-toast-clears-drawer-actions-row.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<void> {
render(
<ThemeProvider>
<ConsoleToaster />
</ThemeProvider>,
);
act(() => {
toast.success('Project created');
});
await waitFor(() => expect(document.querySelector('[data-sonner-toaster]')).toBeTruthy());
}

const region = (): HTMLElement => {
const el = document.querySelector<HTMLElement>('[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<typeof toastRect>, b: ReturnType<typeof actionsRowRect>) =>
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));
});
});
78 changes: 71 additions & 7 deletions ui/packages/app-shell/src/chrome/ConsoleToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,32 @@ 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
* 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.
* `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
Expand Down Expand Up @@ -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<HTMLElement>(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 },
};
}

Expand All @@ -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
Expand All @@ -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);
};
Expand Down
Loading