diff --git a/desktop/src/components/composite/OpenWithMenu.test.tsx b/desktop/src/components/composite/OpenWithMenu.test.tsx index 0a8103c4..456d6842 100644 --- a/desktop/src/components/composite/OpenWithMenu.test.tsx +++ b/desktop/src/components/composite/OpenWithMenu.test.tsx @@ -1,10 +1,17 @@ import '@testing-library/jest-dom' import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OpenWithMenu } from './OpenWithMenu' import type { OpenWithItem } from '../../lib/openWithItems' const anchor = { top: 100, bottom: 110, left: 20, right: 120 } +const originalViewport = { width: window.innerWidth, height: window.innerHeight } + +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { value: originalViewport.width, configurable: true }) + Object.defineProperty(window, 'innerHeight', { value: originalViewport.height, configurable: true }) + vi.restoreAllMocks() +}) function makeItems(onSelect1 = vi.fn(), onSelect2 = vi.fn(), onSelect3 = vi.fn()): OpenWithItem[] { return [ @@ -76,6 +83,34 @@ describe('OpenWithMenu', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('aligns its right edge with the anchor to avoid a right-side native preview', () => { + Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + height: 160, width: 300, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}), + } as DOMRect) + const rightEdge = 900 + + render() + + const menu = screen.getByRole('menu') + expect(menu.style.left).toBe('600px') + expect(Number.parseFloat(menu.style.left) + 300).toBe(rightEdge) + expect(menu.className).toContain('max-w-[min(300px,calc(100vw-16px))]') + }) + + it('keeps a left-shifted menu inside the compact chat column', () => { + Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true }) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + height: 160, width: 300, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}), + } as DOMRect) + + render() + + const menuLeft = Number.parseFloat(screen.getByRole('menu').style.left) + expect(menuLeft).toBe(8) + expect(menuLeft + 300).toBeLessThan(320) + }) + describe('triggerEl exclusion (re-click-trigger toggle support)', () => { it('does NOT call onClose when mousedown lands inside triggerEl', () => { // Set up a trigger element + a child within it in the document. @@ -139,13 +174,12 @@ describe('OpenWithMenu', () => { // The trigger often sits right above the composer; the menu must not render off-screen below it. Object.defineProperty(window, 'innerHeight', { value: 300, configurable: true }) Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true }) - const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ height: 200, width: 220, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}), } as DOMRect) // anchor near the bottom: top:260/bottom:270. Down would be 276, 276+200=476 > 300-8 ⇒ flip up. render() // flipped top = anchor.top - height - 6 = 260 - 200 - 6 = 54 expect(screen.getByRole('menu').style.top).toBe('54px') - rectSpy.mockRestore() }) }) diff --git a/desktop/src/components/composite/OpenWithMenu.tsx b/desktop/src/components/composite/OpenWithMenu.tsx index 7c169988..40e2f89c 100644 --- a/desktop/src/components/composite/OpenWithMenu.tsx +++ b/desktop/src/components/composite/OpenWithMenu.tsx @@ -1,6 +1,7 @@ import { createPortal } from 'react-dom' -import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useRef } from 'react' import { Globe, ExternalLink, FileText, Copy } from 'lucide-react' +import { useAnchoredPosition } from '@/hooks/useAnchoredPosition' import { useDismissable } from '@/hooks/useDismissable' import { TargetIcon } from './TargetIcon' import type { OpenWithItem } from '../../lib/openWithItems' @@ -23,34 +24,14 @@ function ItemIcon({ item }: { item: OpenWithItem }) { return } -const MARGIN = 8 - export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) { const ref = useRef(null) - // Initial guess; corrected (before paint) by the layout effect once we can measure the menu. - const [pos, setPos] = useState<{ top: number; left: number }>(() => ({ - top: anchor.bottom + 6, - left: Math.max(MARGIN, Math.min(anchor.left, window.innerWidth - 240 - MARGIN)), - })) - - // Position viewport-aware: flip above the anchor if it would overflow the bottom - // (the trigger often sits right above the composer), and clamp into the viewport. - useLayoutEffect(() => { - const el = ref.current - if (!el) return - const { height, width } = el.getBoundingClientRect() - const vh = window.innerHeight - const vw = window.innerWidth - - let top = anchor.bottom + 6 - if (height > 0 && top + height > vh - MARGIN) { - const flipped = anchor.top - height - 6 - top = flipped >= MARGIN ? flipped : Math.max(MARGIN, vh - height - MARGIN) - } - let left = anchor.left - if (width > 0) left = Math.max(MARGIN, Math.min(left, vw - width - MARGIN)) - setPos({ top, left }) - }, [anchor]) + const { style: positionStyle } = useAnchoredPosition({ + open: true, + anchorRect: anchor, + floatingRef: ref, + placement: 'bottom-end', + }) // The hook takes a ref; this component is handed the raw trigger element by // its callers. Only the dismiss handler reads it, and it does so at event @@ -77,8 +58,8 @@ export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
{items.map((item) => (