fix(web): preserve hover-card selection and feedback

This commit is contained in:
creatixchu
2026-07-31 16:13:12 +08:00
parent 8714952c85
commit 8997afb3e5
15 changed files with 169 additions and 48 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e
README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5
README.md: 3a7c0fcb6557e2f8aa213290932cefeb62621f0e
README.zh.md: 2a0ce4e3b951cf77f6c8bd83dc15b874d1092027

View File

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Hover cards
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, writes that exact primary value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
## Markdown rendering

View File

@@ -6,7 +6,7 @@
## 悬浮卡片
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,通过包内剪贴板辅助函数原样写入该主要值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
## Markdown 渲染

View File

@@ -31,8 +31,13 @@
outline-offset: 2px;
}
.feedback {
display: flex;
align-items: center;
justify-content: center;
}
.copied {
display: block;
color: #FFFFFF;
font-size: 14px;
line-height: 20px;

View File

@@ -8,7 +8,7 @@
// traversal already treats it as inside — one pair of wrapper handlers covers
// anchor and card alike.
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { writeClipboard } from './clipboard.ts'
@@ -22,8 +22,9 @@ import css from './HoverCard.module.css'
* readable and selectable, but it carries no dismissal affordance of its own.
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.disabled - suppress opening; turning true closes an open card.
* @param props.copyText - optional primary value copied by activating the card.
* @param props.copyLabel - accessible activation label (default "复制").
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
* @param props.copyLabel - accessible activation-label prefix (default "复制").
* @param props.copiedLabel - visible success label (default "复制成功").
* @returns anchor wrapper with the conditional portaled card.
*/
@@ -43,13 +44,30 @@ export function HoverCard({
const cardRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const copyHeightRef = useRef<number | null>(null)
const copyEpochRef = useRef(0)
const copyingRef = useRef(false)
const mountedRef = useRef(true)
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const [copied, setCopied] = useState(false)
const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) })
const clearCopied = useCallback(() => {
if (copyTimerRef.current !== null) {
clearTimeout(copyTimerRef.current)
copyTimerRef.current = null
}
copyHeightRef.current = null
setCopied(false)
}, [])
const close = useCallback(() => {
copyEpochRef.current += 1
clearCopied()
setOpen(false)
}, [clearCopied])
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
const clearTimer = () => {
if (timerRef.current !== null) {
@@ -63,15 +81,19 @@ export function HoverCard({
if (!disabled) return
clearTimer()
cancelClose()
setOpen(false)
}, [disabled, cancelClose])
close()
}, [disabled, cancelClose, close])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
copyEpochRef.current += 1
clearTimer()
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
if (copyTimerRef.current !== null) {
clearTimeout(copyTimerRef.current)
copyTimerRef.current = null
}
}
}, [])
@@ -112,31 +134,39 @@ export function HoverCard({
const copy = async (text: string): Promise<void> => {
if (copied || copyingRef.current) return
copyingRef.current = true
const copyEpoch = copyEpochRef.current
const accepted = await writeClipboard(text)
copyingRef.current = false
if (!accepted || !mountedRef.current) return
const card = cardRef.current
if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return
const height = card.offsetHeight
copyHeightRef.current = height > 0 ? height : null
setCopied(true)
copyTimerRef.current = setTimeout(() => {
copyTimerRef.current = null
setCopied(false)
}, 1000)
copyTimerRef.current = setTimeout(clearCopied, 1000)
}
const copyable = copyText !== undefined
const card = open && pos !== null && (
<div
ref={cardRef}
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}`}
style={pos}
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
role={copyable ? 'button' : undefined}
tabIndex={copyable ? 0 : undefined}
aria-label={copyable ? (copied ? copiedLabel : copyLabel) : undefined}
onClick={copyable ? () => { void copy(copyText) } : undefined}
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
onClick={copyable
? (e) => {
const selection = window.getSelection()
if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(e.currentTarget)) return
void copy(copyText)
}
: undefined}
onKeyDown={copyable
? (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.currentTarget.click()
void copy(copyText)
}
: undefined}
>
@@ -172,7 +202,7 @@ export function HoverCard({
if (cardRef.current?.contains(e.target as Node)) return
clearTimer()
cancelClose()
setOpen(false)
close()
}}
>
{anchor}

View File

@@ -140,6 +140,38 @@ describe('HoverCard', () => {
expect(screen.getByText('card body')).toBeTruthy()
})
it('keeps a completed card selection instead of treating its click as copy', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
const selection = window.getSelection()
if (selection === null) throw new Error('jsdom selection API unavailable')
try {
const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy: card body' })
const selectedText = screen.getByText('card body')
const cardRange = document.createRange()
cardRange.selectNodeContents(selectedText)
selection.addRange(cardRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).not.toHaveBeenCalled()
expect(selection.toString()).toBe('card body')
expect(screen.getByText('card body')).toBeTruthy()
// A non-collapsed selection elsewhere does not block this card.
selection.removeAllRanges()
const anchorRange = document.createRange()
anchorRange.selectNodeContents(screen.getByText('row'))
selection.addRange(anchorRange)
await act(async () => { fireEvent.click(card) })
expect(writeText).toHaveBeenCalledWith('card body')
} finally {
selection.removeAllRanges()
restoreClipboard()
}
})
it('a press while closed leaves the card closed', () => {
mount()
fireEvent.pointerDown(screen.getByText('row'))
@@ -158,11 +190,13 @@ describe('HoverCard', () => {
})
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByRole('button', { name: 'Copy path' })
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
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(screen.getByRole('button', { name: 'Copied' })).toBe(card)
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
// extends the one-second success window.
await act(async () => { fireEvent.click(card) })
@@ -170,7 +204,8 @@ describe('HoverCard', () => {
act(() => { vi.advanceTimersByTime(999) })
expect(screen.getByText('Copied')).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card)
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
expect(card.style.minHeight).toBe('')
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
@@ -228,6 +263,26 @@ describe('HoverCard', () => {
}
})
it('clears copied feedback when the card closes', async () => {
const writeText = vi.fn(async () => {})
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { fireEvent.click(screen.getByRole('button')) })
expect(screen.getByText('Copied')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(screen.queryByText('Copied')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('does not create copied feedback after an in-flight write unmounts', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
@@ -246,6 +301,27 @@ describe('HoverCard', () => {
}
})
it('does not restore copied feedback after an in-flight card closes', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
const restoreClipboard = installClipboard(writeText)
try {
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button'))
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
await act(async () => { acceptWrite?.() })
expect(vi.getTimerCount()).toBe(0)
expect(screen.getByText('card body')).toBeTruthy()
} finally {
restoreClipboard()
}
})
it('coalesces activations while the clipboard write is in flight', async () => {
let acceptWrite: (() => void) | undefined
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))