|
|
@@ -1,7 +1,7 @@
|
|
|
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
|
|
-// TODO: interaction is a placeholder (horizontal overflow clamps, but there
|
|
|
-// is no vertical flip on viewport collision and no arrow) — visuals and
|
|
|
-// behavior get a proper pass later.
|
|
|
+// TODO: interaction is a placeholder (horizontal overflow clamps and a
|
|
|
+// vertical collision flips the bubble to the other side, but there is no
|
|
|
+// arrow) — visuals and behavior get a proper pass later.
|
|
|
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
|
|
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
|
|
// position:fixed and coordinates come from the anchor's rect at show time, so
|
|
|
@@ -48,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|
|
if (typeof childRef === 'function') childRef(el)
|
|
|
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
|
|
}, [childRef])
|
|
|
- const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
|
|
+ // The anchor's edges rather than final coordinates: a vertical flip has to
|
|
|
+ // re-derive the bubble's own top from the opposite edge.
|
|
|
+ const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
|
|
|
+ // Where the bubble actually sits, which is the requested side until the
|
|
|
+ // viewport refuses it.
|
|
|
+ const [placement, setPlacement] = useState<TooltipSide>(side)
|
|
|
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
|
|
|
- // label or wider viewport to release a previous clamp without another render.
|
|
|
+ const y = pos === null
|
|
|
+ ? 0
|
|
|
+ : placement === 'right'
|
|
|
+ ? pos.top + (pos.bottom - pos.top) / 2
|
|
|
+ : placement === 'top' ? pos.top - 8 : pos.bottom + 8
|
|
|
+ const EDGE_MARGIN = 12
|
|
|
+ // Viewport fit: fixed positioning knows nothing about edges, so a centered
|
|
|
+ // bubble near the right edge would clip and a long label under an anchor low
|
|
|
+ // on the page would run off the bottom. Horizontally the bubble slides back
|
|
|
+ // inside; vertically it flips to the opposite side, which is the only move
|
|
|
+ // that does not cover the anchor being read. Each measurement resets the base
|
|
|
+ // position first, so a shorter label or a larger viewport releases a previous
|
|
|
+ // adjustment without another render.
|
|
|
useLayoutEffect(() => {
|
|
|
if (pos === null) return
|
|
|
- const clamp = () => {
|
|
|
+ const fit = () => {
|
|
|
const el = bubble.current
|
|
|
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
|
|
if (el === null) return
|
|
|
- const EDGE_MARGIN = 12
|
|
|
el.style.left = `${pos.x}px`
|
|
|
const r = el.getBoundingClientRect()
|
|
|
let dx = 0
|
|
|
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
|
|
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
|
|
el.style.left = `${pos.x + dx}px`
|
|
|
+ if (side === 'right') return
|
|
|
+ // Flip only into a side that genuinely fits, so an anchor with room on
|
|
|
+ // neither side keeps the requested placement instead of oscillating.
|
|
|
+ const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
|
|
|
+ const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
|
|
|
+ if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
|
|
|
+ if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
|
|
|
}
|
|
|
- clamp()
|
|
|
- window.addEventListener('resize', clamp)
|
|
|
- return () => { window.removeEventListener('resize', clamp) }
|
|
|
- }, [pos, resolvedLabel])
|
|
|
+ fit()
|
|
|
+ window.addEventListener('resize', fit)
|
|
|
+ return () => { window.removeEventListener('resize', fit) }
|
|
|
+ }, [placement, pos, resolvedLabel, side])
|
|
|
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).
|
|
|
@@ -102,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|
|
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
|
|
if (el === null) return
|
|
|
const r = el.getBoundingClientRect()
|
|
|
- setPos(side === 'right'
|
|
|
- ? { x: r.right + 10, y: r.top + r.height / 2 }
|
|
|
- : side === 'top'
|
|
|
- ? { x: r.left + r.width / 2, y: r.top - 8 }
|
|
|
- : { x: r.left + r.width / 2, y: r.bottom + 8 })
|
|
|
+ // Every show starts from the requested side; the fit pass flips it only
|
|
|
+ // where this anchor's position demands it.
|
|
|
+ setPlacement(side)
|
|
|
+ setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
|
|
|
}
|
|
|
const showAfterHoverDelay = () => {
|
|
|
cancelShow()
|
|
|
@@ -137,8 +156,8 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|
|
<span
|
|
|
ref={bubble}
|
|
|
className={css.bubble}
|
|
|
- data-side={side}
|
|
|
- style={{ left: pos.x, top: pos.y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
|
|
+ data-side={placement}
|
|
|
+ style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
|
|
role="tooltip"
|
|
|
>
|
|
|
{resolvedLabel}
|