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 ? {copiedLabel} : 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()