From 0fd77edeca07494d1377eb397c60c3e019a60ed5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:01:08 +0100 Subject: [PATCH] fix: keep the composer's Close, panel button and Send reachable while a plugin panel lies over it --- .../composer-plugin-surfaces.test.tsx | 23 +++++++++++ components/email/composer-plugin-sidebars.tsx | 40 ++++++++++++++++--- components/email/email-composer.tsx | 1 + 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index c9812f7c9..787389c18 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -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(); + 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); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx index a0a2bf1b4..6fcae80da 100644 --- a/components/email/composer-plugin-sidebars.tsx +++ b/components/email/composer-plugin-sidebars.tsx @@ -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): { 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(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}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 77163d04b..194b731c2 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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. */}