tooltip · minimal · 灵感来源:Refactoring UI(实底 + 发丝边)
悬停 350ms 才出现,键盘聚焦立即出现;Esc 可关
浮层不会挡住触发器:鼠标可以直接移上去
Tooltip (WCAG 1.4.13) · 极简提示 · 提示 / Minimal
做一个 Tooltip (WCAG 1.4.13)(极简提示):
- 同一份 props(content / children / placement)
- 实底 + 发丝边 + shadow-lg,没有模糊
- 停留、Esc 关闭与 aria 开关规则同玻璃提示
用这些 CSS 变量:--color-divider --color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --shadow-lg --text-2xs
灵感参考:Refactoring UI(实底 + 发丝边)--color-divider --color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-border --shadow-lg --text-2xs
焦点
(尚未接进整页场景)
/r/tooltip--minimal.json
免外壳的 markdown 版:/llms/components/tooltip--minimal.md--shadow-lg,没有模糊;小字在实底上对比度更稳aria-describedby 开关规则与玻璃提示一致import {
cloneElement,
useEffect,
useId,
useRef,
useState,
type FocusEventHandler,
type PointerEventHandler,
type ReactElement,
type ReactNode,
} from 'react';
import { useOverlay } from '~/lib/overlay';
import { motionMs } from '~/lib/motion';
import { Portal, layerStyle, useLayerPosition } from '~/lib/layer';
interface TriggerProps {
'aria-describedby'?: string;
onPointerEnter?: PointerEventHandler<HTMLElement>;
onPointerLeave?: PointerEventHandler<HTMLElement>;
onFocus?: FocusEventHandler<HTMLElement>;
onBlur?: FocusEventHandler<HTMLElement>;
}
interface Props {
content: ReactNode;
/** 必须是可聚焦的元素:按钮、链接或 tabIndex=0 的节点 */
children: ReactElement<TriggerProps>;
placement?: 'top' | 'bottom';
className?: string;
}
export function MinimalTooltip({ content, children, placement = 'top', className = '' }: Props) {
const id = useId();
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { mounted, entering, leaving } = useOverlay(open);
const { triggerRef, layerRef, pos } = useLayerPosition(mounted, { placement });
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
};
const showNow = () => {
clear();
setOpen(true);
};
const close = () => {
clear();
setOpen(false);
};
const showAfterDwell = () => {
clear();
timer.current = setTimeout(() => setOpen(true), motionMs('--dwell-tooltip', 350));
};
useEffect(() => clear, []);
// 悬停内容要能用 Esc 关掉(SC 1.4.13),焦点不进入浮层
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
const trigger = cloneElement(children, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node;
const own = (children as { ref?: unknown }).ref;
if (typeof own === 'function') own(node);
},
'aria-describedby': open ? id : undefined,
onPointerEnter: (e) => {
showAfterDwell();
children.props.onPointerEnter?.(e);
},
onPointerLeave: (e) => {
close();
children.props.onPointerLeave?.(e);
},
onFocus: (e) => {
showNow();
children.props.onFocus?.(e);
},
onBlur: (e) => {
close();
children.props.onBlur?.(e);
},
} as TriggerProps & { ref: (node: HTMLElement | null) => void });
return (
<>
{trigger}
{mounted && (
<Portal>
<div
ref={layerRef}
role="tooltip"
id={id}
style={layerStyle(pos)}
className={`
z-50 pointer-events-none
max-w-[240px] px-2.5 py-1.5 rounded-lg
text-[length:var(--text-2xs)] leading-snug
bg-[var(--color-surface)] border border-[var(--color-divider)]
shadow-[var(--shadow-lg)] text-[var(--color-text)]
transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
${entering || leaving ? 'opacity-0 translate-y-0.5' : 'opacity-100 translate-y-0'}
${className}
`}
>
{content}
</div>
</Portal>
)}
</>
);
}
src/components/tooltip/minimal/