Files
UI/packages/skin/ui/src/toast.ts
T

75 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h } from '@ui-arch/frame';
import type { ToastProps } from './types.js';
let host: HTMLElement | null = null; // 全局右上角堆叠容器:首条消息出现时懒创建
let seq = 0; // 自增序号:为每条 toast 生成唯一 id,辅助无障碍定位
const MAX_STACK = 4; // 同屏最多条数:超出即挤掉最早一条,避免刷屏淹页面
const PAUSE_FLOOR = 400; // 恢复时的最短剩余毫秒:悬停前已近归零也不至于松手即消失
/*** 确保堆叠容器存在:fixed 挂在 body 末尾,后续消息依次向上堆叠 ***/
function ensureHost(): HTMLElement {
if (host) return host;
host = h('div', { class: 'ua-toast-host' });
document.body.appendChild(host);
return host;
}
/*** 退场并移除:先摘 ua-open 触发过渡,transitionend 与 320ms 超时兜底二选一 ***/
function dismiss(item: HTMLElement): void {
item.classList.remove('ua-open');
item.addEventListener('transitionend', () => item.remove(), { once: true });
window.setTimeout(() => item.isConnected && item.remove(), 320); // reduced-motion 下 transition 不触发
}
/*** 弹出一条 toast:语义色由 variant 决定,duration 毫秒后自动淡出并移除节点 ***/
export function showToast(props: ToastProps): HTMLElement {
// —— 状态集中声明 ——
const duration = props.duration ?? 3000; // 停留时长毫秒
let timer = 0; // 自动关闭计时器句柄
let remain = duration; // 悬停暂停时记下的剩余时长
let startedAt = 0; // 本段计时起点,用于折算剩余
const item = h(
'div',
{ class: `ua-toast ua-toast-${props.variant ?? 'info'}${props.closable ? ' is-closable' : ''}${props.actionLabel ? ' is-action' : ''}`, role: 'status', id: `ua-toast-${++seq}` },
h('span', { class: 'ua-toast-msg' }, props.message),
props.actionLabel
? h('button', { type: 'button', class: 'ua-toast-action', onClick: () => { dismiss(item); props.onAction?.(); } }, props.actionLabel) // 操作按钮(如撤销):点完立即收起再回调
: null,
props.closable ? h('button', { type: 'button', class: 'ua-toast-close', 'aria-label': '关闭提示', onClick: () => dismiss(item) }, '×') : null,
);
const stack = ensureHost();
const oldest = stack.childElementCount >= MAX_STACK ? stack.firstElementChild : null;
if (oldest) dismiss(oldest as HTMLElement); // 超出上限让最早一条走正常退场:直接 remove 会让它凭空消失,与手动关闭两种手感
stack.appendChild(item);
requestAnimationFrame(() => item.classList.add('ua-open')); // 下一帧入场,保证过渡生效
const arm = (ms: number): void => { startedAt = Date.now(); timer = window.setTimeout(() => dismiss(item), ms); }; // 重新武装倒计时
/*** 暂停倒计时:扣掉本段已走时长,清空句柄(重复进入不会二次扣减) ***/
const pause = (): void => {
if (!timer) return; // 已暂停 / 常驻(duration 为 0)时无计时器可停
window.clearTimeout(timer);
timer = 0;
remain = Math.max(PAUSE_FLOOR, remain - (Date.now() - startedAt));
};
/*** 恢复倒计时:按剩余时长重新武装 ***/
const resume = (): void => { if (!timer && remain > 0) arm(remain); };
if (duration > 0) {
arm(duration);
item.addEventListener('pointerenter', pause); // 悬停停留阅读:倒计时暂停
item.addEventListener('pointerleave', resume); // 移出后按剩余时长继续
item.addEventListener('focusin', pause); // 键盘 Tab 进关闭 / 操作按钮时同样暂停
item.addEventListener('focusout', (e) => {
if (!item.contains(e.relatedTarget as Node | null)) resume(); // 焦点还在卡片内部(按钮间切换)时不恢复
});
}
return item;
}
/*** toast 语义快捷工厂:toast.info / success / warning / error(message, duration?) ***/
export const toast = {
info: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'info' }), // 中性信息
success: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'success' }), // 成功反馈
warning: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'warning' }), // 警告
error: (message: string, duration?: number): HTMLElement => showToast({ message, duration, variant: 'danger' }), // 错误
};