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', 'aria-live': 'polite', 'aria-atomic': 'false' }); // live 容器兜底播报:条目自身 role 再定抢占级别 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: props.variant === 'danger' ? 'alert' : 'status', id: `ua-toast-${++seq}` }, // error 抢占播报,其余礼貌播报 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' }), // 错误 };