MioUI

Tooltip

悬停 400ms 或键盘聚焦时出现的小提示,popover 底色、12px 字号,可带快捷键。

安装

components/ui/tooltip.tsx
'use client';
import { AnimatePresence, motion } from 'motion/react';
import {
  cloneElement,
  isValidElement,
  useEffect,
  useId,
  useRef,
  useState,
  type ReactElement,
  type ReactNode,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_SNAPPY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { Portal } from './overlay';

export type TooltipSide = 'top' | 'bottom' | 'left' | 'right';

export interface TooltipProps {
  /** 提示内容;为空时只渲染 children */
  content: ReactNode;
  /** 单个可聚焦元素(按钮 / 链接);会注入 aria-describedby */
  children: ReactNode;
  side?: TooltipSide;
  /** 悬停延迟,默认 400ms(键盘聚焦立即显示) */
  delay?: number;
  /** 右侧快捷键提示,如 "⌘K" */
  shortcut?: ReactNode;
  disabled?: boolean;
  className?: string;
}

const GAP = 6;

function place(r: DOMRect, side: TooltipSide) {
  switch (side) {
    case 'top':
      return { left: r.left + r.width / 2, top: r.top - GAP, transform: 'translate(-50%, -100%)' };
    case 'bottom':
      return { left: r.left + r.width / 2, top: r.bottom + GAP, transform: 'translate(-50%, 0)' };
    case 'left':
      return { left: r.left - GAP, top: r.top + r.height / 2, transform: 'translate(-100%, -50%)' };
    case 'right':
      return { left: r.right + GAP, top: r.top + r.height / 2, transform: 'translate(0, -50%)' };
  }
}

const enterOffset: Record<TooltipSide, { x?: number; y?: number }> = {
  top: { y: 3 },
  bottom: { y: -3 },
  left: { x: 3 },
  right: { x: -3 },
};

/** 悬停 / 聚焦提示:400ms 延迟,popover 底,12px 小字 */
export function Tooltip({ content, children, side = 'top', delay = 400, shortcut, disabled, className }: TooltipProps) {
  const id = useId();
  const anchor = useRef<HTMLSpanElement>(null);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState<ReturnType<typeof place> | null>(null);
  const reduce = useReducedMotionPref();

  const show = (immediate?: boolean) => {
    if (disabled || content == null || content === '') return;
    clearTimeout(timer.current);
    const go = () => {
      const el = anchor.current?.firstElementChild ?? anchor.current;
      if (!el) return;
      setPos(place(el.getBoundingClientRect(), side));
      setOpen(true);
    };
    if (immediate) go();
    else timer.current = setTimeout(go, delay);
  };
  const hide = () => {
    clearTimeout(timer.current);
    setOpen(false);
  };

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
    window.addEventListener('scroll', hide, true);
    window.addEventListener('resize', hide);
    document.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('scroll', hide, true);
      window.removeEventListener('resize', hide);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const child = isValidElement(children)
    ? cloneElement(children as ReactElement<{ 'aria-describedby'?: string }>, { 'aria-describedby': open ? id : undefined })
    : children;

  return (
    <>
      <span
        ref={anchor}
        className="inline-flex"
        onPointerEnter={(e) => e.pointerType === 'mouse' && show()}
        onPointerLeave={hide}
        onPointerDown={hide}
        onFocus={(e) => {
          if ((e.target as HTMLElement).matches?.(':focus-visible')) show(true);
        }}
        onBlur={hide}
      >
        {child}
      </span>
      <Portal>
        <AnimatePresence>
          {open && pos && (
            <div className="pointer-events-none fixed z-[70]" style={pos}>
              <motion.div
                id={id}
                role="tooltip"
                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, ...enterOffset[side] }}
                animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
                exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.08 } }}
                transition={reduce ? { duration: 0 } : SPRING_SNAPPY}
                className={twMerge(
                  'flex max-w-[240px] items-center gap-2 rounded-md bg-popover-surface px-2 py-1 text-[12px] leading-snug text-text-primary shadow-popover',
                  className,
                )}
              >
                <span>{content}</span>
                {shortcut && <span className="font-medium text-text-muted tabular-nums">{shortcut}</span>}
              </motion.div>
            </div>
          )}
        </AnimatePresence>
      </Portal>
    </>
  );
}

用法

import { Tooltip } from "@/components/ui/tooltip";

<Tooltip content="设置" shortcut="⌘," side="right">
  <Button variant="ghost" size="icon" aria-label="设置"><Gear /></Button>
</Tooltip>
属性类型默认说明
contentReactNode—提示内容
sidetop bottom left righttop方向
delaynumber400悬停延迟(ms)
shortcutReactNode—右侧快捷键

本页目录