diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 3ba3469fed..8d425ca85c 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -43,3 +43,12 @@ line-height: 20px; text-align: center; } + +.status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 2387eb5172..122043d904 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -157,8 +157,11 @@ export function HoverCard({ onClick={copyable ? (e) => { const selection = window.getSelection() - if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0 - && selection.getRangeAt(0).intersectsNode(e.currentTarget)) return + if (selection !== null && !selection.isCollapsed) { + for (let i = 0; i < selection.rangeCount; i += 1) { + if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return + } + } void copy(copyText) } : undefined} @@ -170,7 +173,7 @@ export function HoverCard({ } : undefined} > - {copied ? {copiedLabel} : content} + {copied ? : content} ) @@ -206,6 +209,7 @@ export function HoverCard({ }} > {anchor} + {copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 9e37a61d8e..d5b580045a 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -159,8 +159,21 @@ describe('HoverCard', () => { expect(selection.toString()).toBe('card body') expect(screen.getByText('card body')).toBeTruthy() - // A non-collapsed selection elsewhere does not block this card. + // Firefox supports multiple selection ranges: any range intersecting + // this card wins, not only the first. selection.removeAllRanges() + const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({ + isCollapsed: false, + rangeCount: 2, + getRangeAt: vi.fn((index: number) => ({ + intersectsNode: () => index === 1, + })), + } as unknown as Selection) + await act(async () => { fireEvent.click(card) }) + expect(writeText).not.toHaveBeenCalled() + getSelection.mockRestore() + + // A non-collapsed selection elsewhere does not block this card. const anchorRange = document.createRange() anchorRange.selectNodeContents(screen.getByText('row')) selection.addRange(anchorRange) @@ -191,10 +204,13 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) const card = screen.getByRole('button', { name: 'Copy path: /full/path' }) + const status = screen.getByRole('status') + expect(status.textContent).toBe('') + expect(card.contains(status)).toBe(false) Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 }) await act(async () => { fireEvent.click(card) }) expect(writeText).toHaveBeenCalledWith('/full/path') - expect(screen.getByRole('status').textContent).toBe('Copied') + expect(status.textContent).toBe('Copied') expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) expect(card.style.minHeight).toBe('96px') // Repeated activation while feedback is visible neither rewrites nor @@ -202,10 +218,11 @@ describe('HoverCard', () => { await act(async () => { fireEvent.click(card) }) expect(writeText).toHaveBeenCalledOnce() act(() => { vi.advanceTimersByTime(999) }) - expect(screen.getByText('Copied')).toBeTruthy() + expect(status.textContent).toBe('Copied') act(() => { vi.advanceTimersByTime(1) }) expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) expect(card.style.minHeight).toBe('') + expect(status.textContent).toBe('') expect(screen.getByText('card body')).toBeTruthy() } finally { restoreClipboard() @@ -271,7 +288,7 @@ describe('HoverCard', () => { fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) await act(async () => { fireEvent.click(screen.getByRole('button')) }) - expect(screen.getByText('Copied')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('Copied') fireEvent.pointerLeave(wrapper) act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) expect(screen.queryByText('Copied')).toBeNull() diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index bb0be9538c..7bef820d4a 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -160,7 +160,7 @@ describe('workspace browser rows', () => { expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) }) expect(writeText).toHaveBeenCalledWith('/projects/project') - expect(screen.getByText('已复制')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe('已复制') } finally { restoreClipboard() vi.useRealTimers()