tooltip · glass-apple · 灵感来源:Apple HIG Tooltips / WAI-ARIA Tooltip pattern
悬停 350ms 才出现,键盘聚焦立即出现;Esc 可关
浮层不会挡住触发器:鼠标可以直接移上去
Tooltip (WCAG 1.4.13) · 玻璃提示 · 提示 / Glass
做一个 Tooltip (WCAG 1.4.13)(玻璃提示):
- 悬停等 --dwell-tooltip(350ms)才出,键盘聚焦立即出;移出 / 失焦 / Esc 立即关
- 只读信息,不接收焦点;aria-describedby 仅在打开时挂到触发器
- 触发器必须可聚焦且能接 ref;内容只放一句说明
- 浮层 portal 到 body:玻璃祖先的 backdrop-filter 会生成包含块,就地定位会被裁切
用这些 CSS 变量:--color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --shadow-lg --text-2xs
灵感参考:Apple HIG Tooltips / WAI-ARIA Tooltip pattern--color-surface --color-text --color-text-muted --dur-fast --ease-standard --glass-bg --glass-border --shadow-lg --text-2xs
焦点
/r/tooltip--glass-apple.json
免外壳的 markdown 版:/llms/components/tooltip--glass-apple.md--dwell-tooltip(350ms)才出现,键盘聚焦立即出现;移出或失焦立即关闭,Esc 也能关(SC 1.4.13)pointer-events: none,焦点始终留在触发器上aria-describedby 只在打开时挂到触发器,关闭即摘——读屏不会常驻描述children 需能接 ref(原生 button / a 或 forwardRef 组件)backdrop-filter 会生成包含块,就地 absolute 定位必翻车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 GlassAppleTooltip({ 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(--glass-bg)] border border-[var(--glass-border)] backdrop-blur-xl
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/glass-apple/