mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: open the reader's Move, Tag and Rules flyouts on the side with room #1261
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// #1261: the Move, Tag and Rules flyouts of the reader's More menu always
|
||||
// opened on the menu's start side and ran past the reading pane when there
|
||||
// was no room there. They now pick the side with room, like a context
|
||||
// submenu does.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
|
||||
usePluginSlotOffers: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../rules-menu', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../rules-menu')>()),
|
||||
useRulesAvailability: () => ({ availability: 'available', target: {} }),
|
||||
RulesPanel: () => <div>rules panel</div>,
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const email = {
|
||||
id: 'e1',
|
||||
blobId: 'b1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
preview: 'Hello',
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
textBody: [],
|
||||
htmlBody: [],
|
||||
attachments: [],
|
||||
bodyValues: {},
|
||||
} as unknown as Email;
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** Opens Rules with its entry at `entryLeft` (192 px wide) in a 1280 px window; returns the flyout. */
|
||||
function openRules(entryLeft: number, direction = 'ltr'): HTMLElement {
|
||||
render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const entry = screen.getByTestId('viewer-rules').parentElement!;
|
||||
// jsdom doesn't inherit `direction` from <html dir>, so set it where it is read.
|
||||
entry.style.direction = direction;
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === entry) return rect(entryLeft, 120, 192, 32);
|
||||
if (this.getAttribute('aria-label') === 'rules.title') return rect(0, 0, 288, 200);
|
||||
return rect(0, 0, 0, 0);
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('viewer-rules'));
|
||||
return screen.getByRole('menu', { name: 'rules.title' });
|
||||
}
|
||||
|
||||
describe('reader More menu flyouts (#1261)', () => {
|
||||
it('opens on the right when there is no room on the left', () => {
|
||||
const flyout = openRules(100);
|
||||
expect(flyout.style.left).toBe('292px');
|
||||
expect(flyout.style.top).toBe('120px');
|
||||
});
|
||||
|
||||
it('still opens on the left when both sides have room', () => {
|
||||
const flyout = openRules(600);
|
||||
expect(flyout.style.left).toBe('312px');
|
||||
});
|
||||
|
||||
it('opens on the right when both sides have room in a right-to-left layout', () => {
|
||||
const flyout = openRules(600, 'rtl');
|
||||
expect(flyout.style.left).toBe('792px');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { SubMenuPanel } from "@/components/ui/context-menu";
|
||||
import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
|
||||
import { itemsToHide } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
@@ -4004,10 +4005,11 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'move' && (
|
||||
<div
|
||||
<SubMenuPanel
|
||||
side="start"
|
||||
role="menu"
|
||||
aria-label={t('move_to')}
|
||||
className="absolute end-full top-0 me-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10"
|
||||
className="py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border"
|
||||
>
|
||||
{(() => {
|
||||
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
|
||||
@@ -4042,7 +4044,7 @@ export function EmailViewer({
|
||||
};
|
||||
return renderMobileNodes(moveTree);
|
||||
})()}
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -4076,16 +4078,17 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'tag' && (
|
||||
<div
|
||||
<SubMenuPanel
|
||||
side="start"
|
||||
role="menu"
|
||||
aria-label={t('tag')}
|
||||
className="absolute end-full top-0 me-1 py-1 w-56 bg-background rounded-md shadow-lg border border-border z-10"
|
||||
className="py-1 w-56 bg-background rounded-md shadow-lg border border-border"
|
||||
>
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => { if (email) onSetTag?.(email.id, tagId); }}
|
||||
/>
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -4109,13 +4112,14 @@ export function EmailViewer({
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
{moreMenuSub === 'rules' && (
|
||||
<div
|
||||
<SubMenuPanel
|
||||
side="start"
|
||||
role="menu"
|
||||
aria-label={tContextMenu('rules.title')}
|
||||
className="absolute end-full top-0 me-1 py-1 w-72 max-h-80 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10"
|
||||
className="py-1 w-72 max-h-80 overflow-y-auto bg-background rounded-md shadow-lg border border-border"
|
||||
>
|
||||
<RulesPanel email={email} target={rulesTarget} variant="desktop" onDone={closeMoreMenu} />
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,49 +202,8 @@ export function ContextMenuSubMenu({
|
||||
hint,
|
||||
}: ContextMenuSubMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSubMenuPos(null);
|
||||
return;
|
||||
}
|
||||
const itemEl = itemRef.current;
|
||||
const subEl = subMenuRef.current;
|
||||
if (!itemEl || !subEl) return;
|
||||
|
||||
const itemRect = itemEl.getBoundingClientRect();
|
||||
const subRect = subEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left: number;
|
||||
if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) {
|
||||
left = itemRect.right;
|
||||
} else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) {
|
||||
left = itemRect.left - subRect.width;
|
||||
} else {
|
||||
left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
|
||||
}
|
||||
|
||||
let top = itemRect.top;
|
||||
if (top + subRect.height > vh - VIEWPORT_MARGIN) {
|
||||
top = vh - subRect.height - VIEWPORT_MARGIN;
|
||||
}
|
||||
top = Math.max(VIEWPORT_MARGIN, top);
|
||||
|
||||
// The submenu is measured while it still sits at left/top 0, so its rect
|
||||
// is the origin `position: fixed` resolves against. That is the viewport,
|
||||
// unless a theme gives the parent menu a backdrop-filter, filter or
|
||||
// transform: then the parent is the containing block and viewport
|
||||
// coordinates land offset by the parent's own position, far from the
|
||||
// entry (#1149).
|
||||
setSubMenuPos({ x: left - subRect.left, y: top - subRect.top });
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(closeTimerRef.current ?? undefined);
|
||||
}, []);
|
||||
@@ -266,7 +225,6 @@ export function ContextMenuSubMenu({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -297,25 +255,81 @@ export function ContextMenuSubMenu({
|
||||
</div>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
ref={subMenuRef}
|
||||
className="fixed z-50 min-w-[180px] bg-background rounded-md shadow-lg border border-border"
|
||||
style={{
|
||||
left: subMenuPos?.x ?? 0,
|
||||
top: subMenuPos?.y ?? 0,
|
||||
visibility: subMenuPos ? "visible" : "hidden",
|
||||
}}
|
||||
<SubMenuPanel
|
||||
className="min-w-[180px] bg-background rounded-md shadow-lg border border-border"
|
||||
role="menu"
|
||||
>
|
||||
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SubMenuPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubMenuPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** The side of the entry to open on when it fits; flips with the text direction. */
|
||||
side?: "start" | "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* A submenu's panel, placed beside the entry it is rendered in (its parent
|
||||
* element): on `side` when it fits in the window, else on the other side,
|
||||
* else clamped inside the window. It is `position: fixed`, so a pane that
|
||||
* clips its overflow can't cut it off.
|
||||
*/
|
||||
export function SubMenuPanel({ side = "end", className, style, ...rest }: SubMenuPanelProps) {
|
||||
const [pos, setPos] = useState<Position | null>(null);
|
||||
const subMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const subEl = subMenuRef.current;
|
||||
const itemEl = subEl?.parentElement;
|
||||
if (!subEl || !itemEl) return;
|
||||
|
||||
const itemRect = itemEl.getBoundingClientRect();
|
||||
const subRect = subEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Where the panel's left edge goes on either side, if it fits there.
|
||||
const onRight = itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN ? itemRect.right : null;
|
||||
const onLeft = itemRect.left - subRect.width >= VIEWPORT_MARGIN ? itemRect.left - subRect.width : null;
|
||||
const rightFirst = (side === "end") !== (getComputedStyle(itemEl).direction === "rtl");
|
||||
const x = (rightFirst ? (onRight ?? onLeft) : (onLeft ?? onRight))
|
||||
?? Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN);
|
||||
|
||||
let top = itemRect.top;
|
||||
if (top + subRect.height > vh - VIEWPORT_MARGIN) {
|
||||
top = vh - subRect.height - VIEWPORT_MARGIN;
|
||||
}
|
||||
top = Math.max(VIEWPORT_MARGIN, top);
|
||||
|
||||
// The submenu is measured while it still sits at left/top 0, so its rect
|
||||
// is the origin `position: fixed` resolves against. That is the viewport,
|
||||
// unless a theme gives the parent menu a backdrop-filter, filter or
|
||||
// transform: then the parent is the containing block and viewport
|
||||
// coordinates land offset by the parent's own position, far from the
|
||||
// entry (#1149).
|
||||
setPos({ x: x - subRect.left, y: top - subRect.top });
|
||||
}, [side]);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={subMenuRef}
|
||||
className={cn("fixed z-50", className)}
|
||||
style={{
|
||||
...style,
|
||||
left: pos?.x ?? 0,
|
||||
top: pos?.y ?? 0,
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ContextMenuHeaderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user