Files
MetonaToast/src/api.ts
T
tianhao 1f718c4ef8 release: v0.5.0 — 覆盖率96.42%达标、lint零警告、覆盖率门禁、CI强化、发布自动化
- 覆盖率 96.42%(行):新增 59 测试共 385 个,覆盖 DOM 交互事件、对话框按钮交互、Intl/localStorage 异常路径、React 渲染生命周期
- 测试环境重构:SSR 分支用 defineProperty 真正覆盖 document/window;新增 tests/dom.test.ts、tests/setup.ts
- jest 覆盖率门禁 lines >= 95%;CI lint 改必过;新增 publish.yml(v* tag 自动发布到 Gitea registry)
- 修复:Toast.off 空数组残留、Toast.on 取消函数 this 绑定、action close:false 冒泡关闭、use() 重复注册钩子、i18n catch 引用错误
- 删除死代码 autoApplySystemTheme;lint 零警告(no-console 策略 + caughtErrorsIgnorePattern)
2026-08-08 15:02:51 +08:00

762 lines
23 KiB
TypeScript

/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.5.0
*/
import { Toast, _containerCache } from './toast.js';
import { DEFAULTS, VERSION } from './constants.js';
import { t, setCurrentLocale } from './i18n.js';
import { applyTheme } from './themes.js';
import { confirmHTML, promptHTML, progressHTML, actionHTML } from './templates.js';
import { presetPlugins } from './plugins.js';
import type {
ToastConfig, ToastOptions, ToastInstance,
LoadingControl, ProgressControl, CountdownControl,
ActionControl, QueueControl, GroupAPI,
ActionButton, PromiseOptions, ConfirmOptions,
PromptOptions, ProgressOptions, CountdownOptions,
QueueOptions, StackOptions, MeToast, ErrorInfo, Plugin,
StatusInfo, InitOptions,
} from './types.js';
/**
* 参数标准化工具
*/
const normalizeArgs = (args: unknown[], defaultType = 'default'): ToastOptions => {
const [first, second] = args;
if (typeof first === 'string') {
return {
...((second as Record<string, unknown>) || {}),
type: (second as Record<string, unknown>)?.type as string || defaultType,
message: first,
};
}
if (first && typeof first === 'object') {
return {
...(first as Record<string, unknown>),
type: (first as Record<string, unknown>).type as string || defaultType,
} as ToastOptions;
}
return {
type: defaultType,
message: '',
};
};
/**
* meToast API 对象
*/
const meToast: MeToast = {
_toasts: new Map<string, ToastInstance>(),
_config: { ...DEFAULTS } as unknown as ToastConfig,
version: VERSION,
configure(opts: Partial<ToastConfig>): MeToast {
if (!opts || typeof opts !== 'object') return this;
Object.assign(this._config, opts);
if (opts.theme) {
applyTheme(opts.theme);
}
if (opts.locale) {
setCurrentLocale(opts.locale);
}
Toast.trigger('configChange');
return this;
},
_emit(opts: ToastOptions): Toast {
const merged: ToastOptions = { ...this._config, ...opts };
if (merged.content && !merged.message) merged.message = merged.content;
const t = new Toast(merged);
t.create();
// beforeShow/onBeforeShow 拦截(返回 false)时 el 为 null,不注册幽灵实例
if (t.el !== null) {
this._toasts.set(t.id, t);
}
return t;
},
_remove(id: string): void {
this._toasts.delete(id);
},
find(id: string): ToastInstance | undefined {
if (!id) return undefined;
return this._toasts.get(id);
},
show(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'default');
return this._emit(normalized);
},
success(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'success');
return this._emit(normalized);
},
error(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'error');
return this._emit(normalized);
},
warning(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'warning');
return this._emit(normalized);
},
info(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
return this._emit(normalized);
},
loading(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): LoadingControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'loading');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const toast = this._emit(normalized);
return {
id: toast.id,
success: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'success', msg || t('success'), o),
error: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'error', msg || t('error'), o),
info: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'info', msg || t('info'), o),
warning: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'warning', msg || t('warning'), o),
update: (p: Partial<ToastOptions>) => { toast.update(p); return this; },
dismiss: () => toast.close(),
};
},
promise<T>(promise: Promise<T>, opts: PromiseOptions = {}): Promise<T> {
if (!promise || typeof (promise as unknown as { then?: unknown }).then !== 'function') {
console.error('MeToast.promise: first argument must be a Promise');
return Promise.reject(new Error('Invalid promise'));
}
const loadingMsg = opts.loading || t('loading');
const successMsg = opts.success || t('success');
const errorMsg = opts.error || t('error');
const ctrl = this.loading(loadingMsg);
return Promise.resolve(promise)
.then((data: T) => {
ctrl.success(successMsg);
return data;
})
.catch((err: unknown) => {
ctrl.error(errorMsg);
throw err;
});
},
/**
* loading 链式转换 — 原地 update 同一实例(id 稳定,不重建 DOM)
* duration 从 0 恢复为默认值,使转换后的 toast 自动关闭
*/
_resolve(loadingToast: ToastInstance, type: string, message: string, opts: ToastOptions): ToastInstance | null {
const old = this._toasts.get(loadingToast.id);
if (!old) return null;
old.update({
...opts,
type,
message,
duration: opts.duration ?? (old.config.duration || this._config.duration),
} as ToastOptions);
return old;
},
confirm(message: string, opts: ConfirmOptions = {}): Promise<boolean> {
if (typeof message !== 'string') {
console.error('MeToast.confirm: message must be a string');
return Promise.resolve(false);
}
return new Promise<boolean>((resolve) => {
let resolved = false;
const safeResolve = (val: boolean) => {
if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); }
};
const safetyTimeout = setTimeout(() => safeResolve(false), 10000);
const toast = this._emit({
...opts,
type: opts.type || 'warning',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: confirmHTML(opts),
});
setTimeout(() => {
const confirmBtn = toast.el?.querySelector('.met-confirm-btn') as HTMLElement | null;
const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null;
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
toast.close();
safeResolve(true);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
toast.close();
safeResolve(false);
});
}
}, 0);
});
},
prompt(message: string, opts: PromptOptions = {}): Promise<string | null> {
if (typeof message !== 'string') {
console.error('MeToast.prompt: message must be a string');
return Promise.resolve(null);
}
return new Promise<string | null>((resolve) => {
let resolved = false;
const safeResolve = (val: string | null) => {
if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); }
};
const safetyTimeout = setTimeout(() => safeResolve(null), 10000);
const toast = this._emit({
...opts,
type: opts.type || 'info',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: promptHTML(opts),
});
setTimeout(() => {
const input = toast.el?.querySelector('.met-input') as HTMLInputElement | null;
const submitBtn = toast.el?.querySelector('.met-submit-btn') as HTMLElement | null;
const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null;
if (input) {
input.focus();
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
toast.close();
safeResolve(input.value);
}
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
toast.close();
safeResolve(input?.value || null);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
toast.close();
safeResolve(null);
});
}
}, 0);
});
},
progress(messageOrOpts: string | ToastOptions, opts: ProgressOptions = {}): ProgressControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const toast = this._emit({
...normalized,
html: progressHTML(opts),
});
return {
id: toast.id,
setProgress(percent: number) {
const fill = toast.el?.querySelector('.met-progress-fill') as HTMLElement | null;
const text = toast.el?.querySelector('.met-progress-text') as HTMLElement | null;
if (fill) {
fill.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
if (text) {
text.textContent = `${Math.round(percent)}%`;
}
},
complete(message?: string) {
this.setProgress(100);
setTimeout(() => {
toast.update({
type: 'success',
message: message || t('success'),
html: '',
});
setTimeout(() => toast.close(), 1000);
}, 300);
},
error(message?: string) {
toast.update({
type: 'error',
message: message || t('error'),
html: '',
});
setTimeout(() => toast.close(), 2000);
},
dismiss() {
toast.close();
},
};
},
countdown(message: string, seconds = 10, opts: CountdownOptions = {}): CountdownControl {
if (typeof message !== 'string') {
console.error('MeToast.countdown: message must be a string');
return { id: '', cancel: () => {}, pause: () => {}, resume: () => {} };
}
let remaining = Math.max(1, parseInt(String(seconds)) || 10);
let timer: ReturnType<typeof setInterval> | null = null;
const toast = this._emit({
...opts,
type: opts.type || 'warning',
message: message.replace(/\{seconds\}/g, String(remaining)),
duration: 0,
closeButton: true,
showProgress: false,
});
const tick = (): void => {
remaining--;
if (remaining <= 0) {
if (timer !== null) clearInterval(timer);
toast.close();
if (typeof opts.onComplete === 'function') {
try { opts.onComplete(); } catch (e) { console.error('countdown onComplete error:', e); }
}
return;
}
toast.update({
message: message.replace(/\{seconds\}/g, String(remaining)),
});
};
timer = setInterval(tick, 1000);
return {
id: toast.id,
cancel() { if (timer !== null) clearInterval(timer); toast.close(); },
pause() { if (timer !== null) clearInterval(timer); },
resume() { if (timer !== null) clearInterval(timer); timer = setInterval(tick, 1000); },
};
},
action(messageOrOpts: string | ToastOptions, actions: ActionButton[] = [], opts: ToastOptions = {}): ActionControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = opts.duration ?? 0;
normalized.closeButton = opts.closeButton ?? true;
const toast = this._emit({
...normalized,
html: (normalized.html || '') + actionHTML(actions),
});
if (Array.isArray(actions)) {
setTimeout(() => {
actions.forEach((a, i) => {
const btn = toast.el?.querySelector(`.met-action-btn-${i}`) as HTMLElement | null;
if (btn && typeof a.onClick === 'function') {
btn.addEventListener('click', (e: Event) => {
// 阻止冒泡到 toast 的 closeOnClick 处理器,由 close 选项控制是否关闭
e.stopPropagation();
try { a.onClick(toast); } catch (err) { console.error('Action onClick error:', err); }
if (a.close !== false) toast.close();
});
}
});
}, 0);
}
return { id: toast.id, toast, dismiss: () => toast.close() };
},
queue(messages: Array<string | ToastOptions>, opts: QueueOptions = {}): QueueControl {
if (!Array.isArray(messages)) {
console.error('MeToast.queue: messages must be an array');
const p = Promise.resolve();
return { then: (fn) => p.then(fn), catch: (rj) => p.catch(rj), cancel: () => {} };
}
let cancelled = false;
const promise = new Promise<void>((resolve) => {
let index = 0;
const delay = opts.delay || 1000;
const userOnClose = opts.onClose;
const showNext = (): void => {
if (cancelled || index >= messages.length) {
resolve();
return;
}
const message = messages[index];
index++;
const msg = typeof message === 'string' ? message : ((message as ToastOptions)?.message || '');
const msgObj: ToastOptions = (typeof message === 'object' && message !== null) ? message as ToastOptions : {};
const msgOnClose = msgObj.onClose;
this._emit({
...opts,
...msgObj,
message: msg,
duration: msgObj.duration || opts.duration || 3000,
onClose: (toast: ToastInstance) => {
if (typeof msgOnClose === 'function') {
try { msgOnClose(toast); } catch (_e) { /* noop */ }
}
if (typeof userOnClose === 'function') {
try { userOnClose(toast); } catch (_e) { /* noop */ }
}
setTimeout(showNext, delay);
},
} as ToastOptions);
};
showNext();
});
return {
then: (fn, rj) => promise.then(fn, rj),
catch: (rj) => promise.catch(rj),
cancel: () => { cancelled = true; },
};
},
stack(messages: Array<string | ToastOptions>, opts: StackOptions = {}): void {
if (!Array.isArray(messages)) {
console.error('MeToast.stack: messages must be an array');
return;
}
messages.forEach((message, index) => {
setTimeout(() => {
const msg = typeof message === 'string' ? message : ((message as ToastOptions)?.message || '');
this._emit({
...opts,
...((typeof message === 'object' && message !== null) ? message as ToastOptions : {}),
message: msg,
} as ToastOptions);
}, index * (opts.stagger || 100));
});
},
dismiss(id?: string): void {
if (id) {
const t = this._toasts.get(id);
if (t) t.close();
return;
}
this._toasts.forEach(t => t.close());
},
/**
* 立即移除指定 Toast(不触发离场动画)
*/
removeToast(id: string): void {
if (!id) return;
const t = this._toasts.get(id);
if (t) t.remove();
},
clear(position?: string): void {
this._toasts.forEach(t => {
if (!position || t.config.position === position) t.close();
});
},
getAll(): Map<string, ToastInstance> {
return new Map(this._toasts);
},
count(): number {
return this._toasts.size;
},
group(name: string): GroupAPI {
const self = this;
const methods = ['show', 'success', 'error', 'warning', 'info', 'loading', 'action'];
const g: GroupAPI = { _group: name } as unknown as GroupAPI;
methods.forEach(m => {
(g as unknown as Record<string, (...args: unknown[]) => unknown>)[m] = (...args: unknown[]) => {
const lastArg = args[args.length - 1];
const isObj = typeof lastArg === 'object' && lastArg !== null && !Array.isArray(lastArg);
const selfMethods = self as unknown as Record<string, (...a: unknown[]) => unknown>;
if (isObj && args.length === 1) {
return selfMethods[m]({ ...(lastArg as Record<string, unknown>), group: name });
}
if (isObj) {
args.pop();
return selfMethods[m](...args, { ...(lastArg as Record<string, unknown>), group: name });
}
return selfMethods[m](...args, { group: name });
};
});
g.dismiss = () => self.dismissGroup(name);
g.count = () => self._groupCount(name);
return g;
},
dismissGroup(name: string): void {
this._toasts.forEach(t => { if (t.group === name) t.close(); });
},
_groupCount(name: string): number {
let c = 0;
this._toasts.forEach(t => { if (t.group === name) c++; });
return c;
},
getToasts(): ToastInstance[] {
return Array.from(this._toasts.values());
},
hasToasts(): boolean {
return this._toasts.size > 0;
},
getToast(id: string): ToastInstance | null {
if (!id) return null;
return this._toasts.get(id) || null;
},
closeAll(): void {
this._toasts.forEach(t => t.close());
},
clearAll(): void {
this._toasts.forEach(t => t.close());
},
pauseAll(): void {
this._toasts.forEach(t => t._pause());
},
resumeAll(): void {
this._toasts.forEach(t => t._resume());
},
updateAll(partial: Partial<ToastOptions>): void {
if (partial && typeof partial === 'object') {
this._toasts.forEach(t => t.update(partial));
}
},
findToasts(predicate: (toast: ToastInstance) => boolean): ToastInstance[] {
if (typeof predicate !== 'function') return [];
return Array.from(this._toasts.values()).filter(predicate);
},
findByType(type: string): ToastInstance[] {
return this.findToasts(t => t.type === type);
},
findByPosition(position: string): ToastInstance[] {
return this.findToasts(t => t.config.position === position);
},
// ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ======
init(options: InitOptions = {}): MeToast {
Toast.trigger('beforeInit');
this._destroyed = false;
if (options.config) {
this.configure(options.config);
}
if (options.theme && this.themes) {
this.themes.switchTheme(options.theme);
}
if (options.locale && this.i18n) {
this.i18n.switchLocale(options.locale);
}
if (options.plugins && Array.isArray(options.plugins)) {
options.plugins.forEach((p) => {
this.use(p);
});
}
Toast.trigger('afterInit');
return this;
},
getStatus(): StatusInfo {
return {
version: VERSION,
toasts: this._toasts.size,
theme: this.themes?.getCurrentTheme?.() || 'auto',
locale: this.i18n?.getCurrentLocale?.() || 'zh-CN',
plugins: this.plugins?.getNames?.() || [],
animations: this.animations?.getActiveCount?.() || 0,
};
},
getConfig(): ToastConfig {
return { ...this._config };
},
updateConfig(config: Partial<ToastConfig>): MeToast {
if (config && typeof config === 'object') {
Object.assign(this._config, config);
Toast.trigger('configChange');
}
return this;
},
resetConfig(): 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;
},
use(plugin: string | Plugin, options: Record<string, unknown> = {}): MeToast {
if (typeof plugin === 'string') {
const preset = presetPlugins[plugin];
if (!preset) {
console.warn(`Preset plugin "${plugin}" not found`);
return this;
}
this.plugins.register(plugin, { ...preset, ...options });
// 连接插件钩子(重复 use 前先卸载旧钩子,防止重复注册)
// 注意:off 引用存在已注册的插件对象上(stored),uninstall 时 this 即该对象
const stored = this.plugins.get(plugin) as (Record<string, unknown> & Plugin) | null;
if (plugin === 'accessibility') {
if (stored && typeof stored._off === 'function') { (stored._off as () => void)(); }
const acc = preset as Record<string, (t: ToastInstance) => void>;
if (stored) {
stored._off = Toast.on('afterShow', (toast: ToastInstance) => {
if (typeof acc.announce === 'function') acc.announce(toast);
});
}
}
if (plugin === 'persistence') {
if (stored && typeof stored._saveOff === 'function') { (stored._saveOff as () => void)(); }
const saved = typeof preset.install === 'function'
? preset.install(this.plugins as unknown as import('./types.js').PluginManager)
: null;
if (saved) this.configure(saved as Partial<ToastConfig>);
const p = preset as Record<string, (c: unknown) => void>;
if (stored) {
stored._saveOff = Toast.on('afterClose', () => {
if (typeof p.save === 'function') p.save(this.getConfig());
});
}
}
} else if (plugin && typeof plugin === 'object') {
this.plugins.register(plugin.name || 'custom', { ...plugin, ...options } as Plugin);
}
return this;
},
destroy(): void {
if (this._destroyed) return;
this._destroyed = true;
Toast.trigger('beforeDestroy');
this.dismiss();
// 清理子模块
if (this.plugins && typeof (this.plugins as unknown as Record<string, unknown>).destroy === 'function') {
(this.plugins as unknown as Record<string, () => void>).destroy();
}
if (this.themes) {
if (typeof this.themes.clearThemeListeners === 'function') {
this.themes.clearThemeListeners();
}
if (typeof this.themes.unwatchSystemTheme === 'function') {
this.themes.unwatchSystemTheme();
}
}
if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') {
this.i18n.clearLocaleListeners();
}
if (this.animations && typeof this.animations.destroy === 'function') {
this.animations.destroy();
}
// 清理 DOM 与静态资源
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); });
['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 as unknown as MeToast['animations'],
themes: null as unknown as MeToast['themes'],
i18n: null as unknown as MeToast['i18n'],
plugins: null as unknown as MeToast['plugins'],
presetPlugins: {} as MeToast['presetPlugins'],
};
// 连接 Toast 静态回调到 meToast 实例
Toast.setCallbacks({
onError: (info: ErrorInfo) => {
const onError = meToast._config.onError;
if (typeof onError === 'function') {
try { onError(info); } catch (_e) { /* noop */ }
}
},
removeToast: (id: string) => {
meToast._toasts.delete(id);
},
});
export { meToast as default, meToast };