Files
deepseek-harness/packages/client/ui-primitives/src/useAnchoredPosition.ts
T
Yif f15078532f feat(ui-chat): reshape the usage trigger as an icon-row pill
The whole-line meta trigger read as plain text and hid what was clickable.
The Turn-usage trigger is now a data-icon pill (Usage {total} · Cache hit
{percent}%) seated right of the branch action with the action buttons'
hover chrome, so the one interactive element in the tail is visibly a
button; the timing facts (clock, run time, speed, TTFT) return to plain
non-clickable text behind a dot separator. The details dialog keeps the
Turn-usage title and full token buckets, gains a permanent cache-hit row,
and breathes with wider vertical padding. Narrow columns trim the pill
label to an ellipsis instead of widening the chat column. User rows and
turn tails share the recency gate: only the latest row of each kind keeps
its actions visible without hover.
2026-08-27 23:14:08 +08:00

84 lines
3.8 KiB
TypeScript

/**
* Keep a fixed-position floating element anchored to a trigger.
*
* A portaled panel is positioned from its anchor's viewport rect, which stops
* being true the moment anything scrolls or the window resizes. This owns that
* one concern: measure the anchor, offset the panel below or above it, clamp
* the result inside the viewport, and re-run on scroll (capture phase, so
* scrollers nested inside the page are caught too), on resize, and on the
* panel's own size changes while the element is open.
* @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
*/
import { useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
/** Inputs for {@link useAnchoredPosition}. */
export interface AnchoredPositionOptions {
/** Whether the floating element is mounted and should track its anchor. */
open: boolean
/** The element the panel is placed from. */
anchorRef: RefObject<HTMLElement | null>
/** The floating element, measured so the clamp uses real dimensions. */
panelRef: RefObject<HTMLElement | null>
/** Which anchor edge the panel hangs from: below it (`bottom`, the default) or above it (`top`). */
side?: 'top' | 'bottom'
/** Distance kept between the anchor edge named by `side` and the panel. */
gap: number
/** Distance kept between the panel and each viewport edge. */
margin: number
}
/**
* Track an anchor and return the panel's fixed coordinates.
* @param options - the open state, the two refs, the placement side, and the gap/margin distances.
* @returns `left`/`top` for the panel, or `null` before the first measurement.
*/
export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
const { open, anchorRef, panelRef, side = 'bottom', gap, margin } = options
const [position, setPosition] = useState<CSSProperties | null>(null)
useLayoutEffect(() => {
if (!open) {
setPosition(null)
return
}
const place = () => {
/* v8 ignore start -- geometry read from real layout: jsdom reports zero
offset sizes, so the positive-size clamp arms are exercised by browser
scenarios rather than unit tests. */
const rect = anchorRef.current?.getBoundingClientRect()
if (rect === undefined) return
const panel = panelRef.current
const width = panel?.offsetWidth ?? 0
const height = panel?.offsetHeight ?? 0
let left = rect.left
let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
/* v8 ignore stop */
setPosition({ left, top })
}
// The first run measures the panel in the same commit that opened it, so
// the clamp uses real dimensions before anything paints.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
// The panel's own height changes without either event — a status line
// appearing inside it, or a `resize: vertical` textarea dragged taller —
// and a stale clamp would let a panel near the bottom edge cross the
// margin it is supposed to respect. The guard keeps the hook usable where
// `ResizeObserver` is absent, which is how jsdom runs.
const panel = panelRef.current
let observer: ResizeObserver | null = null
if (typeof ResizeObserver !== 'undefined' && panel !== null) {
observer = new ResizeObserver(place)
observer.observe(panel)
}
return () => {
observer?.disconnect()
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, anchorRef, panelRef, side, gap, margin])
return position
}