fix: keep the composer's Close, panel button and Send reachable while a plugin panel lies over it

This commit is contained in:
Linus Rath
2026-11-03 05:01:08 +01:00
parent 97e6256c0e
commit 0fd77edeca
3 changed files with 59 additions and 5 deletions
@@ -543,6 +543,29 @@ describe('composer-sidebar and composer-sidebar-right', () => {
expect(panel('right').style.width).toBe('360px');
});
it('keep a panel laid over the composer between its header and its bottom bar', () => {
stubWidth(700);
offerSlots('p1', ['composer-sidebar-right']);
const rect = (top: number, bottom: number) => ({ top, bottom, left: 0, right: 700, width: 700, height: bottom - top, x: 0, y: top, toJSON: () => ({}) });
const original = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function (this: Element) {
if (this.matches('[data-testid="email-composer"]')) return rect(100, 800);
if (this.matches('[data-composer-header]')) return rect(100, 156);
if (this.matches('[data-composer-footer]')) return rect(740, 800);
return original.call(this);
};
try {
localStorage.setItem('composerPluginSidebarCollapsed:right', '1');
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
fireEvent.click(document.querySelector('[data-composer-panel-toggle="right"]')!);
expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
expect(panel('right').style.top).toBe('56px');
expect(panel('right').style.bottom).toBe('60px');
} finally {
Element.prototype.getBoundingClientRect = original;
}
});
it('folds a widened panel sooner, since it needs the room', () => {
localStorage.setItem('composerPluginSidebarWidth:right', '480');
stubWidth(1000);
+35 -5
View File
@@ -7,9 +7,10 @@
// room at all. When the composer is too narrow for an open panel next to
// it, the panel folds by itself (right first); opening it anyway lays it
// over the composer instead of squeezing the message into a column nobody
// can write in.
// can write in. Laid over, it stays between the composer's header and its
// bottom bar: the panel's own button, Close and Send stay in reach.
import { useEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { useTranslations } from "next-intl";
import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -229,6 +230,32 @@ export function ComposerPanelToggle({
);
}
const COMPOSER_HEADER = "[data-composer-header], [data-mv-compose-titlebar], [data-mv-compose-inline-bar]";
const COMPOSER_FOOTER = "[data-composer-footer]";
/** How far a panel laid over the composer keeps off its top and bottom, to leave the header and the bottom bar free. */
function useOverlayInsets(active: boolean, panelRef: RefObject<HTMLElement | null>): { top: number; bottom: number } {
const [insets, setInsets] = useState({ top: 0, bottom: 0 });
useLayoutEffect(() => {
const root = panelRef.current?.parentElement;
if (!active || !root) return;
const header = root.querySelector(COMPOSER_HEADER);
const footer = root.querySelector(COMPOSER_FOOTER);
const measure = () => {
const box = root.getBoundingClientRect();
const top = header ? Math.max(0, Math.round(header.getBoundingClientRect().bottom - box.top)) : 0;
const bottom = footer ? Math.max(0, Math.round(box.bottom - footer.getBoundingClientRect().top)) : 0;
setInsets((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }));
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
for (const el of [root, header, footer]) if (el) observer.observe(el);
return () => observer.disconnect();
}, [active, panelRef]);
return insets;
}
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
export function ComposerPluginSidebar({
side,
@@ -246,6 +273,8 @@ export function ComposerPluginSidebar({
onWidthChange?: (width: number) => void;
}) {
const dragStart = useRef(width);
const panelRef = useRef<HTMLDivElement>(null);
const insets = useOverlayInsets(mode === "overlay", panelRef);
if (!mode) return null;
const border = side === "left" ? "border-e" : "border-s";
@@ -274,12 +303,13 @@ export function ComposerPluginSidebar({
<>
{side === "right" && handle}
<div
ref={panelRef}
className={cn(
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
"hidden md:flex flex-col shrink-0 border-border bg-background overflow-hidden",
border,
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
mode === "overlay" ? cn("absolute z-30 shadow-xl", side === "left" ? "start-0" : "end-0") : "h-full",
)}
style={{ width }}
style={mode === "overlay" ? { width, top: insets.top, bottom: insets.bottom } : { width }}
data-composer-sidebar={side}
data-composer-sidebar-mode={mode}
>
+1
View File
@@ -5052,6 +5052,7 @@ export function EmailComposer({
one row (see [data-mv-compose-footer] in globals.css): Send first,
the tools after it, and Discard as a bin at the far end. */}
<div
data-composer-footer=""
data-mv-compose-footer={mvWindow ? "" : undefined}
className={cn(
"@container/composer-bar flex items-center justify-between px-4 py-2.5 max-md:py-1.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",