diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 9f1ca493c..30513f9b0 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -344,3 +344,68 @@ describe('composer-attach-menu', () => { expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument(); }); }); + +describe('composer-sidebar and composer-sidebar-right', () => { + function stubWidth(width: number) { + class FixedWidthObserver { + constructor(private cb: ResizeObserverCallback) {} + observe(el: Element) { + if ((el as HTMLElement).dataset.testid !== 'email-composer') return; + this.cb([{ target: el, contentRect: { width, height: 700 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver); + } + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', FixedWidthObserver); + } + const panel = (side: 'left' | 'right') => document.querySelector(`[data-composer-sidebar="${side}"]`)!; + + afterEach(() => { + vi.unstubAllGlobals(); + try { + localStorage.removeItem('composerPluginSidebarCollapsed:left'); + localStorage.removeItem('composerPluginSidebarCollapsed:right'); + } catch { /* ignore */ } + }); + + it('give both panels a fixed width beside a wide composer', () => { + stubWidth(1400); + offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']); + render(); + + for (const side of ['left', 'right'] as const) { + expect(panel(side).dataset.composerSidebarMode).toBe('docked'); + expect(panel(side).style.width).toBe('260px'); + } + expect(panel('left')).toContainElement(screen.getByTestId('slot-composer-sidebar')); + expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right')); + }); + + it('fold the right panel, then both, when the composer would get too narrow', () => { + stubWidth(900); + offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']); + const { unmount } = render(); + expect(panel('left').dataset.composerSidebarMode).toBe('docked'); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull(); + unmount(); + + stubWidth(600); + render(); + expect(panel('left').dataset.composerSidebarMode).toBe('folded'); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + }); + + it('open a folded panel over the composer when there is no room beside it', () => { + stubWidth(600); + offerSlots('p1', ['composer-sidebar-right']); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' })); + expect(panel('right').dataset.composerSidebarMode).toBe('overlay'); + expect(panel('right').className).toContain('absolute'); + + fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' })); + expect(panel('right').dataset.composerSidebarMode).toBe('folded'); + }); +}); diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx new file mode 100644 index 000000000..21d0c0c79 --- /dev/null +++ b/components/email/composer-plugin-sidebars.tsx @@ -0,0 +1,186 @@ +"use client"; + +// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a +// fixed width and a toggle to fold it to a thin strip. 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. + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons"; +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; +import { cn } from "@/lib/utils"; + +/** Width of an open panel. */ +export const COMPOSER_SIDEBAR_WIDTH = 260; +/** Width of a folded panel (the strip with its toggle). */ +export const COMPOSER_SIDEBAR_STRIP = 37; +/** Narrowest the composer itself may get next to open panels. */ +export const MIN_COMPOSER_WIDTH = 480; + +export type ComposerSidebarSide = "left" | "right"; +/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */ +export type ComposerSidebarMode = "docked" | "folded" | "overlay"; + +interface SideState { + present: boolean; + /** Folded by the user. */ + collapsed: boolean; + /** Opened by the user while there was no room for it. */ + overlayOpen?: boolean; +} + +/** + * How each panel is shown in a composer `width` px wide (0 = not measured + * yet: everything the user left open is shown open). The left panel gets + * room first, since the right one is folded first. + */ +export function composerSidebarModes( + width: number, + left: SideState, + right: SideState, +): Record { + const present = (left.present ? 1 : 0) + (right.present ? 1 : 0); + let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity; + const place = (side: SideState): ComposerSidebarMode | null => { + if (!side.present) return null; + if (side.collapsed) return "folded"; + const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP; + if (extra <= room) { + room -= extra; + return "docked"; + } + return side.overlayOpen ? "overlay" : "folded"; + }; + const l = place(left); + return { left: l, right: place(right) }; +} + +const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`; + +function readCollapsed(side: ComposerSidebarSide): boolean { + if (typeof window === "undefined") return false; + try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; } +} + +/** Layout state of both panels for a composer whose root element is `root`. */ +export function useComposerSidebars(root: HTMLElement | null) { + const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0; + const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0; + const [width, setWidth] = useState(0); + const [collapsed, setCollapsed] = useState>(() => ({ + left: readCollapsed("left"), + right: readCollapsed("right"), + })); + const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false }); + + useEffect(() => { + if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width)); + }); + observer.observe(root); + return () => observer.disconnect(); + }, [root, hasLeft, hasRight]); + + const modes = composerSidebarModes( + width, + { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left }, + { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right }, + ); + + const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => { + setCollapsed((prev) => ({ ...prev, [side]: value })); + try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ } + }; + + /** Open a folded panel: beside the composer when it fits, over it otherwise. */ + const open = (side: ComposerSidebarSide) => { + const next = { ...collapsed, [side]: false }; + const fits = composerSidebarModes( + width, + { present: hasLeft, collapsed: next.left }, + { present: hasRight, collapsed: next.right }, + )[side] === "docked"; + setSideCollapsed(side, false); + setOverlayOpen((prev) => ({ ...prev, [side]: !fits })); + }; + const close = (side: ComposerSidebarSide) => { + setSideCollapsed(side, true); + setOverlayOpen((prev) => ({ ...prev, [side]: false })); + }; + + return { modes, open, close }; +} + +/** One plugin panel of the composer, in the mode useComposerSidebars picked. */ +export function ComposerPluginSidebar({ + side, + mode, + onOpen, + onClose, +}: { + side: ComposerSidebarSide; + mode: ComposerSidebarMode | null; + onOpen: () => void; + onClose: () => void; +}) { + const t = useTranslations("email_viewer"); + if (!mode) return null; + const border = side === "left" ? "border-e" : "border-s"; + const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen; + const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose; + + if (mode === "folded") { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+
+ {side === "left" + ? + : } +
+
+ ); +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index cbd2c9cff..beaae874d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -61,6 +61,7 @@ import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers"; import { usePluginActions } from "@/components/plugins/plugin-actions"; @@ -2071,6 +2072,9 @@ export function EmailComposer({ // Stable props, so a re-render of the composer doesn't post them to the // plugin's iframe again. const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); + // The plugin side panels fold when the composer gets too narrow for them. + const [composerRootEl, setComposerRootEl] = useState(null); + const pluginSidebars = useComposerSidebars(composerRootEl); const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source'); const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0; const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0; @@ -3222,14 +3226,12 @@ export function EmailComposer({ return (
- + pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} /> {/* Right-side composer sidebar slot is rendered after the main content div below. */}
)}
- + pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
); } diff --git a/components/icons/index.ts b/components/icons/index.ts index 8c3b910ef..d71b4b106 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -164,6 +164,7 @@ export { IconBeach as Palmtree, IconBeach as PalmtreeIcon, IconLayoutSidebarLeftCollapse as PanelLeftClose, + IconLayoutSidebarLeftExpand as PanelLeftOpen, IconLayoutSidebarRight as PanelRight, IconLayoutSidebarRightCollapse as PanelRightClose, IconLayoutSidebarRightExpand as PanelRightOpen,