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
+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
*/
/**