fix(web): flip a tooltip that the viewport has no room for

The bubble clamped horizontally and did nothing vertically, so a long
label under an anchor low on the page ran off the bottom. Preset cards
made that the common case: custom presets sit at the end of the roster
and carry the longest descriptions.

A `top` or `bottom` bubble now flips to the other side, and only into a
side that genuinely fits — an anchor with room on neither keeps the
requested placement rather than oscillating. Sliding the bubble
vertically instead would cover the text being read.
This commit is contained in:
Yichen Jiang
2026-08-11 14:26:18 +08:00
parent 9f5eb5da8a
commit d6d7953ae9
5 changed files with 125 additions and 35 deletions

View File

@@ -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}

View File

@@ -173,19 +173,88 @@ describe('Tooltip', () => {
}
})
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const [top, bottom] = this.getAttribute('role') === 'tooltip'
? [0, bubbleHeight]
: [anchorTop, anchorBottom]
return {
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
}
})
it('supports top placement for anchors at the viewport bottom', () => {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
// zero-width measured rect clamps left to the 12px edge margin.
expect(bubble.style.left).toBe('12px')
expect(bubble.style.top).toBe('-8px')
const spy = placed(700, 720, 20)
try {
render(
<Tooltip label="Above" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
// There is room above, so the requested side stands: the bubble's own
// top sits at the anchor's top less the 8px gutter.
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('692px')
expect(bubble.style.left).toBe('150px')
} finally {
spy.mockRestore()
}
})
it('flips a bottom bubble above an anchor with no room below', () => {
// jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at
// 700 would run off, and there is room for it above.
const spy = placed(600, 700, 300)
try {
render(
<Tooltip label="Tall" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('top')
expect(bubble.style.top).toBe('592px')
} finally {
spy.mockRestore()
}
})
it('flips a top bubble below an anchor with no room above', () => {
const spy = placed(10, 40, 100)
try {
render(
<Tooltip label="Tall" side="top">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe('bottom')
expect(bubble.style.top).toBe('48px')
} finally {
spy.mockRestore()
}
})
it('keeps the requested side when neither side fits', () => {
// A bubble taller than the viewport has no home; oscillating between the
// two would be worse than honouring the request.
const spy = placed(300, 400, 900)
try {
render(
<Tooltip label="Huge" side="bottom">
<button type="button">anchor</button>
</Tooltip>,
)
fireEvent.mouseEnter(screen.getByText('anchor'))
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
} finally {
spy.mockRestore()
}
})
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {