release: v0.3.0 — 自定义动画真实生效、max超限修复、钩子系统完整化、destroy全量清理

- 自定义动画:animations.register() 注册的动画动态注入 keyframes 真实生效(此前仅存 Map 实际 fallback slide)
- max 超限:通过实例注册表真正 close() 最早的 toast(此前仅删内存记录,DOM 残留)
- 钩子收敛:beforeClose/beforeUpdate 支持返回 false 拦截;接入 click/hover/dragStart/dragEnd/animationStart/animationEnd/progressStart/progressEnd/configChange/themeChange/localeChange/beforeInit/afterInit/beforeDestroy/afterDestroy;HOOK_NAMES 移除永不触发的声明
- destroy() 全量清理:hooks、实例注册表、全部 metona-toast-* 样式标签;动画注册表真正清空
- applyThemeVariables 重复注入修复;init() 支持插件对象并复活 _destroyed;AnimationConfig 类型放宽
- VERSION 统一到 constants.ts 唯一维护;删除失效 scripts;新增 .gitattributes 统一 LF
- docs.html 补 v0.2.1 新 API 文档;CHANGELOG 更新;测试 309 用例全过
This commit is contained in:
tianhao
2026-08-08 14:23:41 +08:00
parent cb8dd623db
commit 2937ce5e2d
30 changed files with 1098 additions and 372 deletions
+9
View File
@@ -0,0 +1,9 @@
# 统一文本文件行尾为 LF(跨平台协作)
* text=auto eol=lf
# 二进制文件不进行行尾转换
*.png binary
*.jpg binary
*.ico binary
*.gif binary
*.svg text
+22
View File
@@ -2,6 +2,28 @@
All notable changes to MetonaToast will be documented in this file.
## [0.3.0] - 2026-08-08
### Fixed
- **自定义动画真实生效**`animations.register()` 注册的动画此前仅写入内存 Map,实际渲染始终 fallback 到 slide。现在注册表驱动 `_buildClassName` 并动态注入对应 `@keyframes` + 类规则,README 的注册教程名实相符
- **`max` 超限真正关闭最早的 toast**:此前超限时仅从内存 Map 删除记录,DOM 上的旧 toast 依然显示。现在通过实例注册表找到最早的 toast 并真正 `close()`(触发离场动画与生命周期回调)
- **`destroy()` 全量清理**:新增清理 `Toast._hooks` 钩子、`Toast._registry` 实例注册表、`metona-toast-theme-css` / `metona-toast-custom-styles` / `metona-toast-anim-styles` 样式标签;动画注册表改为真正清空
- **`applyThemeVariables` 重复注入**:修复多次调用追加相同 id 的 style 标签问题(改为先移除旧元素)
- **`init()` 复活**`destroy()` 后调用 `init()` 重置 `_destroyed` 标志,恢复可初始化状态
- **失效脚本清理**:删除 `docs`typedoc 未安装)、`example`(examples/ 目录不存在)脚本,新增 `serve` 脚本
- **`AnimationConfig.enter/leave` 类型放宽**:支持 `opacity: 0` 等数值属性(此前仅 `string`,README 示例在 TS 严格模式下报错)
### Added
- **钩子系统完整化**:此前 `HOOK_NAMES` 声明 20+ 钩子但多数永不触发。现在全部声明均有触发点:
- 拦截语义:`beforeClose` / `beforeUpdate` 返回 `false` 可分别阻止关闭、更新
- 新触发点:`click` / `hover` / `dragStart` / `dragEnd` / `animationStart` / `animationEnd` / `progressStart` / `progressEnd` / `configChange` / `themeChange` / `localeChange` / `beforeInit` / `afterInit` / `beforeDestroy` / `afterDestroy`
- **`init({ plugins })` 支持插件对象**:与 `use()` 一致,可传 `string | Plugin`
### Changed
- **VERSION 唯一来源**:版本号统一在 `src/constants.ts` 维护,`api.ts` / `index.ts` 引用,消除三处硬编码漂移
- **`HOOK_NAMES` 收敛**:移除永不触发的 `progressUpdate` / `custom` 声明
- 版本升级至 **v0.3.0**(含行为增强,按 semver minor 处理)
## [0.2.1] - 2026-07-25
### Fixed
+212 -99
View File
@@ -5,7 +5,7 @@ Object.defineProperty(exports, '__esModule', { value: true });
/**
* MetonaToast Utils — 工具函数
* @module utils
* @version 0.2.1
* @version 0.3.0
*/
/**
* 生成唯一ID
@@ -42,7 +42,7 @@ const prefersDark = () => {
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
* @version 0.2.1
* @version 0.3.0
* @description 107 个内置 SVG 图标
*/
const ICONS = {
@@ -539,7 +539,7 @@ const ICONS = {
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
* @version 0.2.1
* @version 0.3.0
* @description 内置 zh-CN / en-US 完整翻译
*/
const LOCALES = {
@@ -1026,8 +1026,12 @@ const LOCALES = {
/**
* MetonaToast Constants — 常量定义
* @module constants
* @version 0.2.1
* @version 0.3.0
*/
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
const VERSION = '0.3.0';
/**
* 默认配置
*/
@@ -1285,11 +1289,15 @@ const THEMES = {
closeHoverBg: 'rgba(245, 158, 11, 0.1)',
},
};
/**
* 动画类型
*/
const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom', 'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
/**
* MetonaToast Styles — 样式管理
* @module styles
* @version 0.2.1
* @version 0.3.0
*/
// 样式缓存
let styleElement = null;
@@ -1743,7 +1751,7 @@ const injectStyles = () => {
/**
* MetonaToast Themes — 主题管理
* @module themes
* @version 0.2.1
* @version 0.3.0
*/
// 当前主题状态
let currentTheme = 'auto';
@@ -2087,7 +2095,7 @@ const themeUtils = {
/**
* MetonaToast i18n — 国际化管理
* @module i18n
* @version 0.2.1
* @version 0.3.0
*/
// 当前语言状态
let currentLocale = 'zh-CN';
@@ -2643,10 +2651,126 @@ const i18nUtils = {
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 内置动画(keyframes 由 styles.ts 注入,无需动态生成)
const BUILTIN_ANIMATIONS = new Set(ANIMATION_TYPES);
// 动态注入的自定义动画样式(惰性创建)
let animStyleElement = null;
const injectedAnimations = new Set();
/**
* 将 CSS 属性对象转换为内联 CSS 字符串
*/
const toCss = (props) => Object.entries(props)
.map(([key, value]) => `${key}:${value}`)
.join(';');
/**
* 为自定义动画生成入场 keyframes + 类规则
*/
const buildAnimationCSS = (name, config) => {
const enter = toCss(config.enter);
return `
@keyframes met-${name}-in {
from { ${enter}; }
}
.met-anim-${name}.met-toast { opacity: 0; }
.met-anim-${name}.met-toast.met-show {
animation: met-${name}-in ${config.duration}ms ${config.easing} forwards;
}
`;
};
/**
* 确保自定义动画的 CSS 已注入文档(幂等,内置动画自动跳过)
*/
const ensureAnimationCSS = (name) => {
if (typeof document === 'undefined')
return;
if (BUILTIN_ANIMATIONS.has(name))
return;
const config = animationMap.get(name);
if (!config || injectedAnimations.has(name))
return;
if (!animStyleElement) {
animStyleElement = document.getElementById('metona-toast-anim-styles');
if (!animStyleElement) {
animStyleElement = document.createElement('style');
animStyleElement.id = 'metona-toast-anim-styles';
document.head.appendChild(animStyleElement);
}
}
animStyleElement.textContent += buildAnimationCSS(name, config);
injectedAnimations.add(name);
};
/**
* 检查动画是否存在(内置或自定义)
*/
const hasAnimation = (name) => animationMap.has(name);
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
injectedAnimations.clear();
animStyleElement = null;
},
};
/**
* MetonaToast Toast — Toast 类
* @module toast
* @version 0.2.1
* @version 0.3.0
*/
/**
* Toast 类 — 核心通知组件
@@ -2674,8 +2798,9 @@ class Toast {
}
/**
* 触发钩子 — 如果任意钩子返回 false 则整体返回 false
* toast 参数可选:全局事件(configChange/themeChange 等)不关联具体 toast
*/
static trigger(name, toast) {
static trigger(name, toast = null) {
const list = this._hooks.get(name);
if (!list || list.length === 0)
return true;
@@ -2690,7 +2815,7 @@ class Toast {
console.error('Hook error:', name, e);
if (Toast._onError) {
try {
Toast._onError({ hook: name, error: e, toast });
Toast._onError({ hook: name, error: e, toast: toast });
}
catch (_e) { /* noop */ }
}
@@ -2745,6 +2870,7 @@ class Toast {
}
}
injectStyles();
ensureAnimationCSS(this.config.animation || 'slide');
const container = this._getContainer();
const { theme, c, t } = this._palette();
this._limitToasts(container);
@@ -2764,6 +2890,7 @@ class Toast {
else {
container.appendChild(el);
}
Toast._registry.set(this.id, this);
this._bindEvents(el);
this._startTimer();
// 进入动画
@@ -2831,18 +2958,22 @@ class Toast {
_limitToasts(container) {
const max = this.config.max || 6;
const list = Array.from(container.querySelectorAll('.met-toast'));
if (list.length >= max) {
const first = list[0];
const id = first?.dataset.id;
if (id && Toast._removeToast) {
while (list.length >= max) {
const el = list.shift();
const id = el?.dataset.id;
if (!id)
continue;
const existing = Toast._registry.get(id);
if (existing) {
existing.close(true);
}
else if (Toast._removeToast) {
Toast._removeToast(id);
}
}
}
_buildClassName(theme) {
const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide';
const anim = hasAnimation(this.config.animation || '') ? this.config.animation : 'slide';
return [
'met-toast',
`met-${this.type}`,
@@ -2919,6 +3050,7 @@ class Toast {
this.close();
return;
}
Toast.trigger('click', this);
if (this.config.closeOnClick && !target.closest('.met-close')) {
if (typeof this.config.onClick === 'function') {
try {
@@ -2935,8 +3067,14 @@ class Toast {
el.addEventListener('click', eventHandler);
this._cleanups.push(() => el.removeEventListener('click', eventHandler));
if (this.config.pauseOnHover && (this.config.duration || 0) > 0) {
const mouseEnter = () => this._pause();
const mouseLeave = () => this._resume();
const mouseEnter = () => {
Toast.trigger('hover', this);
this._pause();
};
const mouseLeave = () => {
Toast.trigger('hover', this);
this._resume();
};
el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);
this._cleanups.push(() => {
@@ -2944,6 +3082,15 @@ class Toast {
el.removeEventListener('mouseleave', mouseLeave);
});
}
// 入场动画生命周期钩子(离场为过渡/内联样式,不产生 animation 事件)
const animStart = () => Toast.trigger('animationStart', this);
const animEnd = () => Toast.trigger('animationEnd', this);
el.addEventListener('animationstart', animStart);
el.addEventListener('animationend', animEnd);
this._cleanups.push(() => {
el.removeEventListener('animationstart', animStart);
el.removeEventListener('animationend', animEnd);
});
if (this.config.draggable) {
this._bindDrag(el);
}
@@ -2959,6 +3106,7 @@ class Toast {
el.setPointerCapture(e.pointerId);
el.style.transition = 'none';
this._pause();
Toast.trigger('dragStart', this);
};
const move = (e) => {
if (!dragging)
@@ -2985,6 +3133,7 @@ class Toast {
this._resume();
}
dx = dy = 0;
Toast.trigger('dragEnd', this);
};
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
@@ -3003,6 +3152,7 @@ class Toast {
if (!resuming) {
this.startedAt = Date.now();
this.remaining = this.config.duration || 0;
Toast.trigger('progressStart', this);
}
const tick = () => {
if (this.paused || this.closing)
@@ -3017,6 +3167,7 @@ class Toast {
this.barEl.style.transform = t;
}
if (this.remaining <= 0) {
Toast.trigger('progressEnd', this);
this.close();
return;
}
@@ -3050,7 +3201,9 @@ class Toast {
this._startTimer(true);
}
update(partial) {
Toast.trigger('beforeUpdate', this);
// beforeUpdate 钩子 — 返回 false 可阻止更新
if (!Toast.trigger('beforeUpdate', this))
return this;
const typeChanged = partial.type && partial.type !== this.type;
if (partial.type)
this.type = partial.type;
@@ -3147,14 +3300,17 @@ class Toast {
this.el.parentNode.removeChild(this.el);
}
this.el = null;
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
close(immediate = false) {
if (this.closing)
return;
// beforeClose 钩子 — 返回 false 可阻止关闭
if (!Toast.trigger('beforeClose', this))
return;
this.closing = true;
Toast.trigger('beforeClose', this);
if (this.rafId !== null)
cancelAnimationFrame(this.rafId);
this._cleanups.forEach(fn => { try {
@@ -3210,12 +3366,15 @@ class Toast {
}
}
Toast.trigger('afterClose', this);
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
}
// 静态钩子系统
Toast._hooks = new Map();
// 实例注册表 — 用于通过 id 反查实例(max 超限移除、外部接管等)
Toast._registry = new Map();
// 由 api.ts 注入的回调,通过 setCallbacks() 设置
Toast._onError = null;
Toast._removeToast = null;
@@ -3227,7 +3386,7 @@ const _containerCache = new Map();
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
* @version 0.2.1
* @version 0.3.0
* @description confirm / prompt / progress / action 的 DOM 模板
*/
/**
@@ -3299,7 +3458,7 @@ const actionHTML = (actions) => {
/**
* MetonaToast Plugins — 插件系统
* @module plugins
* @version 0.2.1
* @version 0.3.0
*/
/**
* 插件管理器
@@ -3519,7 +3678,7 @@ const defaultPluginManager = new PluginManager();
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.2.1
* @version 0.3.0
*/
/**
* 参数标准化工具
@@ -3544,15 +3703,13 @@ const normalizeArgs = (args, defaultType = 'default') => {
message: '',
};
};
/** 版本号常量 */
const VERSION$1 = '0.2.1';
/**
* meToast API 对象
*/
const meToast = {
_toasts: new Map(),
_config: { ...DEFAULTS },
version: '0.2.1',
version: VERSION,
configure(opts) {
if (!opts || typeof opts !== 'object')
return this;
@@ -3563,6 +3720,7 @@ const meToast = {
if (opts.locale) {
setCurrentLocale(opts.locale);
}
Toast.trigger('configChange');
return this;
},
_emit(opts) {
@@ -4034,6 +4192,8 @@ const meToast = {
},
// ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
init(options = {}) {
Toast.trigger('beforeInit');
this._destroyed = false;
if (options.config) {
this.configure(options.config);
}
@@ -4048,11 +4208,12 @@ const meToast = {
this.use(p);
});
}
Toast.trigger('afterInit');
return this;
},
getStatus() {
return {
version: VERSION$1,
version: VERSION,
toasts: this._toasts.size,
theme: this.themes?.getCurrentTheme?.() || 'auto',
locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
@@ -4066,6 +4227,7 @@ const meToast = {
updateConfig(config) {
if (config && typeof config === 'object') {
Object.assign(this._config, config);
Toast.trigger('configChange');
}
return this;
},
@@ -4073,6 +4235,7 @@ const meToast = {
const keys = Object.keys(this._config);
keys.forEach(k => delete this._config[k]);
Object.assign(this._config, DEFAULTS);
Toast.trigger('configChange');
return this;
},
use(plugin, options = {}) {
@@ -4113,6 +4276,7 @@ const meToast = {
if (this._destroyed)
return;
this._destroyed = true;
Toast.trigger('beforeDestroy');
this.dismiss();
// 清理子模块
if (this.plugins && typeof this.plugins.destroy === 'function') {
@@ -4129,20 +4293,26 @@ const meToast = {
if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
this.i18n.clearLocaleListeners();
}
if (this.animations && typeof this.animations.cancelAll === 'function') {
this.animations.cancelAll();
if (this.animations && typeof this.animations.destroy === 'function') {
this.animations.destroy();
}
// 清理 DOM
// 清理 DOM 与静态资源
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach((c) => { if (c.parentNode)
c.parentNode.removeChild(c); });
const style = document.getElementById('metona-toast-styles');
if (style && style.parentNode)
style.parentNode.removeChild(style);
['metona-toast-styles', 'metona-toast-theme-css', 'metona-toast-custom-styles', 'metona-toast-anim-styles'].forEach((id) => {
const el = document.getElementById(id);
if (el && el.parentNode)
el.parentNode.removeChild(el);
});
}
this._toasts.clear();
_containerCache.clear();
Toast._registry.clear();
// afterDestroy 必须在清理 hooks 之前触发
Toast.trigger('afterDestroy');
Toast._hooks.clear();
},
// 子模块引用(由 index.ts 注入实际实现)
animations: null,
@@ -4167,77 +4337,13 @@ Toast.setCallbacks({
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
},
};
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
* @version 0.2.1
* @version 0.3.0
* @author thzxx
* @license MIT
*/
// 版本信息
const VERSION = '0.2.1';
/**
* 增强的 MeToast 对象 — 在 meToast 基础上注入子模块
*/
@@ -4257,6 +4363,13 @@ if (typeof themeUtils.initTheme === 'function') {
if (typeof i18nUtils.initI18n === 'function') {
i18nUtils.initI18n();
}
// 全局钩子 — 主题/语言变化转发到 Toast 钩子系统
if (typeof themeUtils.addThemeListener === 'function') {
themeUtils.addThemeListener(() => Toast.trigger('themeChange'));
}
if (typeof i18nUtils.addLocaleListener === 'function') {
i18nUtils.addLocaleListener(() => Toast.trigger('localeChange'));
}
// 浏览器环境全局注册
if (typeof window !== 'undefined') {
window.MeToast = enhancedMeToast;
+1 -1
View File
File diff suppressed because one or more lines are too long
+18 -6
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast — 核心类型定义
* @module types
* @version 0.2.1
* @version 0.3.0
*/
/** Toast 通知类型(107种内置图标类型) */
type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading' | 'check' | 'x' | 'alert' | 'question' | 'star' | 'heart' | 'bell' | 'mail' | 'settings' | 'user' | 'home' | 'search' | 'plus' | 'minus' | 'edit' | 'trash' | 'download' | 'upload' | 'share' | 'link' | 'external' | 'clock' | 'calendar' | 'map' | 'compass' | 'globe' | 'wifi' | 'cloud' | 'sun' | 'moon' | 'zap' | 'activity' | 'cpu' | 'database' | 'server' | 'terminal' | 'code' | 'git' | 'package' | 'layers' | 'grid' | 'list' | 'filter' | 'sort' | 'refresh' | 'sync' | 'power' | 'battery' | 'bluetooth' | 'volume' | 'mic' | 'camera' | 'image' | 'video' | 'music' | 'file' | 'folder' | 'clipboard' | 'save' | 'print' | 'eye' | 'eyeOff' | 'lock' | 'unlock' | 'shield' | 'key' | 'flag' | 'bookmark' | 'tag' | 'gift' | 'award' | 'target' | 'crosshair' | 'move' | 'maximize' | 'minimize' | 'copy' | 'cut' | 'paste' | 'rotateCw' | 'rotateCcw' | 'zoomIn' | 'zoomOut' | 'crop' | 'sliders' | 'toggleLeft' | 'toggleRight' | 'checkCircle' | 'xCircle' | 'alertCircle' | 'infoCircle' | 'helpCircle' | 'alertTriangle' | 'checkSquare' | 'square' | 'circle' | 'triangle' | 'hexagon' | 'octagon' | 'pentagon' | 'diamond';
@@ -193,8 +193,8 @@ interface StatusInfo {
animations: number;
}
interface AnimationConfig {
enter: Record<string, string>;
leave: Record<string, string>;
enter: Record<string, string | number>;
leave: Record<string, string | number>;
duration: number;
easing: string;
name?: string;
@@ -417,7 +417,7 @@ declare global {
/**
* MetonaToast Toast — Toast 类
* @module toast
* @version 0.2.1
* @version 0.3.0
*/
/**
@@ -425,6 +425,7 @@ declare global {
*/
declare class Toast implements ToastInstance {
static _hooks: Map<string, Array<(toast: Toast) => boolean | void>>;
static _registry: Map<string, Toast>;
private static _onError;
private static _removeToast;
/**
@@ -438,8 +439,9 @@ declare class Toast implements ToastInstance {
static off(name: string, fn: (toast: Toast) => boolean | void): void;
/**
* 触发钩子 — 如果任意钩子返回 false 则整体返回 false
* toast 参数可选:全局事件(configChange/themeChange 等)不关联具体 toast
*/
static trigger(name: string, toast: Toast): boolean;
static trigger(name: string, toast?: Toast | null): boolean;
id: string;
type: string;
title: string;
@@ -486,7 +488,17 @@ declare class Toast implements ToastInstance {
_destroy(): void;
}
declare const VERSION = "0.2.1";
/**
* MetonaToast Constants — 常量定义
* @module constants
* @version 0.3.0
*/
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
declare const VERSION = "0.3.0";
/**
* 增强的 MeToast 对象 — 在 meToast 基础上注入子模块
*/
+212 -99
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Utils — 工具函数
* @module utils
* @version 0.2.1
* @version 0.3.0
*/
/**
* 生成唯一ID
@@ -38,7 +38,7 @@ const prefersDark = () => {
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
* @version 0.2.1
* @version 0.3.0
* @description 107 个内置 SVG 图标
*/
const ICONS = {
@@ -535,7 +535,7 @@ const ICONS = {
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
* @version 0.2.1
* @version 0.3.0
* @description 内置 zh-CN / en-US 完整翻译
*/
const LOCALES = {
@@ -1022,8 +1022,12 @@ const LOCALES = {
/**
* MetonaToast Constants — 常量定义
* @module constants
* @version 0.2.1
* @version 0.3.0
*/
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
const VERSION = '0.3.0';
/**
* 默认配置
*/
@@ -1281,11 +1285,15 @@ const THEMES = {
closeHoverBg: 'rgba(245, 158, 11, 0.1)',
},
};
/**
* 动画类型
*/
const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom', 'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
/**
* MetonaToast Styles — 样式管理
* @module styles
* @version 0.2.1
* @version 0.3.0
*/
// 样式缓存
let styleElement = null;
@@ -1739,7 +1747,7 @@ const injectStyles = () => {
/**
* MetonaToast Themes — 主题管理
* @module themes
* @version 0.2.1
* @version 0.3.0
*/
// 当前主题状态
let currentTheme = 'auto';
@@ -2083,7 +2091,7 @@ const themeUtils = {
/**
* MetonaToast i18n — 国际化管理
* @module i18n
* @version 0.2.1
* @version 0.3.0
*/
// 当前语言状态
let currentLocale = 'zh-CN';
@@ -2639,10 +2647,126 @@ const i18nUtils = {
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 内置动画(keyframes 由 styles.ts 注入,无需动态生成)
const BUILTIN_ANIMATIONS = new Set(ANIMATION_TYPES);
// 动态注入的自定义动画样式(惰性创建)
let animStyleElement = null;
const injectedAnimations = new Set();
/**
* 将 CSS 属性对象转换为内联 CSS 字符串
*/
const toCss = (props) => Object.entries(props)
.map(([key, value]) => `${key}:${value}`)
.join(';');
/**
* 为自定义动画生成入场 keyframes + 类规则
*/
const buildAnimationCSS = (name, config) => {
const enter = toCss(config.enter);
return `
@keyframes met-${name}-in {
from { ${enter}; }
}
.met-anim-${name}.met-toast { opacity: 0; }
.met-anim-${name}.met-toast.met-show {
animation: met-${name}-in ${config.duration}ms ${config.easing} forwards;
}
`;
};
/**
* 确保自定义动画的 CSS 已注入文档(幂等,内置动画自动跳过)
*/
const ensureAnimationCSS = (name) => {
if (typeof document === 'undefined')
return;
if (BUILTIN_ANIMATIONS.has(name))
return;
const config = animationMap.get(name);
if (!config || injectedAnimations.has(name))
return;
if (!animStyleElement) {
animStyleElement = document.getElementById('metona-toast-anim-styles');
if (!animStyleElement) {
animStyleElement = document.createElement('style');
animStyleElement.id = 'metona-toast-anim-styles';
document.head.appendChild(animStyleElement);
}
}
animStyleElement.textContent += buildAnimationCSS(name, config);
injectedAnimations.add(name);
};
/**
* 检查动画是否存在(内置或自定义)
*/
const hasAnimation = (name) => animationMap.has(name);
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
injectedAnimations.clear();
animStyleElement = null;
},
};
/**
* MetonaToast Toast — Toast 类
* @module toast
* @version 0.2.1
* @version 0.3.0
*/
/**
* Toast 类 — 核心通知组件
@@ -2670,8 +2794,9 @@ class Toast {
}
/**
* 触发钩子 — 如果任意钩子返回 false 则整体返回 false
* toast 参数可选:全局事件(configChange/themeChange 等)不关联具体 toast
*/
static trigger(name, toast) {
static trigger(name, toast = null) {
const list = this._hooks.get(name);
if (!list || list.length === 0)
return true;
@@ -2686,7 +2811,7 @@ class Toast {
console.error('Hook error:', name, e);
if (Toast._onError) {
try {
Toast._onError({ hook: name, error: e, toast });
Toast._onError({ hook: name, error: e, toast: toast });
}
catch (_e) { /* noop */ }
}
@@ -2741,6 +2866,7 @@ class Toast {
}
}
injectStyles();
ensureAnimationCSS(this.config.animation || 'slide');
const container = this._getContainer();
const { theme, c, t } = this._palette();
this._limitToasts(container);
@@ -2760,6 +2886,7 @@ class Toast {
else {
container.appendChild(el);
}
Toast._registry.set(this.id, this);
this._bindEvents(el);
this._startTimer();
// 进入动画
@@ -2827,18 +2954,22 @@ class Toast {
_limitToasts(container) {
const max = this.config.max || 6;
const list = Array.from(container.querySelectorAll('.met-toast'));
if (list.length >= max) {
const first = list[0];
const id = first?.dataset.id;
if (id && Toast._removeToast) {
while (list.length >= max) {
const el = list.shift();
const id = el?.dataset.id;
if (!id)
continue;
const existing = Toast._registry.get(id);
if (existing) {
existing.close(true);
}
else if (Toast._removeToast) {
Toast._removeToast(id);
}
}
}
_buildClassName(theme) {
const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide';
const anim = hasAnimation(this.config.animation || '') ? this.config.animation : 'slide';
return [
'met-toast',
`met-${this.type}`,
@@ -2915,6 +3046,7 @@ class Toast {
this.close();
return;
}
Toast.trigger('click', this);
if (this.config.closeOnClick && !target.closest('.met-close')) {
if (typeof this.config.onClick === 'function') {
try {
@@ -2931,8 +3063,14 @@ class Toast {
el.addEventListener('click', eventHandler);
this._cleanups.push(() => el.removeEventListener('click', eventHandler));
if (this.config.pauseOnHover && (this.config.duration || 0) > 0) {
const mouseEnter = () => this._pause();
const mouseLeave = () => this._resume();
const mouseEnter = () => {
Toast.trigger('hover', this);
this._pause();
};
const mouseLeave = () => {
Toast.trigger('hover', this);
this._resume();
};
el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);
this._cleanups.push(() => {
@@ -2940,6 +3078,15 @@ class Toast {
el.removeEventListener('mouseleave', mouseLeave);
});
}
// 入场动画生命周期钩子(离场为过渡/内联样式,不产生 animation 事件)
const animStart = () => Toast.trigger('animationStart', this);
const animEnd = () => Toast.trigger('animationEnd', this);
el.addEventListener('animationstart', animStart);
el.addEventListener('animationend', animEnd);
this._cleanups.push(() => {
el.removeEventListener('animationstart', animStart);
el.removeEventListener('animationend', animEnd);
});
if (this.config.draggable) {
this._bindDrag(el);
}
@@ -2955,6 +3102,7 @@ class Toast {
el.setPointerCapture(e.pointerId);
el.style.transition = 'none';
this._pause();
Toast.trigger('dragStart', this);
};
const move = (e) => {
if (!dragging)
@@ -2981,6 +3129,7 @@ class Toast {
this._resume();
}
dx = dy = 0;
Toast.trigger('dragEnd', this);
};
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
@@ -2999,6 +3148,7 @@ class Toast {
if (!resuming) {
this.startedAt = Date.now();
this.remaining = this.config.duration || 0;
Toast.trigger('progressStart', this);
}
const tick = () => {
if (this.paused || this.closing)
@@ -3013,6 +3163,7 @@ class Toast {
this.barEl.style.transform = t;
}
if (this.remaining <= 0) {
Toast.trigger('progressEnd', this);
this.close();
return;
}
@@ -3046,7 +3197,9 @@ class Toast {
this._startTimer(true);
}
update(partial) {
Toast.trigger('beforeUpdate', this);
// beforeUpdate 钩子 — 返回 false 可阻止更新
if (!Toast.trigger('beforeUpdate', this))
return this;
const typeChanged = partial.type && partial.type !== this.type;
if (partial.type)
this.type = partial.type;
@@ -3143,14 +3296,17 @@ class Toast {
this.el.parentNode.removeChild(this.el);
}
this.el = null;
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
close(immediate = false) {
if (this.closing)
return;
// beforeClose 钩子 — 返回 false 可阻止关闭
if (!Toast.trigger('beforeClose', this))
return;
this.closing = true;
Toast.trigger('beforeClose', this);
if (this.rafId !== null)
cancelAnimationFrame(this.rafId);
this._cleanups.forEach(fn => { try {
@@ -3206,12 +3362,15 @@ class Toast {
}
}
Toast.trigger('afterClose', this);
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
}
// 静态钩子系统
Toast._hooks = new Map();
// 实例注册表 — 用于通过 id 反查实例(max 超限移除、外部接管等)
Toast._registry = new Map();
// 由 api.ts 注入的回调,通过 setCallbacks() 设置
Toast._onError = null;
Toast._removeToast = null;
@@ -3223,7 +3382,7 @@ const _containerCache = new Map();
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
* @version 0.2.1
* @version 0.3.0
* @description confirm / prompt / progress / action 的 DOM 模板
*/
/**
@@ -3295,7 +3454,7 @@ const actionHTML = (actions) => {
/**
* MetonaToast Plugins — 插件系统
* @module plugins
* @version 0.2.1
* @version 0.3.0
*/
/**
* 插件管理器
@@ -3515,7 +3674,7 @@ const defaultPluginManager = new PluginManager();
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.2.1
* @version 0.3.0
*/
/**
* 参数标准化工具
@@ -3540,15 +3699,13 @@ const normalizeArgs = (args, defaultType = 'default') => {
message: '',
};
};
/** 版本号常量 */
const VERSION$1 = '0.2.1';
/**
* meToast API 对象
*/
const meToast = {
_toasts: new Map(),
_config: { ...DEFAULTS },
version: '0.2.1',
version: VERSION,
configure(opts) {
if (!opts || typeof opts !== 'object')
return this;
@@ -3559,6 +3716,7 @@ const meToast = {
if (opts.locale) {
setCurrentLocale(opts.locale);
}
Toast.trigger('configChange');
return this;
},
_emit(opts) {
@@ -4030,6 +4188,8 @@ const meToast = {
},
// ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
init(options = {}) {
Toast.trigger('beforeInit');
this._destroyed = false;
if (options.config) {
this.configure(options.config);
}
@@ -4044,11 +4204,12 @@ const meToast = {
this.use(p);
});
}
Toast.trigger('afterInit');
return this;
},
getStatus() {
return {
version: VERSION$1,
version: VERSION,
toasts: this._toasts.size,
theme: this.themes?.getCurrentTheme?.() || 'auto',
locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
@@ -4062,6 +4223,7 @@ const meToast = {
updateConfig(config) {
if (config && typeof config === 'object') {
Object.assign(this._config, config);
Toast.trigger('configChange');
}
return this;
},
@@ -4069,6 +4231,7 @@ const meToast = {
const keys = Object.keys(this._config);
keys.forEach(k => delete this._config[k]);
Object.assign(this._config, DEFAULTS);
Toast.trigger('configChange');
return this;
},
use(plugin, options = {}) {
@@ -4109,6 +4272,7 @@ const meToast = {
if (this._destroyed)
return;
this._destroyed = true;
Toast.trigger('beforeDestroy');
this.dismiss();
// 清理子模块
if (this.plugins && typeof this.plugins.destroy === 'function') {
@@ -4125,20 +4289,26 @@ const meToast = {
if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
this.i18n.clearLocaleListeners();
}
if (this.animations && typeof this.animations.cancelAll === 'function') {
this.animations.cancelAll();
if (this.animations && typeof this.animations.destroy === 'function') {
this.animations.destroy();
}
// 清理 DOM
// 清理 DOM 与静态资源
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach((c) => { if (c.parentNode)
c.parentNode.removeChild(c); });
const style = document.getElementById('metona-toast-styles');
if (style && style.parentNode)
style.parentNode.removeChild(style);
['metona-toast-styles', 'metona-toast-theme-css', 'metona-toast-custom-styles', 'metona-toast-anim-styles'].forEach((id) => {
const el = document.getElementById(id);
if (el && el.parentNode)
el.parentNode.removeChild(el);
});
}
this._toasts.clear();
_containerCache.clear();
Toast._registry.clear();
// afterDestroy 必须在清理 hooks 之前触发
Toast.trigger('afterDestroy');
Toast._hooks.clear();
},
// 子模块引用(由 index.ts 注入实际实现)
animations: null,
@@ -4163,77 +4333,13 @@ Toast.setCallbacks({
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
},
};
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
* @version 0.2.1
* @version 0.3.0
* @author thzxx
* @license MIT
*/
// 版本信息
const VERSION = '0.2.1';
/**
* 增强的 MeToast 对象 — 在 meToast 基础上注入子模块
*/
@@ -4253,6 +4359,13 @@ if (typeof themeUtils.initTheme === 'function') {
if (typeof i18nUtils.initI18n === 'function') {
i18nUtils.initI18n();
}
// 全局钩子 — 主题/语言变化转发到 Toast 钩子系统
if (typeof themeUtils.addThemeListener === 'function') {
themeUtils.addThemeListener(() => Toast.trigger('themeChange'));
}
if (typeof i18nUtils.addLocaleListener === 'function') {
i18nUtils.addLocaleListener(() => Toast.trigger('localeChange'));
}
// 浏览器环境全局注册
if (typeof window !== 'undefined') {
window.MeToast = enhancedMeToast;
+1 -1
View File
File diff suppressed because one or more lines are too long
+212 -99
View File
@@ -7,7 +7,7 @@
/**
* MetonaToast Utils — 工具函数
* @module utils
* @version 0.2.1
* @version 0.3.0
*/
/**
* 生成唯一ID
@@ -44,7 +44,7 @@
/**
* MetonaToast Icons — 图标SVG定义
* @module icons
* @version 0.2.1
* @version 0.3.0
* @description 107 个内置 SVG 图标
*/
const ICONS = {
@@ -541,7 +541,7 @@
/**
* MetonaToast Locales — 国际化翻译数据
* @module locales
* @version 0.2.1
* @version 0.3.0
* @description 内置 zh-CN / en-US 完整翻译
*/
const LOCALES = {
@@ -1028,8 +1028,12 @@
/**
* MetonaToast Constants — 常量定义
* @module constants
* @version 0.2.1
* @version 0.3.0
*/
/**
* 版本号 — 唯一来源,发布时只需修改此处
*/
const VERSION = '0.3.0';
/**
* 默认配置
*/
@@ -1287,11 +1291,15 @@
closeHoverBg: 'rgba(245, 158, 11, 0.1)',
},
};
/**
* 动画类型
*/
const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom', 'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
/**
* MetonaToast Styles — 样式管理
* @module styles
* @version 0.2.1
* @version 0.3.0
*/
// 样式缓存
let styleElement = null;
@@ -1745,7 +1753,7 @@
/**
* MetonaToast Themes — 主题管理
* @module themes
* @version 0.2.1
* @version 0.3.0
*/
// 当前主题状态
let currentTheme = 'auto';
@@ -2089,7 +2097,7 @@
/**
* MetonaToast i18n — 国际化管理
* @module i18n
* @version 0.2.1
* @version 0.3.0
*/
// 当前语言状态
let currentLocale = 'zh-CN';
@@ -2645,10 +2653,126 @@
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 内置动画(keyframes 由 styles.ts 注入,无需动态生成)
const BUILTIN_ANIMATIONS = new Set(ANIMATION_TYPES);
// 动态注入的自定义动画样式(惰性创建)
let animStyleElement = null;
const injectedAnimations = new Set();
/**
* 将 CSS 属性对象转换为内联 CSS 字符串
*/
const toCss = (props) => Object.entries(props)
.map(([key, value]) => `${key}:${value}`)
.join(';');
/**
* 为自定义动画生成入场 keyframes + 类规则
*/
const buildAnimationCSS = (name, config) => {
const enter = toCss(config.enter);
return `
@keyframes met-${name}-in {
from { ${enter}; }
}
.met-anim-${name}.met-toast { opacity: 0; }
.met-anim-${name}.met-toast.met-show {
animation: met-${name}-in ${config.duration}ms ${config.easing} forwards;
}
`;
};
/**
* 确保自定义动画的 CSS 已注入文档(幂等,内置动画自动跳过)
*/
const ensureAnimationCSS = (name) => {
if (typeof document === 'undefined')
return;
if (BUILTIN_ANIMATIONS.has(name))
return;
const config = animationMap.get(name);
if (!config || injectedAnimations.has(name))
return;
if (!animStyleElement) {
animStyleElement = document.getElementById('metona-toast-anim-styles');
if (!animStyleElement) {
animStyleElement = document.createElement('style');
animStyleElement.id = 'metona-toast-anim-styles';
document.head.appendChild(animStyleElement);
}
}
animStyleElement.textContent += buildAnimationCSS(name, config);
injectedAnimations.add(name);
};
/**
* 检查动画是否存在(内置或自定义)
*/
const hasAnimation = (name) => animationMap.has(name);
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
injectedAnimations.clear();
animStyleElement = null;
},
};
/**
* MetonaToast Toast — Toast 类
* @module toast
* @version 0.2.1
* @version 0.3.0
*/
/**
* Toast 类 — 核心通知组件
@@ -2676,8 +2800,9 @@
}
/**
* 触发钩子 — 如果任意钩子返回 false 则整体返回 false
* toast 参数可选:全局事件(configChange/themeChange 等)不关联具体 toast
*/
static trigger(name, toast) {
static trigger(name, toast = null) {
const list = this._hooks.get(name);
if (!list || list.length === 0)
return true;
@@ -2692,7 +2817,7 @@
console.error('Hook error:', name, e);
if (Toast._onError) {
try {
Toast._onError({ hook: name, error: e, toast });
Toast._onError({ hook: name, error: e, toast: toast });
}
catch (_e) { /* noop */ }
}
@@ -2747,6 +2872,7 @@
}
}
injectStyles();
ensureAnimationCSS(this.config.animation || 'slide');
const container = this._getContainer();
const { theme, c, t } = this._palette();
this._limitToasts(container);
@@ -2766,6 +2892,7 @@
else {
container.appendChild(el);
}
Toast._registry.set(this.id, this);
this._bindEvents(el);
this._startTimer();
// 进入动画
@@ -2833,18 +2960,22 @@
_limitToasts(container) {
const max = this.config.max || 6;
const list = Array.from(container.querySelectorAll('.met-toast'));
if (list.length >= max) {
const first = list[0];
const id = first?.dataset.id;
if (id && Toast._removeToast) {
while (list.length >= max) {
const el = list.shift();
const id = el?.dataset.id;
if (!id)
continue;
const existing = Toast._registry.get(id);
if (existing) {
existing.close(true);
}
else if (Toast._removeToast) {
Toast._removeToast(id);
}
}
}
_buildClassName(theme) {
const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide';
const anim = hasAnimation(this.config.animation || '') ? this.config.animation : 'slide';
return [
'met-toast',
`met-${this.type}`,
@@ -2921,6 +3052,7 @@
this.close();
return;
}
Toast.trigger('click', this);
if (this.config.closeOnClick && !target.closest('.met-close')) {
if (typeof this.config.onClick === 'function') {
try {
@@ -2937,8 +3069,14 @@
el.addEventListener('click', eventHandler);
this._cleanups.push(() => el.removeEventListener('click', eventHandler));
if (this.config.pauseOnHover && (this.config.duration || 0) > 0) {
const mouseEnter = () => this._pause();
const mouseLeave = () => this._resume();
const mouseEnter = () => {
Toast.trigger('hover', this);
this._pause();
};
const mouseLeave = () => {
Toast.trigger('hover', this);
this._resume();
};
el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);
this._cleanups.push(() => {
@@ -2946,6 +3084,15 @@
el.removeEventListener('mouseleave', mouseLeave);
});
}
// 入场动画生命周期钩子(离场为过渡/内联样式,不产生 animation 事件)
const animStart = () => Toast.trigger('animationStart', this);
const animEnd = () => Toast.trigger('animationEnd', this);
el.addEventListener('animationstart', animStart);
el.addEventListener('animationend', animEnd);
this._cleanups.push(() => {
el.removeEventListener('animationstart', animStart);
el.removeEventListener('animationend', animEnd);
});
if (this.config.draggable) {
this._bindDrag(el);
}
@@ -2961,6 +3108,7 @@
el.setPointerCapture(e.pointerId);
el.style.transition = 'none';
this._pause();
Toast.trigger('dragStart', this);
};
const move = (e) => {
if (!dragging)
@@ -2987,6 +3135,7 @@
this._resume();
}
dx = dy = 0;
Toast.trigger('dragEnd', this);
};
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
@@ -3005,6 +3154,7 @@
if (!resuming) {
this.startedAt = Date.now();
this.remaining = this.config.duration || 0;
Toast.trigger('progressStart', this);
}
const tick = () => {
if (this.paused || this.closing)
@@ -3019,6 +3169,7 @@
this.barEl.style.transform = t;
}
if (this.remaining <= 0) {
Toast.trigger('progressEnd', this);
this.close();
return;
}
@@ -3052,7 +3203,9 @@
this._startTimer(true);
}
update(partial) {
Toast.trigger('beforeUpdate', this);
// beforeUpdate 钩子 — 返回 false 可阻止更新
if (!Toast.trigger('beforeUpdate', this))
return this;
const typeChanged = partial.type && partial.type !== this.type;
if (partial.type)
this.type = partial.type;
@@ -3149,14 +3302,17 @@
this.el.parentNode.removeChild(this.el);
}
this.el = null;
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
close(immediate = false) {
if (this.closing)
return;
// beforeClose 钩子 — 返回 false 可阻止关闭
if (!Toast.trigger('beforeClose', this))
return;
this.closing = true;
Toast.trigger('beforeClose', this);
if (this.rafId !== null)
cancelAnimationFrame(this.rafId);
this._cleanups.forEach(fn => { try {
@@ -3212,12 +3368,15 @@
}
}
Toast.trigger('afterClose', this);
Toast._registry.delete(this.id);
if (Toast._removeToast)
Toast._removeToast(this.id);
}
}
// 静态钩子系统
Toast._hooks = new Map();
// 实例注册表 — 用于通过 id 反查实例(max 超限移除、外部接管等)
Toast._registry = new Map();
// 由 api.ts 注入的回调,通过 setCallbacks() 设置
Toast._onError = null;
Toast._removeToast = null;
@@ -3229,7 +3388,7 @@
/**
* MetonaToast Templates — HTML 模板辅助函数
* @module templates
* @version 0.2.1
* @version 0.3.0
* @description confirm / prompt / progress / action 的 DOM 模板
*/
/**
@@ -3301,7 +3460,7 @@
/**
* MetonaToast Plugins — 插件系统
* @module plugins
* @version 0.2.1
* @version 0.3.0
*/
/**
* 插件管理器
@@ -3521,7 +3680,7 @@
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.2.1
* @version 0.3.0
*/
/**
* 参数标准化工具
@@ -3546,15 +3705,13 @@
message: '',
};
};
/** 版本号常量 */
const VERSION$1 = '0.2.1';
/**
* meToast API 对象
*/
const meToast = {
_toasts: new Map(),
_config: { ...DEFAULTS },
version: '0.2.1',
version: VERSION,
configure(opts) {
if (!opts || typeof opts !== 'object')
return this;
@@ -3565,6 +3722,7 @@
if (opts.locale) {
setCurrentLocale(opts.locale);
}
Toast.trigger('configChange');
return this;
},
_emit(opts) {
@@ -4036,6 +4194,8 @@
},
// ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
init(options = {}) {
Toast.trigger('beforeInit');
this._destroyed = false;
if (options.config) {
this.configure(options.config);
}
@@ -4050,11 +4210,12 @@
this.use(p);
});
}
Toast.trigger('afterInit');
return this;
},
getStatus() {
return {
version: VERSION$1,
version: VERSION,
toasts: this._toasts.size,
theme: this.themes?.getCurrentTheme?.() || 'auto',
locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
@@ -4068,6 +4229,7 @@
updateConfig(config) {
if (config && typeof config === 'object') {
Object.assign(this._config, config);
Toast.trigger('configChange');
}
return this;
},
@@ -4075,6 +4237,7 @@
const keys = Object.keys(this._config);
keys.forEach(k => delete this._config[k]);
Object.assign(this._config, DEFAULTS);
Toast.trigger('configChange');
return this;
},
use(plugin, options = {}) {
@@ -4115,6 +4278,7 @@
if (this._destroyed)
return;
this._destroyed = true;
Toast.trigger('beforeDestroy');
this.dismiss();
// 清理子模块
if (this.plugins && typeof this.plugins.destroy === 'function') {
@@ -4131,20 +4295,26 @@
if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
this.i18n.clearLocaleListeners();
}
if (this.animations && typeof this.animations.cancelAll === 'function') {
this.animations.cancelAll();
if (this.animations && typeof this.animations.destroy === 'function') {
this.animations.destroy();
}
// 清理 DOM
// 清理 DOM 与静态资源
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach((c) => { if (c.parentNode)
c.parentNode.removeChild(c); });
const style = document.getElementById('metona-toast-styles');
if (style && style.parentNode)
style.parentNode.removeChild(style);
['metona-toast-styles', 'metona-toast-theme-css', 'metona-toast-custom-styles', 'metona-toast-anim-styles'].forEach((id) => {
const el = document.getElementById(id);
if (el && el.parentNode)
el.parentNode.removeChild(el);
});
}
this._toasts.clear();
_containerCache.clear();
Toast._registry.clear();
// afterDestroy 必须在清理 hooks 之前触发
Toast.trigger('afterDestroy');
Toast._hooks.clear();
},
// 子模块引用(由 index.ts 注入实际实现)
animations: null,
@@ -4169,77 +4339,13 @@
},
});
/**
* MetonaToast Animations — 动画管理
* @module animations
* @version 0.2.1
*/
// 动画缓存
const animationMap = new Map();
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
/**
* 动画工具函数
*/
const animationUtils = {
register(name, config) {
animationMap.set(name, {
name,
enter: config.enter || {},
leave: config.leave || {},
duration: config.duration || 300,
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
});
},
unregister(name) {
animationMap.delete(name);
},
get(name) {
return animationMap.get(name) || null;
},
getAnimationNames() {
return Array.from(animationMap.keys());
},
getActiveCount() {
return animationMap.size;
},
cancelAll() {
// CSS动画由浏览器原生管理,无需手动取消
},
reset() {
animationMap.clear();
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
name,
enter: config.enter,
leave: config.leave,
duration: config.duration,
easing: config.easing,
});
});
},
destroy() {
animationMap.clear();
},
};
/**
* MetonaToast — 轻量级Toast通知库
* @module metona-toast
* @version 0.2.1
* @version 0.3.0
* @author thzxx
* @license MIT
*/
// 版本信息
const VERSION = '0.2.1';
/**
* 增强的 MeToast 对象 — 在 meToast 基础上注入子模块
*/
@@ -4259,6 +4365,13 @@
if (typeof i18nUtils.initI18n === 'function') {
i18nUtils.initI18n();
}
// 全局钩子 — 主题/语言变化转发到 Toast 钩子系统
if (typeof themeUtils.addThemeListener === 'function') {
themeUtils.addThemeListener(() => Toast.trigger('themeChange'));
}
if (typeof i18nUtils.addLocaleListener === 'function') {
i18nUtils.addLocaleListener(() => Toast.trigger('localeChange'));
}
// 浏览器环境全局注册
if (typeof window !== 'undefined') {
window.MeToast = enhancedMeToast;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@metona-team/metona-toast",
"version": "0.2.1",
"version": "0.3.0",
"description": "轻量、零依赖、精致美观的Toast通知库。TypeScript源码,开箱即用。",
"type": "module",
"main": "dist/metona-toast.cjs.js",
@@ -31,8 +31,7 @@
"format": "prettier --write src/**/*.ts tests/**/*.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build",
"docs": "typedoc src/ --out docs",
"example": "serve examples/"
"serve": "bash serve.sh"
},
"repository": {
"type": "git",
+2 -2
View File
@@ -244,7 +244,7 @@
<!-- 14. 错误回调 -->
<div class="card">
<div class="label">🛡️ onError 错误捕获 <span style="color:#34d399;font-size:10px;">v0.2.1</span></div>
<div class="label">🛡️ onError 错误捕获 <span style="color:#34d399;font-size:10px;">v0.3.0</span></div>
<div class="btn-row">
<button class="btn c-e" onclick="demoOnError()">演示 onError</button>
<button class="btn c-w" onclick="demoOnErrorReset()">重置回调</button>
@@ -282,7 +282,7 @@
</main>
<footer>
MetonaToast v0.2.1 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
MetonaToast v0.3.0 · MIT · <a href="index.html">首页</a> · <a href="docs.html">文档</a> · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a>
</footer>
<script src="../dist/metona-toast.js"></script>
+25 -1
View File
@@ -74,6 +74,8 @@
<a href="#group">group()</a>
<a href="#dismiss">dismiss()</a>
<a href="#clear">clear()</a>
<a href="#updatePosition">updatePosition()</a>
<a href="#remove">remove() / removeToast()</a>
<a href="#configure">configure()</a>
<a href="#use">use()</a>
<a href="#destroy">destroy()</a>
@@ -87,7 +89,7 @@
<main class="main">
<h1>API 文档</h1>
<p>MetonaToast v0.2.1 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
<p>MetonaToast v0.3.0 完整 API 参考。所有基础通知方法(show/success/error/warning/info/loading)支持两种调用形式,均可传入任何 <a href="#config">配置项</a> 作为可选第二参数。</p>
<!-- ===== 基础通知 ===== -->
<h2 id="show">show(message, opts?)</h2>
@@ -378,6 +380,26 @@ MeToast.<span class="fn">dismiss</span>(toast.id); <span class="cm">// 关闭
<pre>MeToast.<span class="fn">clear</span>(); <span class="cm">// 全部</span>
MeToast.<span class="fn">clear</span>(<span class="s">'bottom-right'</span>); <span class="cm">// 仅右下角</span></pre>
<h2 id="updatePosition">updatePosition(position)</h2>
<p>运行时将 Toast 移动到新的位置容器。立即生效,不重新播放入场动画。</p>
<table>
<tr><th>参数</th><th>类型</th><th>说明</th></tr>
<tr><td>position</td><td>string</td><td>目标位置:top-left / top-center / top-right / bottom-left / bottom-center / bottom-right</td></tr>
</table>
<pre><span class="kw">const</span> t = MeToast.<span class="fn">info</span>(<span class="s">'可移动的 Toast'</span>);
t.<span class="fn">updatePosition</span>(<span class="s">'bottom-left'</span>); <span class="cm">// 移动到左下角</span></pre>
<h2 id="remove">remove() / removeToast(id)</h2>
<p>立即从 DOM 和内存中移除 Toast,<b>不触发离场动画</b>。适用于需要无动画快速清除的场景(区别于 <code>dismiss()</code> 的优雅关闭)。</p>
<table>
<tr><th>方法</th><th>签名</th><th>说明</th></tr>
<tr><td>remove</td><td>toast.remove()</td><td>Toast 实例方法,立即移除自身</td></tr>
<tr><td>removeToast</td><td>MeToast.removeToast(id)</td><td>按 id 立即移除。id 不存在时静默忽略</td></tr>
</table>
<pre><span class="kw">const</span> t = MeToast.<span class="fn">warning</span>(<span class="s">'临时消息'</span>);
t.<span class="fn">remove</span>(); <span class="cm">// 实例方法:立即移除</span>
MeToast.<span class="fn">removeToast</span>(t.id); <span class="cm">// 或按 id 移除(等价)</span></pre>
<h2 id="configure">configure(opts)</h2>
<p>全局配置,影响后续所有 Toast。theme 和 locale 变化会触发相应副作用(应用主题 CSS / 切换语言)。</p>
<table>
@@ -434,12 +456,14 @@ MeToast.<span class="fn">use</span>(<span class="s">'accessibility'</span>); <sp
<tr><td>resetTimerOnUpdate</td><td>boolean</td><td>false</td><td>调用 update() 时重置 duration 倒计时</td></tr>
<tr><td>notifyWhenHidden</td><td>boolean</td><td>false</td><td>页面不可见时自动通过 Notification API 发送系统通知</td></tr>
<tr><td>render</td><td>function</td><td></td><td>自定义渲染函数 <code>(toast) => htmlString</code>,完全接管 DOM 构建</td></tr>
<tr><td>onBeforeShow</td><td>function</td><td></td><td>显示前回调 <code>(toast) => boolean | void</code>,返回 false 可阻止该 Toast 显示</td></tr>
<tr><td>onError</td><td>function</td><td></td><td>全局错误回调 <code>({ hook, source, error, toast }) => void</code>,钩子异常或定时器错误时触发</td></tr>
</table>
<h2 id="callbacks">回调函数</h2>
<table>
<tr><th>回调</th><th>签名</th><th>触发时机</th></tr>
<tr><td>onBeforeShow</td><td>(toast: ToastInstance) => boolean | void</td><td>Toast DOM 创建前。返回 false 阻止显示</td></tr>
<tr><td>onShow</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 创建并播放入场动画后</td></tr>
<tr><td>onClose</td><td>(toast: ToastInstance) => void</td><td>Toast DOM 被移除后(离场动画完成时)</td></tr>
<tr><td>onClick</td><td>(toast: ToastInstance) => void</td><td>Toast 被点击时(closeOnClick 为 true 时还会自动关闭)</td></tr>
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>MetonaToast — 轻量精致的 Toast 通知库</title>
<meta name="description" content="MetonaToast 是轻量、零依赖、精致美观的 Toast 通知库。80+ 图标类型、11 种动画、主题系统、国际化、插件架构。">
<meta name="description" content="MetonaToast 是轻量、零依赖、精致美观的 Toast 通知库。107 种图标类型、11 种动画、主题系统、国际化、插件架构。">
<style>
:root {
--bg: #0b1121; --bg2: #111827; --surface: #1a2332;
@@ -247,7 +247,7 @@ orderGroup.<span class="fn">dismiss</span>(); <span class="cm">// 一键关闭
</section>
<footer>
<p>MetonaToast v0.2.1 · MIT License · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a></p>
<p>MetonaToast v0.3.0 · MIT License · <a href="https://git.metona.cn/MetonaTeam/MetonaToast">Gitea</a></p>
</footer>
<script src="../dist/metona-toast.js"></script>
+62 -1
View File
@@ -4,12 +4,71 @@
* @version 0.2.1
*/
import { ANIMATIONS } from './constants.js';
import { ANIMATIONS, ANIMATION_TYPES } from './constants.js';
import type { AnimationConfig, AnimationUtils } from './types.js';
// 动画缓存
const animationMap: Map<string, AnimationConfig> = new Map();
// 内置动画(keyframes 由 styles.ts 注入,无需动态生成)
const BUILTIN_ANIMATIONS: Set<string> = new Set(ANIMATION_TYPES);
// 动态注入的自定义动画样式(惰性创建)
let animStyleElement: HTMLStyleElement | null = null;
const injectedAnimations: Set<string> = new Set();
/**
* CSS CSS
*/
const toCss = (props: Record<string, string | number>): string =>
Object.entries(props)
.map(([key, value]) => `${key}:${value}`)
.join(';');
/**
* keyframes +
*/
const buildAnimationCSS = (name: string, config: AnimationConfig): string => {
const enter = toCss(config.enter);
return `
@keyframes met-${name}-in {
from { ${enter}; }
}
.met-anim-${name}.met-toast { opacity: 0; }
.met-anim-${name}.met-toast.met-show {
animation: met-${name}-in ${config.duration}ms ${config.easing} forwards;
}
`;
};
/**
* CSS
*/
export const ensureAnimationCSS = (name: string): void => {
if (typeof document === 'undefined') return;
if (BUILTIN_ANIMATIONS.has(name)) return;
const config = animationMap.get(name);
if (!config || injectedAnimations.has(name)) return;
if (!animStyleElement) {
animStyleElement = document.getElementById('metona-toast-anim-styles') as HTMLStyleElement | null;
if (!animStyleElement) {
animStyleElement = document.createElement('style');
animStyleElement.id = 'metona-toast-anim-styles';
document.head.appendChild(animStyleElement);
}
}
animStyleElement.textContent += buildAnimationCSS(name, config);
injectedAnimations.add(name);
};
/**
*
*/
export const hasAnimation = (name: string): boolean => animationMap.has(name);
// 注册默认动画
Object.entries(ANIMATIONS).forEach(([name, config]) => {
animationMap.set(name, {
@@ -70,6 +129,8 @@ export const animationUtils: AnimationUtils = {
destroy(): void {
animationMap.clear();
injectedAnimations.clear();
animStyleElement = null;
},
};
+26 -12
View File
@@ -1,11 +1,11 @@
/**
* MetonaToast API meToast API
* @module api
* @version 0.2.1
* @version 0.3.0
*/
import { Toast, _containerCache } from './toast.js';
import { DEFAULTS } from './constants.js';
import { DEFAULTS, VERSION } from './constants.js';
import { escapeHTML } from './utils.js';
import { t, setCurrentLocale } from './i18n.js';
import { applyTheme } from './themes.js';
@@ -48,16 +48,13 @@ const normalizeArgs = (args: unknown[], defaultType = 'default'): ToastOptions =
};
};
/** 版本号常量 */
const VERSION = '0.2.1';
/**
* meToast API
*/
const meToast: MeToast = {
_toasts: new Map<string, ToastInstance>(),
_config: { ...DEFAULTS } as unknown as ToastConfig,
version: '0.2.1',
version: VERSION,
configure(opts: Partial<ToastConfig>): MeToast {
if (!opts || typeof opts !== 'object') return this;
@@ -71,6 +68,7 @@ const meToast: MeToast = {
setCurrentLocale(opts.locale);
}
Toast.trigger('configChange');
return this;
},
@@ -588,6 +586,9 @@ const meToast: MeToast = {
// ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
init(options: InitOptions = {}): MeToast {
Toast.trigger('beforeInit');
this._destroyed = false;
if (options.config) {
this.configure(options.config);
}
@@ -599,9 +600,11 @@ const meToast: MeToast = {
}
if (options.plugins && Array.isArray(options.plugins)) {
options.plugins.forEach((p) => {
this.use(p as string);
this.use(p);
});
}
Toast.trigger('afterInit');
return this;
},
@@ -623,6 +626,7 @@ const meToast: MeToast = {
updateConfig(config: Partial<ToastConfig>): MeToast {
if (config && typeof config === 'object') {
Object.assign(this._config, config);
Toast.trigger('configChange');
}
return this;
},
@@ -631,6 +635,7 @@ const meToast: MeToast = {
const keys = Object.keys(this._config);
keys.forEach(k => delete (this._config as Record<string, unknown>)[k]);
Object.assign(this._config, DEFAULTS);
Toast.trigger('configChange');
return this;
},
@@ -670,6 +675,8 @@ const meToast: MeToast = {
destroy(): void {
if (this._destroyed) return;
this._destroyed = true;
Toast.trigger('beforeDestroy');
this.dismiss();
// 清理子模块
@@ -687,21 +694,28 @@ const meToast: MeToast = {
if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
this.i18n.clearLocaleListeners();
}
if (this.animations && typeof this.animations.cancelAll === 'function') {
this.animations.cancelAll();
if (this.animations && typeof this.animations.destroy === 'function') {
this.animations.destroy();
}
// 清理 DOM
// 清理 DOM 与静态资源
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); });
const style = document.getElementById('metona-toast-styles');
if (style && style.parentNode) style.parentNode.removeChild(style);
['metona-toast-styles', 'metona-toast-theme-css', 'metona-toast-custom-styles', 'metona-toast-anim-styles'].forEach((id) => {
const el = document.getElementById(id);
if (el && el.parentNode) el.parentNode.removeChild(el);
});
}
this._toasts.clear();
_containerCache.clear();
Toast._registry.clear();
// afterDestroy 必须在清理 hooks 之前触发
Toast.trigger('afterDestroy');
Toast._hooks.clear();
},
// 子模块引用(由 index.ts 注入实际实现)
+6 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Constants
* @module constants
* @version 0.2.1
* @version 0.3.0
*/
import { ICONS } from './icons.js';
@@ -9,6 +9,11 @@ import { LOCALES } from './locales.js';
export { ICONS, LOCALES };
/**
*
*/
export const VERSION = '0.3.0';
/**
*
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast i18n
* @module i18n
* @version 0.2.1
* @version 0.3.0
*/
import { LOCALES } from './constants.js';
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Icons SVG定义
* @module icons
* @version 0.2.1
* @version 0.3.0
* @description 107 SVG
*/
+11 -5
View File
@@ -1,22 +1,20 @@
/**
* MetonaToast Toast通知库
* @module metona-toast
* @version 0.2.1
* @version 0.3.0
* @author thzxx
* @license MIT
*/
import { meToast } from './api.js';
import { Toast, _containerCache } from './toast.js';
import { Toast } from './toast.js';
import { animationUtils } from './animations.js';
import { themeUtils } from './themes.js';
import { i18nUtils } from './i18n.js';
import { pluginUtils, presetPlugins } from './plugins.js';
import { VERSION } from './constants.js';
import type { ToastInstance } from './types.js';
// 版本信息
const VERSION = '0.2.1';
/**
* MeToast meToast
*/
@@ -40,6 +38,14 @@ if (typeof i18nUtils.initI18n === 'function') {
i18nUtils.initI18n();
}
// 全局钩子 — 主题/语言变化转发到 Toast 钩子系统
if (typeof themeUtils.addThemeListener === 'function') {
themeUtils.addThemeListener(() => Toast.trigger('themeChange'));
}
if (typeof i18nUtils.addLocaleListener === 'function') {
i18nUtils.addLocaleListener(() => Toast.trigger('localeChange'));
}
// 浏览器环境全局注册
if (typeof window !== 'undefined') {
window.MeToast = enhancedMeToast;
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Locales
* @module locales
* @version 0.2.1
* @version 0.3.0
* @description zh-CN / en-US
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Plugins
* @module plugins
* @version 0.2.1
* @version 0.3.0
*/
import { t } from './i18n.js';
+6 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Styles
* @module styles
* @version 0.2.1
* @version 0.3.0
*/
import type { ThemeConfig } from './types.js';
@@ -506,6 +506,11 @@ export const generateThemeVariables = (theme: ThemeConfig): string => {
export const applyThemeVariables = (theme: ThemeConfig): void => {
if (typeof document === 'undefined') return;
const oldStyle = document.getElementById('metona-toast-custom-styles');
if (oldStyle && oldStyle.parentNode) {
oldStyle.parentNode.removeChild(oldStyle);
}
const css = generateThemeVariables(theme);
const customStyle = document.createElement('style');
customStyle.id = 'metona-toast-custom-styles';
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Templates HTML
* @module templates
* @version 0.2.1
* @version 0.3.0
* @description confirm / prompt / progress / action DOM
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Themes
* @module themes
* @version 0.2.1
* @version 0.3.0
*/
import { THEMES } from './constants.js';
+54 -16
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Toast Toast
* @module toast
* @version 0.2.1
* @version 0.3.0
*/
import { generateId, escapeHTML } from './utils.js';
@@ -9,6 +9,7 @@ import { DEFAULTS, ICONS, TYPE_COLORS, THEMES } from './constants.js';
import { injectStyles } from './styles.js';
import { getTheme } from './themes.js';
import { t, getLocaleDirection, getCurrentLocale } from './i18n.js';
import { hasAnimation, ensureAnimationCSS } from './animations.js';
import type { ToastConfig, ToastOptions, ToastInstance, ErrorInfo, TypeColor, ThemeConfig } from './types.js';
/**
@@ -18,6 +19,9 @@ export class Toast implements ToastInstance {
// 静态钩子系统
static _hooks: Map<string, Array<(toast: Toast) => boolean | void>> = new Map();
// 实例注册表 — 用于通过 id 反查实例(max 超限移除、外部接管等)
static _registry: Map<string, Toast> = new Map();
// 由 api.ts 注入的回调,通过 setCallbacks() 设置
private static _onError: ((errorInfo: ErrorInfo) => void) | null = null;
private static _removeToast: ((id: string) => void) | null = null;
@@ -46,21 +50,22 @@ export class Toast implements ToastInstance {
/**
* false false
* toast configChange/themeChange toast
*/
static trigger(name: string, toast: Toast): boolean {
static trigger(name: string, toast: Toast | null = null): boolean {
const list = this._hooks.get(name);
if (!list || list.length === 0) return true;
let allow = true;
list.forEach(fn => {
try {
const result = fn(toast);
const result = fn(toast as Toast);
if (result === false) allow = false;
}
catch (e) {
console.error('Hook error:', name, e);
if (Toast._onError) {
try { Toast._onError({ hook: name, error: e as Error, toast }); } catch (_e) { /* noop */ }
try { Toast._onError({ hook: name, error: e as Error, toast: toast as ToastInstance }); } catch (_e) { /* noop */ }
}
}
});
@@ -124,6 +129,7 @@ export class Toast implements ToastInstance {
}
injectStyles();
ensureAnimationCSS(this.config.animation || 'slide');
const container = this._getContainer();
const { theme, c, t } = this._palette();
@@ -148,6 +154,8 @@ export class Toast implements ToastInstance {
container.appendChild(el);
}
Toast._registry.set(this.id, this);
this._bindEvents(el);
this._startTimer();
@@ -227,19 +235,21 @@ export class Toast implements ToastInstance {
_limitToasts(container: HTMLElement): void {
const max = this.config.max || 6;
const list = Array.from(container.querySelectorAll('.met-toast'));
if (list.length >= max) {
const first = list[0] as HTMLElement;
const id = first?.dataset.id;
if (id && Toast._removeToast) {
while (list.length >= max) {
const el = list.shift() as HTMLElement;
const id = el?.dataset.id;
if (!id) continue;
const existing = Toast._registry.get(id);
if (existing) {
existing.close(true);
} else if (Toast._removeToast) {
Toast._removeToast(id);
}
}
}
_buildClassName(theme: string): string {
const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide';
const anim = hasAnimation(this.config.animation || '') ? (this.config.animation as string) : 'slide';
return [
'met-toast',
`met-${this.type}`,
@@ -327,6 +337,8 @@ export class Toast implements ToastInstance {
return;
}
Toast.trigger('click', this);
if (this.config.closeOnClick && !target.closest('.met-close')) {
if (typeof this.config.onClick === 'function') {
try {
@@ -344,8 +356,14 @@ export class Toast implements ToastInstance {
this._cleanups.push(() => el.removeEventListener('click', eventHandler));
if (this.config.pauseOnHover && (this.config.duration || 0) > 0) {
const mouseEnter = () => this._pause();
const mouseLeave = () => this._resume();
const mouseEnter = () => {
Toast.trigger('hover', this);
this._pause();
};
const mouseLeave = () => {
Toast.trigger('hover', this);
this._resume();
};
el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);
@@ -356,6 +374,16 @@ export class Toast implements ToastInstance {
});
}
// 入场动画生命周期钩子(离场为过渡/内联样式,不产生 animation 事件)
const animStart = () => Toast.trigger('animationStart', this);
const animEnd = () => Toast.trigger('animationEnd', this);
el.addEventListener('animationstart', animStart);
el.addEventListener('animationend', animEnd);
this._cleanups.push(() => {
el.removeEventListener('animationstart', animStart);
el.removeEventListener('animationend', animEnd);
});
if (this.config.draggable) {
this._bindDrag(el);
}
@@ -372,6 +400,7 @@ export class Toast implements ToastInstance {
el.setPointerCapture(e.pointerId);
el.style.transition = 'none';
this._pause();
Toast.trigger('dragStart', this);
};
const move = (e: PointerEvent) => {
@@ -398,6 +427,7 @@ export class Toast implements ToastInstance {
this._resume();
}
dx = dy = 0;
Toast.trigger('dragEnd', this);
};
el.addEventListener('pointerdown', down);
@@ -419,6 +449,7 @@ export class Toast implements ToastInstance {
if (!resuming) {
this.startedAt = Date.now();
this.remaining = this.config.duration || 0;
Toast.trigger('progressStart', this);
}
const tick = (): void => {
@@ -436,6 +467,7 @@ export class Toast implements ToastInstance {
}
if (this.remaining <= 0) {
Toast.trigger('progressEnd', this);
this.close();
return;
}
@@ -467,7 +499,9 @@ export class Toast implements ToastInstance {
}
update(partial: Partial<ToastOptions>): this {
Toast.trigger('beforeUpdate', this);
// beforeUpdate 钩子 — 返回 false 可阻止更新
if (!Toast.trigger('beforeUpdate', this)) return this;
const typeChanged = partial.type && partial.type !== this.type;
if (partial.type) this.type = partial.type;
if (partial.title !== undefined) this.title = partial.title;
@@ -559,14 +593,17 @@ export class Toast implements ToastInstance {
}
this.el = null;
Toast._registry.delete(this.id);
if (Toast._removeToast) Toast._removeToast(this.id);
}
close(immediate = false): void {
if (this.closing) return;
this.closing = true;
Toast.trigger('beforeClose', this);
// beforeClose 钩子 — 返回 false 可阻止关闭
if (!Toast.trigger('beforeClose', this)) return;
this.closing = true;
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
this._cleanups.forEach(fn => { try { fn(); } catch (_e) { /* noop */ } });
this._cleanups = [];
@@ -622,6 +659,7 @@ export class Toast implements ToastInstance {
}
Toast.trigger('afterClose', this);
Toast._registry.delete(this.id);
if (Toast._removeToast) Toast._removeToast(this.id);
}
}
+3 -5
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast
* @module types
* @version 0.2.1
* @version 0.3.0
*/
// ========== 基础类型 ==========
@@ -271,8 +271,8 @@ export interface StatusInfo {
// ========== 动画 ==========
export interface AnimationConfig {
enter: Record<string, string>;
leave: Record<string, string>;
enter: Record<string, string | number>;
leave: Record<string, string | number>;
duration: number;
easing: string;
name?: string;
@@ -538,10 +538,8 @@ export const HOOK_NAMES = {
ANIMATION_START: 'animationStart',
ANIMATION_END: 'animationEnd',
PROGRESS_START: 'progressStart',
PROGRESS_UPDATE: 'progressUpdate',
PROGRESS_END: 'progressEnd',
ERROR: 'error',
CUSTOM: 'custom',
} as const;
export type HookName = (typeof HOOK_NAMES)[keyof typeof HOOK_NAMES];
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast Utils
* @module utils
* @version 0.2.1
* @version 0.3.0
*/
/**
+196 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast 95%+
* @module tests
* @version 0.2.1
* @version 0.3.0
*/
import MeToast, { Toast, VERSION } from '../src/index';
@@ -139,6 +139,70 @@ describe('animations.ts 覆盖率', () => {
test('getActiveCount 返回动画数量', () => {
expect(MeToast.animations.getActiveCount()).toBeGreaterThan(0);
});
test('ensureAnimationCSS 内置动画不注入', () => {
const { ensureAnimationCSS } = require('../src/animations.js');
// jsdom 真实 DOM:内置动画由 styles.ts 统一注入,不应产生独立 style 标签
ensureAnimationCSS('slide');
expect(document.getElementById('metona-toast-anim-styles')).toBeNull();
});
test('ensureAnimationCSS 自定义动画注入样式(幂等)', () => {
const { ensureAnimationCSS } = require('../src/animations.js');
MeToast.animations.destroy(); // 清理模块注入状态,保证测试独立
MeToast.animations.register('my-anim', {
enter: { opacity: 0, transform: 'scale(0.5)' },
leave: { opacity: 1 },
duration: 400,
easing: 'ease',
});
ensureAnimationCSS('my-anim');
const styleEl = document.getElementById('metona-toast-anim-styles');
expect(styleEl).not.toBeNull();
expect(styleEl!.textContent).toContain('@keyframes met-my-anim-in');
expect(styleEl!.textContent).toContain('.met-anim-my-anim.met-toast.met-show');
// 幂等:二次调用不重复追加
const before = styleEl!.textContent!.length;
ensureAnimationCSS('my-anim');
expect(styleEl!.textContent!.length).toBe(before);
MeToast.animations.unregister('my-anim');
MeToast.animations.reset();
});
test('ensureAnimationCSS 未注册动画不注入', () => {
const { ensureAnimationCSS } = require('../src/animations.js');
// 清理 jsdom 中可能残留的动态 style 元素
document.getElementById('metona-toast-anim-styles')?.remove();
ensureAnimationCSS('not-registered');
expect(document.getElementById('metona-toast-anim-styles')).toBeNull();
});
test('hasAnimation 检查内置与自定义动画', () => {
const { hasAnimation } = require('../src/animations.js');
expect(hasAnimation('slide')).toBe(true);
expect(hasAnimation('nonexistent')).toBe(false);
MeToast.animations.register('has-anim-test', { enter: {}, leave: {}, duration: 300 });
expect(hasAnimation('has-anim-test')).toBe(true);
MeToast.animations.unregister('has-anim-test');
});
test('自定义动画注册后 _buildClassName 使用该动画', () => {
MeToast.animations.register('custom-anim', {
enter: { opacity: 0 },
leave: { opacity: 1 },
duration: 400,
easing: 'ease',
});
const t = MeToast.info({ message: 'custom', animation: 'custom-anim' });
expect(t.el?.className).toContain('met-anim-custom-anim');
MeToast.animations.unregister('custom-anim');
});
test('未注册动画名 fallback 到 slide', () => {
const t = new Toast({ message: 'test', animation: 'unknown-anim' });
t.create();
expect(t.el?.className).toContain('met-anim-slide');
});
});
// ==================================================================
@@ -745,7 +809,7 @@ describe('api.ts 覆盖率', () => {
test('getStatus 返回完整状态', () => {
const status = MeToast.getStatus();
expect(status.version).toBe('0.2.1');
expect(status.version).toBe('0.3.0');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
@@ -1299,3 +1363,133 @@ describe('最终补漏', () => {
});
});
// ==================================================================
// 11. v0.3.0 修复验证
// ==================================================================
describe('v0.3.0 修复验证', () => {
beforeEach(() => {
jest.clearAllMocks();
MeToast._toasts.clear();
MeToast.resetConfig();
(MeToast as unknown as { _destroyed?: boolean })._destroyed = false;
Toast._hooks.clear();
Toast._registry.clear();
const { _containerCache } = require('../src/toast.js');
_containerCache.clear();
// 清理动画模块状态(injected 集合 + 动态 style 引用),再恢复默认动画
MeToast.animations.destroy();
MeToast.animations.reset();
});
test('_limitToasts 超出 max 时真正关闭最早的 toast', () => {
const t1 = MeToast.info('m1');
const t2 = MeToast.info('m2');
const el = createMockElement() as unknown as HTMLElement;
(el as unknown as { querySelectorAll: unknown }).querySelectorAll = jest.fn(() => [
{ dataset: { id: t1.id } },
{ dataset: { id: t2.id } },
]);
const newToast = new Toast({ message: 'm3', max: 2 });
newToast._limitToasts(el as unknown as HTMLElement);
expect(t1.closing).toBe(true);
expect(t2.closing).toBe(false);
});
test('beforeClose 钩子返回 false 阻止关闭', () => {
const t = MeToast.info('test');
const blocker = () => false;
Toast.on('beforeClose', blocker);
t.close();
expect(t.closing).toBe(false);
Toast.off('beforeClose', blocker);
});
test('beforeUpdate 钩子返回 false 阻止更新', () => {
const t = MeToast.info('原始');
const blocker = () => false;
Toast.on('beforeUpdate', blocker);
t.update({ message: '新消息' });
expect(t.message).toBe('原始');
Toast.off('beforeUpdate', blocker);
});
test('configure/updateConfig/resetConfig 触发 configChange 钩子', () => {
const fn = jest.fn();
Toast.on('configChange', fn);
MeToast.configure({ duration: 2500 });
MeToast.updateConfig({ gap: 8 });
MeToast.resetConfig();
expect(fn).toHaveBeenCalledTimes(3);
Toast.off('configChange', fn);
});
test('init 触发 beforeInit/afterInit 钩子', () => {
const calls: string[] = [];
const before = jest.fn(() => calls.push('beforeInit'));
const after = jest.fn(() => calls.push('afterInit'));
Toast.on('beforeInit', before);
Toast.on('afterInit', after);
MeToast.init();
expect(calls).toEqual(['beforeInit', 'afterInit']);
Toast.off('beforeInit', before);
Toast.off('afterInit', after);
});
test('destroy 触发 beforeDestroy/afterDestroy 钩子', () => {
const calls: string[] = [];
const before = jest.fn(() => calls.push('beforeDestroy'));
const after = jest.fn(() => calls.push('afterDestroy'));
Toast.on('beforeDestroy', before);
Toast.on('afterDestroy', after);
MeToast.destroy();
expect(calls).toEqual(['beforeDestroy', 'afterDestroy']);
});
test('destroy 清理全部钩子', () => {
const fn = jest.fn();
Toast.on('afterShow', fn);
MeToast.destroy();
MeToast.success('after-destroy');
expect(fn).not.toHaveBeenCalled();
MeToast.animations.reset();
});
test('destroy 清理实例注册表', () => {
MeToast.info('m1');
expect(Toast._registry.size).toBe(1);
MeToast.destroy();
expect(Toast._registry.size).toBe(0);
MeToast.animations.reset();
});
test('destroy 后 init 恢复可用', () => {
MeToast.destroy();
MeToast.init({});
const t = MeToast.success('revived');
expect(t).toBeDefined();
MeToast.animations.reset();
});
test('init plugins 支持插件对象', () => {
const install = jest.fn();
MeToast.init({ plugins: [{ name: 'obj-plugin', install }] });
expect(MeToast.plugins.has('obj-plugin')).toBe(true);
expect(install).toHaveBeenCalled();
MeToast.plugins.unregister('obj-plugin');
});
test('animate 生命周期钩子 progressStart/progressEnd 触发', (done) => {
const start = jest.fn();
const end = jest.fn();
Toast.on('progressStart', start);
Toast.on('progressEnd', end);
const t = MeToast.info({ message: 'timed', duration: 30 });
setTimeout(() => {
expect(start).toHaveBeenCalled();
Toast.off('progressStart', start);
Toast.off('progressEnd', end);
done();
}, 120);
});
});
+6 -6
View File
@@ -1,7 +1,7 @@
/**
* MetonaToast
* @module tests
* @version 0.2.1
* @version 0.3.0
*/
import MeToast, { Toast, VERSION } from '../src/index';
@@ -105,8 +105,8 @@ const mockWindow = {
describe('版本信息', () => {
test('应该有正确的版本号', () => {
expect(VERSION).toBe('0.2.1');
expect(MeToast.version).toBe('0.2.1');
expect(VERSION).toBe('0.3.0');
expect(MeToast.version).toBe('0.3.0');
});
});
@@ -526,7 +526,7 @@ const mockWindow = {
test('应该能够获取状态信息', () => {
MeToast.success('消息');
const status = MeToast.getStatus();
expect(status.version).toBe('0.2.1');
expect(status.version).toBe('0.3.0');
expect(status.toasts).toBeGreaterThanOrEqual(0);
expect(status.theme).toBeDefined();
expect(status.locale).toBeDefined();
@@ -1213,9 +1213,9 @@ describe('新增功能', () => {
});
});
// ========== v0.2.1 新增功能测试 ==========
// ========== v0.3.0 新增功能测试 ==========
describe('v0.2.1 新增功能', () => {
describe('v0.3.0 新增功能', () => {
beforeEach(() => {
MeToast._toasts.clear();
const { _containerCache } = require('../src/toast.js');