perf(ui-trajectory): defer trajectory text processing

This commit is contained in:
imccyu
2026-08-10 23:51:18 +08:00
parent 58ea69f64f
commit e27dba8457
12 changed files with 415 additions and 168 deletions

View File

@@ -24,9 +24,11 @@ interface AnchorProps {
onBlur?: FocusEventHandler | undefined
}
type TooltipLabel = string | (() => string)
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
@@ -34,7 +36,7 @@ interface AnchorProps {
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -46,6 +48,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
const bubble = useRef<HTMLSpanElement | null>(null)
const resolvedLabel = pos === null
? null
: typeof label === 'function' ? label() : label
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
// a centered bubble near the right edge would clip. Each measurement resets
// the base position before applying a direct style offset, allowing a shorter
@@ -67,7 +72,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
clamp()
window.addEventListener('resize', clamp)
return () => { window.removeEventListener('resize', clamp) }
}, [label, pos])
}, [pos, resolvedLabel])
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -128,7 +133,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
})}
{pos !== null && (
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
{label}
{resolvedLabel}
</span>
)}
</>

View File

@@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('Tooltip', () => {
it('resolves lazy labels only after the bubble becomes visible', () => {
vi.useFakeTimers()
try {
const label = vi.fn(() => 'Timing details')
render(
<Tooltip label={label} delayMs={500}>
<button type="button">anchor</button>
</Tooltip>,
)
expect(label).not.toHaveBeenCalled()
fireEvent.mouseEnter(screen.getByText('anchor'))
act(() => { vi.advanceTimersByTime(499) })
expect(label).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
expect(label).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('can delay pointer hover without delaying keyboard focus', () => {
vi.useFakeTimers()
try {