release: v0.4.0 — React 适配器、loading链式id稳定、dragThreshold、RTL适配、dedupe插件

- React 适配器子包:@metona-team/metona-toast/react — useToast() hook + 声明式 <Toast /> 组件,组件卸载自动清理;react 为 optional peerDependency,主包保持零依赖
- loading 链式转换改原地 update:id 稳定不重建 DOM,转换后 duration 恢复默认自动关闭;update() 支持 duration 变更动态重启计时器
- _emit 幽灵实例修复:beforeShow 拦截后的 toast 不再注册进 _toasts
- dragThreshold 配置项(默认 120px,原硬编码);RTL 容器 dir 属性 + 进度条/side 条/色条镜像
- dedupe 预设插件:相同 type+message 自动去重,支持 uninstall
- jest 环境隔离测试独立成 tests/ssr.test.ts(resetModules 不再污染共享模块状态);新增 tests/setup.ts 补 TextEncoder
- 文档:README/docs.html 补 React 适配器、dedupe、dragThreshold;CHANGELOG 更新;328 测试全过
This commit is contained in:
tianhao
2026-08-08 14:45:19 +08:00
parent 2937ce5e2d
commit bbff631f4f
41 changed files with 10761 additions and 161 deletions
+17 -7
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.3.0
* @version 0.4.0
*/
import { Toast, _containerCache } from './toast.js';
@@ -78,7 +78,10 @@ const meToast: MeToast = {
const t = new Toast(merged);
t.create();
this._toasts.set(t.id, t);
// beforeShow/onBeforeShow 拦截(返回 false)时 el 为 null,不注册幽灵实例
if (t.el !== null) {
this._toasts.set(t.id, t);
}
return t;
},
@@ -159,15 +162,22 @@ const meToast: MeToast = {
});
},
/**
* loading 链式转换 — 原地 update 同一实例(id 稳定,不重建 DOM)
* duration 从 0 恢复为默认值,使转换后的 toast 自动关闭
*/
_resolve(loadingToast: ToastInstance, type: string, message: string, opts: ToastOptions): ToastInstance | null {
const id = loadingToast.id;
const old = this._toasts.get(id);
const old = this._toasts.get(loadingToast.id);
if (!old) return null;
const position = old.config.position;
old.close();
old.update({
...opts,
type,
message,
duration: opts.duration ?? (old.config.duration || this._config.duration),
} as ToastOptions);
return this._emit({ ...opts, type, message, position } as ToastOptions);
return old;
},
confirm(message: string, opts: ConfirmOptions = {}): Promise<boolean> {
+3 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Constants — 常量定义
* @module constants
* @version 0.3.0
* @version 0.4.0
*/
import { ICONS } from './icons.js';
@@ -12,7 +12,7 @@ export { ICONS, LOCALES };
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
export const VERSION = '0.3.0';
export const VERSION = '0.4.0';
/**
* 默认配置
@@ -26,6 +26,7 @@ export const DEFAULTS = Object.freeze({
pauseOnHover: true,
closeOnClick: true,
draggable: true,
dragThreshold: 120,
showProgress: true,
progressDirection: 'horizontal' as const,
icon: true,
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast i18n — 国际化管理
* @module i18n
* @version 0.3.0
* @version 0.4.0
*/
import { LOCALES } from './constants.js';
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
* @version 0.3.0
* @version 0.4.0
* @description 107 个内置 SVG 图标
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
* @version 0.3.0
* @version 0.4.0
* @author thzxx
* @license MIT
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
* @version 0.3.0
* @version 0.4.0
* @description 内置 zh-CN / en-US 完整翻译
*/
+33 -2
View File
@@ -1,11 +1,12 @@
/**
* MetonaToast Plugins — 插件系统
* @module plugins
* @version 0.3.0
* @version 0.4.0
*/
import { t } from './i18n.js';
import type { Plugin, PluginManager as IPluginManager, PluginUtils } from './types.js';
import { Toast } from './toast.js';
import type { Plugin, PluginManager as IPluginManager, PluginUtils, ToastInstance } from './types.js';
/**
* 插件管理器
@@ -176,6 +177,36 @@ const presetPlugins: Record<string, Plugin> = {
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
},
},
/**
* 去重插件 — 相同 type + message 的 toast 自动合并(更新已有的一条)
*/
dedupe: {
name: 'dedupe',
version: '1.0.0',
description: '相同类型和消息的 Toast 自动去重,更新已有实例而非重复弹出',
_off: null as (() => void) | null,
install(this: Plugin) {
const off = Toast.on('beforeShow', (toast: ToastInstance) => {
const existing = Array.from(Toast._registry.values()).find(
t => !t.closing && t.type === toast.type && t.message === toast.message
);
if (existing) {
existing.update({ title: toast.title || existing.title });
return false;
}
return undefined;
});
(this as Record<string, unknown>)._off = off;
},
uninstall(this: Plugin) {
const off = (this as Record<string, unknown>)._off as (() => void) | null;
if (off) off();
(this as Record<string, unknown>)._off = null;
},
},
};
/**
+122
View File
@@ -0,0 +1,122 @@
/**
* MetonaToast React — React 适配器
* @module react
* @version 0.4.0
* @description useToast hook + 声明式 <Toast /> 组件。主包保持零依赖,React 为 optional peerDependency
*/
import { useEffect, useMemo, useRef } from 'react';
import MeToast from './index.js';
import type { ToastInstance, ToastOptions } from './types.js';
/** 支持声明式渲染的 Toast 类型 */
export type ReactToastType = 'success' | 'error' | 'warning' | 'info' | 'show' | (string & {});
export interface ToastProps extends Omit<ToastOptions, 'message' | 'type'> {
message: string;
type?: ReactToastType;
/** 组件卸载时自动移除该 Toast(默认 true)。设为 false 则 Toast 独立于组件生命周期 */
autoClose?: boolean;
}
const SHOW_METHODS = ['success', 'error', 'warning', 'info', 'show'] as const;
type ShowMethod = (typeof SHOW_METHODS)[number];
/** 有 id 的可追踪对象(ToastInstance / LoadingControl */
interface Tracked {
id: string;
}
const showToast = (method: ShowMethod, message: string, opts: Omit<ToastProps, 'message' | 'type' | 'autoClose'>): ToastInstance => {
switch (method) {
case 'success': return MeToast.success(message, opts);
case 'error': return MeToast.error(message, opts);
case 'warning': return MeToast.warning(message, opts);
case 'info': return MeToast.info(message, opts);
default: return MeToast.show(message, opts);
}
};
/**
* 创建绑定清理集合的 API — 组件卸载时自动移除本组件创建的 Toast。
* 纯函数,便于独立测试与扩展。
*/
export const createBoundApi = (cleanup: Set<string>): Record<string, unknown> => {
const track = <T extends Tracked | null>(fn: () => T): T => {
const result = fn();
if (result && result.id) cleanup.add(result.id);
return result;
};
const bound: Record<string, unknown> = {
...MeToast,
success: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.success(messageOrOpts, opts)),
error: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.error(messageOrOpts, opts)),
warning: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.warning(messageOrOpts, opts)),
info: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.info(messageOrOpts, opts)),
show: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.show(messageOrOpts, opts)),
loading: (messageOrOpts: string | ToastOptions, opts?: ToastOptions) =>
track(() => MeToast.loading(messageOrOpts, opts)),
// 通过 id 关闭时同步清理集合,避免卸载时重复移除
dismiss: (id?: string) => {
if (id) cleanup.delete(id);
return MeToast.dismiss(id);
},
removeToast: (id: string) => {
cleanup.delete(id);
return MeToast.removeToast(id);
},
};
return bound;
};
/**
* useToast — 返回 MeToast API,但本组件创建的 Toast 会在组件卸载时自动移除。
* 适合"通知随组件生命周期"的场景(如表单提交、异步任务页)。
*/
export const useToast = (): Record<string, unknown> => {
const cleanupRef = useRef<Set<string>>(new Set());
useEffect(() => {
const set = cleanupRef.current;
return () => {
set.forEach(id => MeToast.removeToast(id));
set.clear();
};
}, []);
return useMemo(() => createBoundApi(cleanupRef.current), []);
};
/**
* Toast — 声明式 Toast 组件。props 变化时更新内容(重新创建实例),
* 组件卸载时自动移除(autoClose 默认 true)。
*/
export const Toast = (props: ToastProps): null => {
const { message, type, autoClose = true, ...rest } = props;
const toastRef = useRef<ToastInstance | null>(null);
const restKey = JSON.stringify(rest);
useEffect(() => {
const method: ShowMethod = SHOW_METHODS.includes(type as ShowMethod) ? (type as ShowMethod) : 'show';
toastRef.current = showToast(method, message, rest);
return () => {
if (autoClose) toastRef.current?.remove();
toastRef.current = null;
};
// autoClose 不参与重建依赖:它只影响卸载时的清理行为
}, [message, type, restKey]);
return null;
};
export { MeToast };
+36 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Styles — 样式管理
* @module styles
* @version 0.3.0
* @version 0.4.0
*/
import type { ThemeConfig } from './types.js';
@@ -363,6 +363,41 @@ const generateCSS = (): string => {
transition: transform 0.1s linear;
}
/* RTL 镜像:进度条从右向左收缩、side 条移到右侧、类型色条翻转到右边 */
[dir="rtl"] .met-bar {
transform-origin: right;
}
[dir="rtl"] .met-side {
left: auto;
right: 0;
border-radius: 0 12px 12px 0;
}
[dir="rtl"] .met-progress-v {
left: auto;
right: 0;
border-radius: 0 12px 12px 0;
}
[dir="rtl"] .met-toast.met-success {
border-left-width: 1px;
border-right: 4px solid #10b981;
}
[dir="rtl"] .met-toast.met-error {
border-left-width: 1px;
border-right: 4px solid #ef4444;
}
[dir="rtl"] .met-toast.met-warning {
border-left-width: 1px;
border-right: 4px solid #f59e0b;
}
[dir="rtl"] .met-toast.met-info {
border-left-width: 1px;
border-right: 4px solid #3b82f6;
}
[dir="rtl"] .met-toast.met-loading {
border-left-width: 1px;
border-right: 4px solid #6366f1;
}
.met-container::-webkit-scrollbar { width: 6px; height: 6px; }
.met-container::-webkit-scrollbar-track { background: transparent; }
.met-container::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 3px; }
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
* @version 0.3.0
* @version 0.4.0
* @description confirm / prompt / progress / action 的 DOM 模板
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Themes — 主题管理
* @module themes
* @version 0.3.0
* @version 0.4.0
*/
import { THEMES } from './constants.js';
+17 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Toast — Toast 类
* @module toast
* @version 0.3.0
* @version 0.4.0
*/
import { generateId, escapeHTML } from './utils.js';
@@ -204,6 +204,8 @@ export class Toast implements ToastInstance {
el.className = `met-container ${position}`;
el.setAttribute('aria-label', 'Notifications');
el.setAttribute('role', 'region');
// RTL 语言设置容器文字方向,使内容/关闭按钮/进度条正确镜像
if (isRTL) el.setAttribute('dir', 'rtl');
const posStyles: Record<string, string> = {
'top-left': 'top:0;left:0;align-items:flex-start',
@@ -417,7 +419,8 @@ export class Toast implements ToastInstance {
el.releasePointerCapture(e.pointerId);
el.style.transition = '';
if (Math.abs(dx) > 120) {
const threshold = this.config.dragThreshold ?? 120;
if (Math.abs(dx) > threshold) {
el.style.transform = `translate(${dx * 2}px, ${dy}px) rotate(${dx * 0.2}deg)`;
el.style.opacity = '0';
setTimeout(() => this.close(true), 250);
@@ -535,6 +538,18 @@ export class Toast implements ToastInstance {
content.innerHTML = `${safeTitle}${safeMessage}`;
}
// duration 变更:重启或停止计时器(loading 0 → N 转换、动态调整时长等场景)
if (partial.duration !== undefined && partial.duration !== this.config.duration) {
this.config.duration = partial.duration;
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
this.rafId = null;
this.remaining = Math.max(0, partial.duration);
if (partial.duration > 0 && !this.paused) {
this.startedAt = Date.now();
this._startTimer();
}
}
// resetTimerOnUpdate: 更新内容后重置计时器
if (this.config.resetTimerOnUpdate && (this.config.duration || 0) > 0) {
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
+2 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast — 核心类型定义
* @module types
* @version 0.3.0
* @version 0.4.0
*/
// ========== 基础类型 ==========
@@ -72,6 +72,7 @@ export interface ToastConfig {
pauseOnHover?: boolean;
closeOnClick?: boolean;
draggable?: boolean;
dragThreshold?: number;
showProgress?: boolean;
progressDirection?: ToastProgressDirection;
icon?: boolean;
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Utils — 工具函数
* @module utils
* @version 0.3.0
* @version 0.4.0
*/
/**