Files
UI/packages/skin/ui/src/notification.ts
T
2026-10-26 16:05:05 +08:00

95 lines
5.1 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 { NotificationHandle, NotificationProps } from './types.js';
const hosts = new Map<string, HTMLElement>(); // 每个角落一个堆叠容器,首次用到该角落时懒创建
const byKey = new Map<string, NotificationHandle>(); // 同 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<NotificationProps>): 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;
}