Files
phokus/src/components/Tooltip.tsx
T
LyAhn d619b01f2e
github/actions/ci GitHub Actions CI finished: success
refactor(ui): replace native app tooltips
Wrap app controls with the shared Tooltip component and use cursor-positioned tooltip placement for icon buttons, chips, path labels, and media controls.

Leave native title attributes on the window chrome buttons so Minimize, Maximize, and Close keep platform-style behavior.
2026-06-30 23:25:43 +01:00

165 lines
5.7 KiB
TypeScript

import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/**
* Lightweight custom tooltip — fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
* entry position; `followCursor` keeps tracking the pointer.
*/
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
block = false,
anchorToCursor = false,
followCursor = false,
disabled = false,
className = "",
children,
}: {
label: ReactNode;
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
/** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean;
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return;
clear();
if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
useEffect(() => {
setMounted(true);
return clear;
}, []);
useEffect(() => {
if (disabled) hide();
}, [disabled]);
const Wrapper = block ? "div" : "span";
const cursorTooltip = (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Portaled + fixed so transformed parents and scroll containers cannot
// distort cursor-anchored tooltip coordinates.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
);
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</Wrapper>
);
}