mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): keep open-with menu clear of workbench
This commit is contained in:
@@ -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(<OpenWithMenu items={makeItems()} anchor={{ top: 100, bottom: 110, left: 700, right: rightEdge }} onClose={vi.fn()} />)
|
||||
|
||||
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(<OpenWithMenu items={makeItems()} anchor={{ top: 100, bottom: 110, left: 40, right: 100 }} onClose={vi.fn()} />)
|
||||
|
||||
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(<OpenWithMenu items={makeItems()} anchor={{ top: 260, bottom: 270, left: 20, right: 120 }} onClose={vi.fn()} />)
|
||||
// flipped top = anchor.top - height - 6 = 260 - 200 - 6 = 54
|
||||
expect(screen.getByRole('menu').style.top).toBe('54px')
|
||||
rectSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <ExternalLink size={18} strokeWidth={1.9} />
|
||||
}
|
||||
|
||||
const MARGIN = 8
|
||||
|
||||
export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(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) {
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className="fixed min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
style={{ top: pos.top, left: pos.left, zIndex: 'var(--z-dropdown)' }}
|
||||
className="min-w-[min(220px,calc(100vw-16px))] max-w-[min(300px,calc(100vw-16px))] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
style={{ ...positionStyle, zIndex: 'var(--z-dropdown)' }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
|
||||
@@ -98,6 +98,32 @@ describe('useAnchoredPosition', () => {
|
||||
expect(result.style.left).toBe(120)
|
||||
})
|
||||
|
||||
it('positions from snapshot anchor geometry', () => {
|
||||
const captured: { current: Result | null } = { current: null }
|
||||
|
||||
function Harness() {
|
||||
const floatingRef = useRef<HTMLDivElement | null>(null)
|
||||
captured.current = useAnchoredPosition({
|
||||
open: true,
|
||||
anchorRect: { top: 100, bottom: 130, left: 200, right: 300 },
|
||||
floatingRef,
|
||||
placement: 'bottom-end',
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={(element) => {
|
||||
floatingRef.current = element
|
||||
if (element) stubRect(element, { width: 180, height: 120 })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
expect(captured.current!.style.top).toBe(136)
|
||||
expect(captured.current!.style.left).toBe(120)
|
||||
})
|
||||
|
||||
it('flips above the anchor when it would overflow the bottom', () => {
|
||||
const result = renderAnchored({
|
||||
anchor: { top: 700, bottom: 740, left: 200, right: 300 },
|
||||
|
||||
@@ -2,9 +2,14 @@ import { useCallback, useLayoutEffect, useState, type CSSProperties } from 'reac
|
||||
|
||||
export type AnchoredPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
|
||||
|
||||
export type UseAnchoredPositionOptions = {
|
||||
export type AnchoredRect = Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left'>
|
||||
|
||||
type AnchorSource =
|
||||
| { anchorRef: { current: HTMLElement | null }; anchorRect?: never }
|
||||
| { anchorRef?: never; anchorRect: AnchoredRect }
|
||||
|
||||
export type UseAnchoredPositionOptions = AnchorSource & {
|
||||
open: boolean
|
||||
anchorRef: { current: HTMLElement | null }
|
||||
floatingRef: { current: HTMLElement | null }
|
||||
placement?: AnchoredPlacement
|
||||
/** Gap between anchor and overlay, in pixels. */
|
||||
@@ -42,6 +47,7 @@ const DEFAULT_MARGIN = 8
|
||||
export function useAnchoredPosition({
|
||||
open,
|
||||
anchorRef,
|
||||
anchorRect,
|
||||
floatingRef,
|
||||
placement = 'bottom-start',
|
||||
offset = 6,
|
||||
@@ -54,11 +60,10 @@ export function useAnchoredPosition({
|
||||
)
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const anchor = anchorRef.current
|
||||
const anchorBox = anchorRect ?? anchorRef?.current?.getBoundingClientRect()
|
||||
const floating = floatingRef.current
|
||||
if (!anchor || !floating) return
|
||||
if (!anchorBox || !floating) return
|
||||
|
||||
const anchorBox = anchor.getBoundingClientRect()
|
||||
const { width, height } = floating.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
@@ -98,7 +103,19 @@ export function useAnchoredPosition({
|
||||
}
|
||||
|
||||
setState({ top, left, placement: resolved, ready: true })
|
||||
}, [anchorRef, floatingRef, placement, offset, viewportMargin, flip, shift])
|
||||
}, [
|
||||
anchorRect?.top,
|
||||
anchorRect?.right,
|
||||
anchorRect?.bottom,
|
||||
anchorRect?.left,
|
||||
anchorRef,
|
||||
floatingRef,
|
||||
placement,
|
||||
offset,
|
||||
viewportMargin,
|
||||
flip,
|
||||
shift,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
|
||||
Reference in New Issue
Block a user