mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: give composer plugin panels a fixed width and a toggle, and fold them when the composer gets too narrow
This commit is contained in:
@@ -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<HTMLElement>(`[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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ComposerSidebarSide, ComposerSidebarMode | null> {
|
||||
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<Record<ComposerSidebarSide, boolean>>(() => ({
|
||||
left: readCollapsed("left"),
|
||||
right: readCollapsed("right"),
|
||||
}));
|
||||
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ 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 (
|
||||
<div
|
||||
className={cn("hidden md:flex flex-col items-center shrink-0 h-full border-border bg-background", border)}
|
||||
style={{ width: COMPOSER_SIDEBAR_STRIP }}
|
||||
data-composer-sidebar={side}
|
||||
data-composer-sidebar-mode="folded"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="m-1 grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={t("plugin_panel_show")}
|
||||
aria-label={t("plugin_panel_show")}
|
||||
>
|
||||
<OpenIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
|
||||
border,
|
||||
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
|
||||
)}
|
||||
style={{ width: COMPOSER_SIDEBAR_WIDTH }}
|
||||
data-composer-sidebar={side}
|
||||
data-composer-sidebar-mode={mode}
|
||||
>
|
||||
<div className={cn("flex items-center px-1 py-1 border-b border-border shrink-0", side === "left" ? "justify-end" : "justify-start")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
title={t("plugin_panel_hide")}
|
||||
aria-label={t("plugin_panel_hide")}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{side === "left"
|
||||
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
|
||||
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(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 (
|
||||
<div
|
||||
ref={setComposerRootEl}
|
||||
data-testid="email-composer"
|
||||
className={cn("flex h-full bg-background", className)}
|
||||
className={cn("flex h-full bg-background relative", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
/>
|
||||
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
|
||||
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
|
||||
<div
|
||||
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||
@@ -4335,10 +4337,7 @@ export function EmailComposer({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PluginSlot
|
||||
name="composer-sidebar-right"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
|
||||
/>
|
||||
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user