134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
const GAP = 8
|
|
|
|
type Rect = { top: number; bottom: number; left: number; width: number }
|
|
type Placed = { top: number; left: number }
|
|
|
|
/**
|
|
* A tooltip that is actually part of the design.
|
|
*
|
|
* Replaces `title=""`, which cannot be styled, cannot be themed, appears after a
|
|
* delay the page does not control, and renders in the OS's own font at the
|
|
* cursor rather than against the thing it describes.
|
|
*
|
|
* Positioning is deliberately in two stages, and the order matters:
|
|
*
|
|
* 1. The anchor's rect is captured in the open handler, and the panel is drawn
|
|
* immediately from it using a transform (`translate(-50%, -100%)`) — the
|
|
* browser centres and lifts it with no measurement at all, so it is
|
|
* correctly placed on the very first paint.
|
|
* 2. A layout effect then measures the rendered panel and only refines: flip
|
|
* below when there is no room above, clamp into the viewport at the edges.
|
|
*
|
|
* An earlier version skipped straight to stage 2 — render at 0,0 hidden, measure,
|
|
* then position. When that measurement did not land the tooltip stayed invisible
|
|
* in the corner, which is the worst failure available: the information silently
|
|
* disappears. Now a failed refinement costs at most some clipping at a screen
|
|
* edge, and the tooltip is always visible.
|
|
*
|
|
* Because the pane behind it scrolls, the captured rect can go stale — so any
|
|
* scroll closes the tooltip rather than leaving it pointing at nothing.
|
|
*
|
|
* It is `aria-hidden` on purpose: the same fact is in the trigger's own
|
|
* accessible name (see the field input's `aria-label`), so a screen reader loses
|
|
* nothing when this never appears. Opens on hover AND on keyboard focus.
|
|
*/
|
|
export function Tooltip({
|
|
label,
|
|
className,
|
|
children,
|
|
}: {
|
|
label: string
|
|
/** Applied to the wrapper, which stands in for the element it replaces. */
|
|
className?: string
|
|
children: React.ReactNode
|
|
}) {
|
|
const anchor = useRef<HTMLSpanElement>(null)
|
|
const panel = useRef<HTMLDivElement>(null)
|
|
const [rect, setRect] = useState<Rect | null>(null)
|
|
const [placed, setPlaced] = useState<Placed | null>(null)
|
|
|
|
const open = () => {
|
|
const r = anchor.current?.getBoundingClientRect()
|
|
if (r) setRect({ top: r.top, bottom: r.bottom, left: r.left, width: r.width })
|
|
}
|
|
const close = () => {
|
|
setRect(null)
|
|
setPlaced(null)
|
|
}
|
|
|
|
useLayoutEffect(() => {
|
|
if (!rect) return
|
|
const p = panel.current?.getBoundingClientRect()
|
|
if (!p) return
|
|
const flip = rect.top - p.height - GAP < GAP
|
|
const top = flip ? rect.bottom + GAP : rect.top - p.height - GAP
|
|
const wanted = rect.left + rect.width / 2 - p.width / 2
|
|
const left = Math.max(GAP, Math.min(wanted, window.innerWidth - p.width - GAP))
|
|
// Only take over from the transform when the transform would actually be
|
|
// wrong; otherwise leave the first-paint placement exactly as it is.
|
|
const drifted = Math.abs(left - wanted) > 0.5 || flip
|
|
setPlaced(drifted ? { top, left } : null)
|
|
}, [rect, label])
|
|
|
|
useEffect(() => {
|
|
if (!rect) return
|
|
// `true` for the capture phase: the scroll happens inside the review pane,
|
|
// and scroll events on an inner element do not bubble to window.
|
|
window.addEventListener('scroll', close, true)
|
|
window.addEventListener('resize', close)
|
|
return () => {
|
|
window.removeEventListener('scroll', close, true)
|
|
window.removeEventListener('resize', close)
|
|
}
|
|
}, [rect])
|
|
|
|
return (
|
|
<>
|
|
<span
|
|
ref={anchor}
|
|
className={className}
|
|
// focus/blur do not bubble natively, but React synthesises these from
|
|
// focusin/focusout, which do — so focusing the inner input opens it.
|
|
onMouseEnter={open}
|
|
onMouseLeave={close}
|
|
onFocus={open}
|
|
onBlur={close}
|
|
>
|
|
{children}
|
|
</span>
|
|
|
|
{rect &&
|
|
createPortal(
|
|
<div
|
|
ref={panel}
|
|
role="presentation"
|
|
aria-hidden="true"
|
|
className="pointer-events-none fixed z-50 w-max max-w-[18rem] rounded-lg px-2.5 py-1.5 font-plex text-[11px] font-medium leading-snug shadow-lg"
|
|
style={
|
|
placed
|
|
? { top: placed.top, left: placed.left, backgroundColor: 'var(--ink)', color: 'var(--surface)' }
|
|
: {
|
|
top: rect.top - GAP,
|
|
left: rect.left + rect.width / 2,
|
|
// Centres and lifts with no measurement — this is what makes
|
|
// the first paint correct rather than hidden.
|
|
transform: 'translate(-50%, -100%)',
|
|
// Inverted from the surface it floats over: a panel sharing
|
|
// its host's background reads as part of the page instead of
|
|
// above it. Both directions clear AAA against their own text.
|
|
backgroundColor: 'var(--ink)',
|
|
color: 'var(--surface)',
|
|
}
|
|
}
|
|
>
|
|
{label}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
)
|
|
}
|