"use client"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; type TipState = { text: string; x: number; y: number; place: "top" | "bottom"; } | null; /** * 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。 * 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。 */ export default function TipHost() { const [tip, setTip] = useState(null); const tipRef = useRef(null); const activeEl = useRef(null); useEffect(() => { const hide = () => { activeEl.current = null; setTip(null); }; const placeTip = (el: Element, text: string) => { const rect = el.getBoundingClientRect(); const preferBottom = rect.top < 48; const x = rect.left + rect.width / 2; const y = preferBottom ? rect.bottom + 8 : rect.top - 8; setTip({ text, x, y, place: preferBottom ? "bottom" : "top", }); }; const onOver = (e: Event) => { const t = e.target; if (!(t instanceof Element)) return; const host = t.closest("[data-tip]"); if (!host) { if (activeEl.current) hide(); return; } const text = host.getAttribute("data-tip")?.trim(); if (!text) { hide(); return; } activeEl.current = host; placeTip(host, text); }; const onOut = (e: Event) => { if (!(e instanceof MouseEvent)) return; const related = e.relatedTarget; if ( related instanceof Node && activeEl.current && activeEl.current.contains(related) ) { return; } hide(); }; const onScrollOrResize = () => { if (!activeEl.current) return; const text = activeEl.current.getAttribute("data-tip")?.trim(); if (!text) { hide(); return; } placeTip(activeEl.current, text); }; // mouseover 冒泡,便于 closest;立即显示,无延时 document.addEventListener("mouseover", onOver); document.addEventListener("mouseout", onOut); document.addEventListener("focusin", onOver); document.addEventListener("focusout", onOut); window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); return () => { document.removeEventListener("mouseover", onOver); document.removeEventListener("mouseout", onOut); document.removeEventListener("focusin", onOver); document.removeEventListener("focusout", onOut); window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }; }, []); // 测量后微调,避免贴边裁切 useEffect(() => { if (!tip || !tipRef.current) return; const el = tipRef.current; const w = el.offsetWidth; const pad = 10; let left = tip.x; const half = w / 2; if (left - half < pad) left = pad + half; if (left + half > window.innerWidth - pad) { left = window.innerWidth - pad - half; } el.style.left = `${left}px`; }, [tip]); if (!tip) return null; return createPortal(
{tip.text}
, document.body ); }