diff --git a/components/email/__tests__/viewer-more-menu-flyouts.test.tsx b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx new file mode 100644 index 000000000..4ff0aff31 --- /dev/null +++ b/components/email/__tests__/viewer-more-menu-flyouts.test.tsx @@ -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()), + useRulesAvailability: () => ({ availability: 'available', target: {} }), + RulesPanel: () =>
rules panel
, +})); + +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(); + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + const entry = screen.getByTestId('viewer-rules').parentElement!; + // jsdom doesn't inherit `direction` from , 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'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index be5fd5cc1..5a061399e 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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({ {moreMenuSub === 'move' && ( -
{(() => { const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => { @@ -4042,7 +4044,7 @@ export function EmailViewer({ }; return renderMobileNodes(moveTree); })()} -
+ )} )} @@ -4076,16 +4078,17 @@ export function EmailViewer({ {moreMenuSub === 'tag' && ( -
{ if (email) onSetTag?.(email.id, tagId); }} /> -
+ )} )} @@ -4109,13 +4112,14 @@ export function EmailViewer({ {moreMenuSub === 'rules' && ( -
-
+ )} )} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index a8dc532a8..a9c5ab387 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -202,49 +202,8 @@ export function ContextMenuSubMenu({ hint, }: ContextMenuSubMenuProps) { const [isOpen, setIsOpen] = useState(false); - const [subMenuPos, setSubMenuPos] = useState(null); - const itemRef = useRef(null); - const subMenuRef = useRef(null); const closeTimerRef = useRef | 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 (
{isOpen && !disabled && ( -
{children}
-
+ )}
); } +interface SubMenuPanelProps extends React.HTMLAttributes { + /** 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(null); + const subMenuRef = useRef(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 ( +
+ ); +} + interface ContextMenuHeaderProps { children: React.ReactNode; }