91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Inline "?"" hint icon with a hover tooltip.
|
|
*
|
|
* Use this for any UI element whose meaning isn't obvious from its label —
|
|
* stat boxes, tab names, jargon-y settings. Goal is the same as the (i)
|
|
* dots in Linear / Hyperliquid / Coinglass: small, ignorable, one hover
|
|
* away from a one-line plain-English explanation.
|
|
*
|
|
* Design rules:
|
|
* * `text` MUST be one short sentence — never paste long docs in here.
|
|
* If you need a paragraph, you're explaining the wrong thing.
|
|
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
|
|
* should disappear visually until the user actively scans for help.
|
|
* * The tooltip uses CSS hover positioning — no portals. The only JS is
|
|
* an on-reveal measurement that clamps the bubble inside the viewport
|
|
* (a centred bubble on an icon near the screen edge used to hang
|
|
* off-screen, worst on phones). Trade-off: it's still clipped by the
|
|
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
|
|
* a `<table>` cell should add `overflow: visible` to that row/td.
|
|
*
|
|
* Usage:
|
|
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
|
|
*
|
|
* <InfoTip
|
|
* text="Last 24h of cycles, averaged. Smooths the noise of one print."
|
|
* placement="bottom" // default 'top'
|
|
* />
|
|
*/
|
|
|
|
import { useRef } from 'react'
|
|
import type { CSSProperties } from 'react'
|
|
|
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
|
|
|
interface Props {
|
|
text: string
|
|
placement?: Placement
|
|
/** Tip max-width in px. Default 240 — fits ~2 short lines. */
|
|
width?: number
|
|
/** Tweak the icon's vertical alignment for fussy contexts. */
|
|
iconStyle?: CSSProperties
|
|
}
|
|
|
|
export default function InfoTip({
|
|
text,
|
|
placement = 'top',
|
|
width = 240,
|
|
iconStyle,
|
|
}: Props) {
|
|
const rootRef = useRef<HTMLSpanElement>(null)
|
|
|
|
// Clamp the bubble inside the viewport when it's revealed. The bubble is
|
|
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
|
|
// Measured at shift=0, then the needed horizontal offset is written to the
|
|
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
|
|
function clampIntoViewport() {
|
|
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
|
|
if (!bubble) return
|
|
bubble.style.setProperty('--tip-shift', '0px')
|
|
const r = bubble.getBoundingClientRect()
|
|
const vw = document.documentElement.clientWidth
|
|
const margin = 8
|
|
let shift = 0
|
|
if (r.left < margin) shift = margin - r.left
|
|
else if (r.right > vw - margin) shift = (vw - margin) - r.right
|
|
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
|
|
}
|
|
|
|
return (
|
|
<span
|
|
ref={rootRef}
|
|
className={`infotip infotip-${placement}`}
|
|
// role=button is a slight overstatement but it lets screen-reader users
|
|
// know there's a thing to tab to. The actual content is in data-tip and
|
|
// also as a regular <span> child for non-CSS-tooltip fallback.
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={text}
|
|
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
|
|
onMouseEnter={clampIntoViewport}
|
|
onFocus={clampIntoViewport}
|
|
onTouchStart={clampIntoViewport}
|
|
>
|
|
<span aria-hidden="true" className="infotip-icon">?</span>
|
|
<span className="infotip-bubble" role="tooltip">{text}</span>
|
|
</span>
|
|
)
|
|
}
|