iter(g161): 通知支持同 key 原地更新

This commit is contained in:
HIsheR
2026-10-26 16:05:05 +08:00
parent 8b742061ec
commit 0bcf73fb76
6 changed files with 69 additions and 19 deletions
+36 -14
View File
@@ -1,7 +1,8 @@
import { h } from '@ui-arch/frame';
import type { NotificationProps } from './types.js';
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 的右上角分区不打架 ***/
@@ -21,8 +22,11 @@ function dismiss(item: HTMLElement): void {
window.setTimeout(() => item.isConnected && item.remove(), 320); // reduced-motion 下不触发 transition
}
/*** 通知卡片:标题 + 描述 + 可选操作区,返回 { el, close } 句柄;duration 为 0 表示常驻,非 0 时悬停 / 聚焦自动暂停计时 ***/
export function notification(props: NotificationProps): { el: HTMLElement; close: () => void } {
/*** 通知卡片:标题 + 描述 + 可选操作区,返回 { 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; // 缺省带关闭按钮
@@ -31,27 +35,43 @@ export function notification(props: NotificationProps): { el: HTMLElement; close
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' }),
h('span', { class: 'ua-notice-title' }, props.title),
closable ? closeBtn : null,
),
props.description ? h('div', { class: 'ua-notice-desc' }, props.description) : null,
props.action ? h('div', { class: 'ua-notice-action' }, props.action) : null,
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); };
/*** 暂停计时:扣掉已流逝部分,剩余时间留给续计 ***/
@@ -66,7 +86,9 @@ export function notification(props: NotificationProps): { el: HTMLElement; close
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 { el, close };
return handle;
}
+8
View File
@@ -493,6 +493,14 @@ export interface NotificationProps {
closable?: boolean; // 是否显示关闭按钮;缺省 true
action?: unknown; // 操作区内容(如按钮);缺省不渲染
placement?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; // 停靠角落;缺省 bottom-right(避开右上角 toast)
key?: string; // 同 key 复用同一张卡:再次调用会原地改标题/描述/语义色,不再堆新条
}
/*** 通知句柄:拿到卡片节点、关闭函数与原地更新入口 ***/
export interface NotificationHandle {
el: HTMLElement; // 卡片根节点
close: () => void; // 关闭(走退场动画);同 key 登记随之释放
update: (next: Partial<NotificationProps>) => void; // 原地更新标题 / 描述 / 语义色
}
/*** 文件选择入参 ***/