fix: open the reader's Move, Tag and Rules flyouts on the side with room #1261

This commit is contained in:
Linus Rath
2026-10-18 13:46:48 +02:00
parent 86d6c78ce7
commit 89001ededa
3 changed files with 183 additions and 60 deletions
@@ -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');
});
});
+13 -9
View File
@@ -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>
)}
+65 -51
View File
@@ -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;
}