import { h } from '@ui-arch/frame'; import type { NotificationHandle, NotificationProps } from './types.js'; const hosts = new Map(); // 每个角落一个堆叠容器,首次用到该角落时懒创建 const byKey = new Map(); // 同 key 只留一张卡,二次调用走原地更新 const MAX_STACK = 3; // 同一角落最多条数,超出挤掉最早一条 /*** 确保指定角落的堆叠容器存在:fixed 挂 body 末尾,与 toast 的右上角分区不打架 ***/ function ensureHost(place: string): HTMLElement { const hit = hosts.get(place); if (hit) return hit; const el = h('div', { class: `ua-notice-host is-${place}` }); document.body.appendChild(el); hosts.set(place, el); return el; } /*** 退场并移除:摘 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 } /*** 通知卡片:标题 + 描述 + 可选操作区,返回 { el, close, update } 句柄;duration 为 0 表示常驻,非 0 时悬停 / 聚焦自动暂停计时;带 key 时二次调用原地更新同一张卡 ***/ export function notification(props: NotificationProps): NotificationHandle { const dup = props.key ? byKey.get(props.key) : undefined; if (dup) { dup.update(props); return dup; } // 同 key:只更新内容,不新增一条 // —— 状态集中声明 —— const duration = props.duration ?? 0; // 缺省常驻:通知通常需要用户读完 const closable = props.closable ?? true; // 缺省带关闭按钮 let timer = 0; // 自动关闭计时器句柄 let remain = duration; // 剩余毫秒:暂停时扣减,续计时接着走 let startedAt = 0; // 本轮计时起点,用于折算暂停前已流逝的时间 let paused = false; // 是否因悬停 / 聚焦而暂停 const closeBtn = h('button', { type: 'button', class: 'ua-notice-close', 'aria-label': '关闭通知' }, '×') as HTMLButtonElement; // 关闭按钮,回调在下方绑定 const titleEl = h('span', { class: 'ua-notice-title' }, props.title) as HTMLElement; const actionEl = props.action ? h('div', { class: 'ua-notice-action' }, props.action) : null; let descEl = props.description ? h('div', { class: 'ua-notice-desc' }, props.description) : null; /*** 换语义色:整串重设 class,保住已挂上的入场态 ***/ const paint = (variant: string): void => { el.className = `ua-notice ua-notice-${variant}${el.classList.contains('ua-open') ? ' ua-open' : ''}`; }; const el = h( 'div', { class: `ua-notice ua-notice-${props.variant ?? 'info'}`, role: 'alert' }, h('div', { class: 'ua-notice-head' }, h('span', { class: 'ua-notice-dot', 'aria-hidden': 'true' }), titleEl, closable ? closeBtn : null), descEl, actionEl, ) as HTMLElement; /*** 关闭:清计时器后走退场动画,顺手释放 key 登记 ***/ const close = () => { window.clearTimeout(timer); if (props.key && byKey.get(props.key) === handle) byKey.delete(props.key); dismiss(el); }; closeBtn.addEventListener('click', () => close()); /*** 原地更新:改标题 / 描述 / 语义色;描述按需插入到操作区之前 ***/ const update = (next: Partial): void => { if (next.title != null) titleEl.textContent = next.title; if (next.variant) paint(next.variant); if (next.description != null) { if (!descEl) { // 首次补描述:插在操作区之前,没有操作区就追加 descEl = h('div', { class: 'ua-notice-desc' }); if (actionEl) el.insertBefore(descEl, actionEl); else el.appendChild(descEl); } descEl.textContent = next.description; } }; /*** 起表:按剩余毫秒设定时器 ***/ const arm = () => { if (remain <= 0) return; startedAt = Date.now(); timer = window.setTimeout(() => dismiss(el), remain); }; /*** 暂停计时:扣掉已流逝部分,剩余时间留给续计 ***/ const pause = () => { if (paused || remain <= 0) return; paused = true; window.clearTimeout(timer); remain -= Date.now() - startedAt; }; /*** 续计:离开后按剩余毫秒重新起表 ***/ const resume = () => { if (!paused) return; paused = false; arm(); }; el.addEventListener('pointerenter', pause); // 悬停阅读时不倒计时 el.addEventListener('pointerleave', resume); el.addEventListener('focusin', pause); // 键盘聚焦到关闭/操作按钮同样暂停 el.addEventListener('focusout', resume); const stack = ensureHost(props.placement ?? 'bottom-right'); // 缺省右下,与右上角 toast 错开 if (stack.childElementCount >= MAX_STACK) stack.firstElementChild?.remove(); // 超出上限挤掉最早一条 stack.appendChild(el); const handle: NotificationHandle = { el, close, update }; if (props.key) byKey.set(props.key, handle); requestAnimationFrame(() => el.classList.add('ua-open')); // 下一帧入场,保证过渡生效 if (duration > 0) arm(); // 常驻(0)不设定时器 return handle; }