/**
* MetonaToast Core - 核心Toast逻辑
* @module core
* @version 2.0.0
* @description 重构后的核心模块,包含Toast类的优化实现
*/
import { generateId, escapeHTML } from './utils.js';
import { DEFAULTS, ICONS, TYPE_COLORS, THEMES } from './constants.js';
import { injectStyles } from './styles.js';
import { getTheme, applyTheme } from './themes.js';
import { t as i18nT, setCurrentLocale, getLocaleDirection, getCurrentLocale } from './i18n.js';
// 模块级共享容器缓存,所有 Toast 实例共用
// 使用 Map 而非 WeakMap:document.body 在页面存活期间永不被 GC,
// 容器需要显式通过 destroy() 清理,WeakMap 在此场景无实际收益。
const _containerCache = new Map();
/**
* 参数标准化工具
* 支持多种调用方式:
* - success('message')
* - success({ message: 'text' })
* - success({ title: 'title', message: 'text' })
* - success('message', { duration: 5000 })
*/
const normalizeArgs = (args, defaultType = 'default') => {
const [first, second] = args;
// 情况1: success('message')
if (typeof first === 'string') {
return {
...second,
type: second?.type || defaultType,
message: first,
};
}
// 情况2: success({ message: 'text' }) 或 success({ title: 't', message: 'm' })
if (first && typeof first === 'object') {
return {
...first,
type: first.type || defaultType,
};
}
// 情况3: success() 无参数
return {
type: defaultType,
message: '',
};
};
/**
* Toast类 - 核心通知组件
*/
class Toast {
// 静态钩子系统
static _hooks = new Map();
static on(name, fn) {
if (!this._hooks.has(name)) this._hooks.set(name, []);
this._hooks.get(name).push(fn);
return () => this.off(name, fn);
}
static off(name, fn) {
const list = this._hooks.get(name);
if (list) this._hooks.set(name, list.filter(f => f !== fn));
}
static trigger(name, toast) {
const list = this._hooks.get(name);
if (list) list.forEach(fn => { try { fn(toast); } catch (e) { console.error('Hook error:', name, e); } });
}
constructor(opts) {
this.id = opts.id || generateId();
this.type = opts.type || 'default';
this.title = opts.title || '';
this.message = opts.message ?? opts.content ?? '';
this.html = opts.html || '';
this.iconHTML = opts.iconHTML || '';
this.config = { ...DEFAULTS, ...opts };
// 防止嵌套对象被多个 toast 实例共享引用
if (opts.style && typeof opts.style === 'object') {
this.config.style = { ...opts.style };
}
this.el = null;
this.barEl = null;
this.rafId = null;
this.remaining = this.config.duration;
this.startedAt = 0;
this.paused = false;
this.closing = false;
this._cleanups = [];
this.group = opts.group || null;
}
_palette() {
const theme = getTheme(this.config.theme);
const c = TYPE_COLORS[this.type] || TYPE_COLORS.default;
const tc = THEMES[theme] || THEMES.light;
const t = { bg: tc.bg, border: tc.border, shadow: tc.shadow };
return { theme, c, t };
}
create() {
if (typeof window === 'undefined' || typeof document === 'undefined') return this;
Toast.trigger('beforeShow', this);
injectStyles();
const container = this._getContainer();
const { theme, c, t } = this._palette();
this._limitToasts(container);
const el = document.createElement('div');
el.className = this._buildClassName(theme);
el.setAttribute('role', (this.type === 'error' || this.type === 'warning') ? 'alert' : 'status');
el.setAttribute('aria-live', this.type === 'error' ? 'assertive' : 'polite');
el.dataset.id = this.id;
this._applyStyles(el, theme, t);
this._buildContent(el, c);
this.el = el;
// Chrome bug: column-reverse + appendChild 会导致重叠。改用 column + insertBefore
const pos = this.config.position || 'top-right';
if (pos.startsWith('top')) {
container.insertBefore(el, container.firstChild);
} else {
container.appendChild(el);
}
this._bindEvents(el);
this._startTimer();
// 进入动画
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('met-show');
});
});
if (typeof this.config.onShow === 'function') {
try {
this.config.onShow(this);
} catch (e) {
console.error('onShow callback error:', e);
}
}
Toast.trigger('afterShow', this);
return this;
}
_getContainer() {
const position = this.config.position || 'top-right';
const zIndex = this.config.zIndex || 9999;
if (_containerCache.has(document.body)) {
const containers = _containerCache.get(document.body);
if (containers.has(position)) {
return containers.get(position);
}
} else {
_containerCache.set(document.body, new Map());
}
const el = document.createElement('div');
el.className = `met-container ${position}`;
el.setAttribute('aria-label', 'Notifications');
el.setAttribute('role', 'region');
// cssText 完全接管布局,不依赖 CSS class
const posStyles = {
'top-left': 'top:0;left:0;align-items:flex-start',
'top-center': 'top:0;left:0;right:0;align-items:center',
'top-right': 'top:0;right:0;align-items:flex-end',
'bottom-left': 'bottom:0;left:0;align-items:flex-start',
'bottom-center':'bottom:0;left:0;right:0;align-items:center',
'bottom-right': 'bottom:0;right:0;align-items:flex-end',
};
el.style.cssText = `
display:flex;
flex-direction:column;
gap:12px;
box-sizing:border-box;
padding:24px;
max-width:100vw;
position:fixed;
z-index:${zIndex};
pointer-events:none;
${posStyles[position] || 'top:0;right:0;align-items:flex-end'}
`;
document.body.appendChild(el);
_containerCache.get(document.body).set(position, el);
return el;
}
_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 && first.dataset.id;
if (id) {
const old = meToast._toasts.get(id);
if (old) old.close(true);
}
}
}
_buildClassName(theme) {
// CSS 支持的动画类型,未列出的 fallback 到 slide
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';
return [
'met-toast',
`met-${this.type}`,
`met-theme-${theme}`,
`met-anim-${anim}`,
(this.config.closeOnClick || this.config.draggable) ? 'met-clickable' : '',
this.config.className || '',
].filter(Boolean).join(' ');
}
_applyStyles(el, theme, t) {
const widthValue = typeof this.config.width === 'number'
? `${this.config.width}px`
: (typeof this.config.width === 'string' ? this.config.width : '360px');
// 只强制核心布局属性,动画/滤镜/颜色由 CSS class 控制
const set = (p, v) => el.style.setProperty(p, v, 'important');
set('position', 'relative');
set('flex-shrink', '0');
set('min-width', '240px');
set('max-width', 'calc(100vw - 48px)');
set('width', widthValue);
// 用户自定义样式最后应用
if (this.config.style) {
Object.entries(this.config.style).forEach(([k, v]) => { el.style[k] = v; });
}
}
_buildContent(el, c) {
// 自定义渲染函数 — 完全接管 DOM 构建
if (typeof this.config.render === 'function') {
el.innerHTML = this.config.render(this);
this.barEl = el.querySelector('.met-bar, .met-bar-v');
return;
}
const showIcon = this.config.icon !== false && (this.iconHTML || ICONS[this.type]);
const showClose = this.config.closeButton !== false;
const showProgress = this.config.showProgress !== false && this.config.duration > 0;
const showSide = (!showIcon && this.type !== 'default');
const safeTitle = this.title ? `
${escapeHTML(this.title)}
` : '';
const safeMessage = this.html
? `${this.html}
`
: (this.message ? `${escapeHTML(this.message)}
` : '');
const iconHTML = showIcon
? `${this.iconHTML || ICONS[this.type]}
` : '';
const closeHTML = showClose
? ``
: '';
const progressHTML = showProgress
? (this.config.progressDirection === 'vertical'
? ``
: ``)
: '';
const sideHTML = showSide
? `` : '';
el.innerHTML = `
${iconHTML}
${sideHTML}
${safeTitle}
${safeMessage}
${closeHTML}
${progressHTML}
`;
this.barEl = el.querySelector('.met-bar, .met-bar-v');
}
_bindEvents(el) {
const eventHandler = (e) => {
const target = e.target;
if (target.closest('.met-close')) {
e.stopPropagation();
this.close();
return;
}
if (this.config.closeOnClick && !target.closest('.met-close')) {
if (typeof this.config.onClick === 'function') {
try {
this.config.onClick(this);
} catch (err) {
console.error('onClick callback error:', err);
}
}
this.close();
return;
}
};
el.addEventListener('click', eventHandler);
this._cleanups.push(() => el.removeEventListener('click', eventHandler));
if (this.config.pauseOnHover && this.config.duration > 0) {
const mouseEnter = () => this._pause();
const mouseLeave = () => this._resume();
el.addEventListener('mouseenter', mouseEnter);
el.addEventListener('mouseleave', mouseLeave);
this._cleanups.push(() => {
el.removeEventListener('mouseenter', mouseEnter);
el.removeEventListener('mouseleave', mouseLeave);
});
}
if (this.config.draggable) {
this._bindDrag(el);
}
}
_bindDrag(el) {
let sx = 0, sy = 0, dx = 0, dy = 0, dragging = false;
const down = (e) => {
if (e.target.closest('.met-close')) return;
dragging = true;
sx = e.clientX;
sy = e.clientY;
el.setPointerCapture(e.pointerId);
el.style.transition = 'none';
this._pause();
};
const move = (e) => {
if (!dragging) return;
dx = e.clientX - sx;
dy = e.clientY - sy;
el.style.transform = `translate(${dx}px, ${dy}px) rotate(${dx * 0.1}deg)`;
el.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 200));
};
const up = (e) => {
if (!dragging) return;
dragging = false;
el.releasePointerCapture(e.pointerId);
el.style.transition = '';
if (Math.abs(dx) > 120) {
el.style.transform = `translate(${dx * 2}px, ${dy}px) rotate(${dx * 0.2}deg)`;
el.style.opacity = '0';
setTimeout(() => this.close(true), 250);
} else {
el.style.transform = '';
el.style.opacity = '';
this._resume();
}
dx = dy = 0;
};
el.addEventListener('pointerdown', down);
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
this._cleanups.push(() => {
el.removeEventListener('pointerdown', down);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
});
}
_startTimer() {
if (this.config.duration <= 0) return;
this.startedAt = Date.now();
this.remaining = this.config.duration;
const tick = () => {
if (this.paused || this.closing) return;
const elapsed = Date.now() - this.startedAt;
this.remaining = Math.max(0, this.config.duration - elapsed);
if (this.barEl) {
const ratio = this.remaining / this.config.duration;
const t = this.config.progressDirection === 'vertical'
? `scaleY(${ratio})` : `scaleX(${ratio})`;
this.barEl.style.transform = t;
}
if (this.remaining <= 0) {
this.close();
return;
}
this.rafId = requestAnimationFrame(tick);
};
this.rafId = requestAnimationFrame(tick);
}
_pause() {
if (this.paused || this.config.duration <= 0) return;
this.paused = true;
cancelAnimationFrame(this.rafId);
this.remaining = Math.max(0, this.config.duration - (Date.now() - this.startedAt));
}
_resume() {
if (!this.paused) return;
this.paused = false;
this.startedAt = Date.now() - (this.config.duration - this.remaining);
this._startTimer();
}
update(partial) {
Toast.trigger('beforeUpdate', this);
if (partial.type) this.type = partial.type;
if (partial.title !== undefined) this.title = partial.title;
if (partial.message !== undefined) this.message = partial.message;
if (partial.html !== undefined) this.html = partial.html;
if (!this.el) { Toast.trigger('afterUpdate', this); return this; }
const content = this.el.querySelector('.met-content');
if (content) {
const safeTitle = this.title ? `${escapeHTML(this.title)}
` : '';
const safeMessage = this.html
? `${this.html}
`
: (this.message ? `${escapeHTML(this.message)}
` : '');
content.innerHTML = `${safeTitle}${safeMessage}`;
}
// resetTimerOnUpdate: 更新内容后重置计时器
if (this.config.resetTimerOnUpdate && this.config.duration > 0) {
cancelAnimationFrame(this.rafId);
this.startedAt = Date.now();
this.remaining = this.config.duration;
this._startTimer();
}
if (typeof this.config.onUpdate === 'function') {
try { this.config.onUpdate(this); } catch (e) { console.error('onUpdate callback error:', e); }
}
Toast.trigger('afterUpdate', this);
return this;
}
close(immediate = false) {
if (this.closing) return;
this.closing = true;
Toast.trigger('beforeClose', this);
cancelAnimationFrame(this.rafId);
this._cleanups.forEach(fn => { try { fn(); } catch (_) {} });
this._cleanups = [];
const el = this.el;
if (!el) {
meToast._remove(this.id);
return;
}
// 立即脱离文档流:记录当前位置,然后设为 absolute,让下方 toast 自然上移
const container = el.parentNode;
if (container && !immediate) {
const elRect = el.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
el.style.position = 'absolute';
el.style.top = (elRect.top - containerRect.top) + 'px';
el.style.left = (elRect.left - containerRect.left) + 'px';
el.style.width = elRect.width + 'px';
el.style.margin = '0';
}
el.classList.add('met-leaving');
el.classList.remove('met-show');
const pos = this.config.position || 'top-right';
const isRTL = getLocaleDirection(getCurrentLocale()) === 'rtl';
let transform = 'scale(.96)';
if (pos.includes('right')) transform = isRTL ? 'translateX(-120%)' : 'translateX(120%)';
else if (pos.includes('left')) transform = isRTL ? 'translateX(120%)' : 'translateX(-120%)';
else if (pos.startsWith('top')) transform = 'translateY(-20px)';
else transform = 'translateY(20px)';
el.style.transform = transform;
el.style.opacity = '0';
setTimeout(() => this._destroy(), immediate ? 0 : 300);
}
_destroy() {
if (this.el && this.el.parentNode) {
this.el.parentNode.removeChild(this.el);
}
this.el = null;
if (typeof this.config.onClose === 'function') {
try {
this.config.onClose(this);
} catch (e) {
console.error('onClose callback error:', e);
}
}
Toast.trigger('afterClose', this);
meToast._remove(this.id);
}
}
// ========== HTML模板辅助函数 ==========
/**
* 按钮行模板 — confirm/prompt 共用
* @param {string} buttons - 按钮HTML
* @returns {string} 按钮行HTML
*/
const _btnRow = (buttons) => `
${buttons}
`;
/**
* 确认对话框模板
* @param {Object} opts - 选项
* @returns {string} HTML
*/
const _confirmHTML = (opts) => {
const confirmText = opts.confirmText || i18nT('confirm');
const cancelText = opts.cancelText || i18nT('cancel');
const confirmColor = opts.confirmColor || '#10b981';
const cancelColor = opts.cancelColor || '#6b7280';
return _btnRow(`
`);
};
/**
* 输入对话框模板
* @param {Object} opts - 选项
* @returns {string} HTML
*/
const _promptHTML = (opts) => {
const submitText = opts.submitText || i18nT('confirm');
const cancelText = opts.cancelText || i18nT('cancel');
const submitColor = opts.submitColor || '#3b82f6';
const cancelColor = opts.cancelColor || '#6b7280';
return `
${_btnRow(`
`)}
`;
};
/**
* 进度条模板
* @param {Object} opts - 选项
* @returns {string} HTML
*/
const _progressHTML = (opts) => {
const color = opts.progressColor || '#3b82f6';
return `
0%
`;
};
/**
* Action Toast 模板
* @param {Array} actions - 操作按钮数组 [{ text, onClick, style }]
* @returns {string} HTML
*/
const _actionHTML = (actions) => {
if (!Array.isArray(actions) || actions.length === 0) return '';
const buttons = actions.map((a, i) => {
const bg = a.style?.background || a.color || '#6366f1';
const cls = `met-action-btn-${i}`;
return ``;
}).join('');
return _btnRow(buttons);
};
/**
* 主对象
*/
const meToast = {
_toasts: new Map(),
_config: { ...DEFAULTS },
version: '2.0.0',
configure(opts) {
if (!opts || typeof opts !== 'object') return this;
this._config = { ...this._config, ...opts };
if (opts.theme) {
applyTheme(opts.theme);
}
if (opts.locale) {
setCurrentLocale(opts.locale);
}
return this;
},
_emit(opts) {
const merged = { ...this._config, ...opts };
if (merged.content && !merged.message) merged.message = merged.content;
const t = new Toast(merged);
t.create();
this._toasts.set(t.id, t);
return t;
},
_remove(id) {
this._toasts.delete(id);
},
find(id) {
if (!id) return undefined;
return this._toasts.get(id);
},
/**
* 显示默认Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象(当第一个参数是字符串时)
*/
show(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'default');
return this._emit(normalized);
},
/**
* 显示成功Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象
*/
success(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'success');
return this._emit(normalized);
},
/**
* 显示错误Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象
*/
error(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'error');
return this._emit(normalized);
},
/**
* 显示警告Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象
*/
warning(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'warning');
return this._emit(normalized);
},
/**
* 显示信息Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象
*/
info(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
return this._emit(normalized);
},
/**
* 显示加载Toast
* @param {string|Object} messageOrOpts - 消息字符串或选项对象
* @param {Object} [opts] - 选项对象
*/
loading(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'loading');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const t = this._emit(normalized);
return {
id: t.id,
success: (msg, o = {}) => this._resolve(t, 'success', msg, o),
error: (msg, o = {}) => this._resolve(t, 'error', msg, o),
info: (msg, o = {}) => this._resolve(t, 'info', msg, o),
warning: (msg, o = {}) => this._resolve(t, 'warning', msg, o),
update: (p) => { t.update(p); return this; },
dismiss: () => t.close(),
};
},
promise(promise, opts = {}) {
if (!promise || typeof promise.then !== 'function') {
console.error('MeToast.promise: first argument must be a Promise');
return Promise.reject(new Error('Invalid promise'));
}
const loadingMsg = opts.loading || i18nT('loading');
const successMsg = opts.success || i18nT('success');
const errorMsg = opts.error || i18nT('error');
const ctrl = this.loading(loadingMsg);
return Promise.resolve(promise)
.then((data) => {
ctrl.success(successMsg);
return data;
})
.catch((err) => {
ctrl.error(errorMsg);
throw err;
});
},
_resolve(loadingToast, type, message, opts) {
const id = loadingToast.id;
const old = this._toasts.get(id);
if (!old) return null;
const position = old.config.position;
old.close();
return this._emit({ ...opts, type, message, position });
},
/**
* 确认对话框
* @param {string} message - 消息内容
* @param {Object} [opts] - 选项
* @returns {Promise}
*/
confirm(message, opts = {}) {
if (typeof message !== 'string') {
console.error('MeToast.confirm: message must be a string');
return Promise.resolve(false);
}
return new Promise((resolve) => {
let resolved = false;
const safeResolve = (val) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } };
const safetyTimeout = setTimeout(() => safeResolve(false), 10000);
const t = this._emit({
...opts,
type: opts.type || 'warning',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: _confirmHTML(opts),
});
setTimeout(() => {
const confirmBtn = t.el?.querySelector('.met-confirm-btn');
const cancelBtn = t.el?.querySelector('.met-cancel-btn');
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
t.close();
safeResolve(true);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
t.close();
safeResolve(false);
});
}
}, 0);
});
},
/**
* 输入对话框
* @param {string} message - 消息内容
* @param {Object} [opts] - 选项
* @returns {Promise}
*/
prompt(message, opts = {}) {
if (typeof message !== 'string') {
console.error('MeToast.prompt: message must be a string');
return Promise.resolve(null);
}
return new Promise((resolve) => {
let resolved = false;
const safeResolve = (val) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } };
const safetyTimeout = setTimeout(() => safeResolve(null), 10000);
const t = this._emit({
...opts,
type: opts.type || 'info',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: _promptHTML(opts),
});
setTimeout(() => {
const input = t.el?.querySelector('.met-input');
const submitBtn = t.el?.querySelector('.met-submit-btn');
const cancelBtn = t.el?.querySelector('.met-cancel-btn');
if (input) {
input.focus();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
t.close();
safeResolve(input.value);
}
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
t.close();
safeResolve(input?.value || null);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
t.close();
safeResolve(null);
});
}
}, 0);
});
},
/**
* 进度Toast
* @param {string|Object} messageOrOpts - 消息或选项
* @param {Object} [opts] - 选项
* @returns {Object} 控制对象
*/
progress(messageOrOpts, opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const t = this._emit({
...normalized,
html: _progressHTML(opts),
});
return {
id: t.id,
setProgress(percent) {
const fill = t.el?.querySelector('.met-progress-fill');
const text = t.el?.querySelector('.met-progress-text');
if (fill) {
fill.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
if (text) {
text.textContent = `${Math.round(percent)}%`;
}
},
complete(message) {
this.setProgress(100);
setTimeout(() => {
t.update({
type: 'success',
message: message || i18nT('success'),
html: '',
});
setTimeout(() => t.close(), 1000);
}, 300);
},
error(message) {
t.update({
type: 'error',
message: message || i18nT('error'),
html: '',
});
setTimeout(() => t.close(), 2000);
},
dismiss() {
t.close();
},
};
},
/**
* 倒计时Toast
* @param {string} message - 消息内容
* @param {number} seconds - 秒数
* @param {Object} [opts] - 选项
* @returns {Object} 控制对象
*/
countdown(message, seconds = 10, opts = {}) {
if (typeof message !== 'string') {
console.error('MeToast.countdown: message must be a string');
return { cancel: () => {}, pause: () => {}, resume: () => {} };
}
let remaining = Math.max(1, parseInt(seconds) || 10);
let timer = null;
const t = this._emit({
...opts,
type: opts.type || 'warning',
message: message.replace(/\{seconds\}/g, remaining),
duration: 0,
closeButton: true,
showProgress: false,
});
const tick = () => {
remaining--;
if (remaining <= 0) {
clearInterval(timer);
t.close();
if (typeof opts.onComplete === 'function') {
try {
opts.onComplete();
} catch (e) {
console.error('countdown onComplete error:', e);
}
}
return;
}
t.update({
message: message.replace(/\{seconds\}/g, remaining),
});
};
timer = setInterval(tick, 1000);
return {
id: t.id,
cancel() {
clearInterval(timer);
t.close();
},
pause() {
clearInterval(timer);
},
resume() {
clearInterval(timer);
timer = setInterval(tick, 1000);
},
};
},
/**
* Action Toast — 带操作按钮的通知
* @param {string|Object} messageOrOpts - 消息
* @param {Array} actions - 操作按钮 [{ text, onClick, color }]
* @param {Object} [opts] - 选项
* @returns {Object} toast实例 + dismiss
*/
action(messageOrOpts, actions = [], opts = {}) {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = opts.duration ?? 0;
normalized.closeButton = opts.closeButton ?? true;
const t = this._emit({
...normalized,
html: _actionHTML(actions) + (normalized.html || ''),
});
if (Array.isArray(actions)) {
setTimeout(() => {
actions.forEach((a, i) => {
const btn = t.el?.querySelector(`.met-action-btn-${i}`);
if (btn && typeof a.onClick === 'function') {
btn.addEventListener('click', () => {
try { a.onClick(t); } catch (e) { console.error('Action onClick error:', e); }
if (a.close !== false) t.close();
});
}
});
}, 0);
}
return { id: t.id, toast: t, dismiss: () => t.close() };
},
/**
* 队列Toast
* @param {Array} messages - 消息数组
* @param {Object} [opts] - 选项
* @returns {Promise}
*/
queue(messages, opts = {}) {
if (!Array.isArray(messages)) {
console.error('MeToast.queue: messages must be an array');
return { then: (fn) => Promise.resolve().then(fn), cancel: () => {} };
}
let cancelled = false;
const promise = new Promise((resolve) => {
let index = 0;
const delay = opts.delay || 1000;
const userOnClose = opts.onClose;
const showNext = () => {
if (cancelled || index >= messages.length) {
resolve();
return;
}
const message = messages[index];
index++;
const msg = typeof message === 'string' ? message : (message?.message || '');
const msgObj = (typeof message === 'object' && message !== null) ? message : {};
const msgOnClose = msgObj.onClose;
this._emit({
...opts,
...msgObj,
message: msg,
duration: msgObj.duration || opts.duration || 3000,
onClose: (toast) => {
if (typeof msgOnClose === 'function') {
try { msgOnClose(toast); } catch (_) {}
}
if (typeof userOnClose === 'function') {
try { userOnClose(toast); } catch (_) {}
}
setTimeout(showNext, delay);
},
});
};
showNext();
});
return {
then: (fn, rj) => promise.then(fn, rj),
cancel: () => { cancelled = true; },
};
},
/**
* 堆叠Toast
* @param {Array} messages - 消息数组
* @param {Object} [opts] - 选项
*/
stack(messages, opts = {}) {
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?.message || '');
this._emit({
...opts,
...((typeof message === 'object' && message !== null) ? message : {}),
message: msg,
});
}, index * (opts.stagger || 100));
});
},
dismiss(id) {
if (id) {
const t = this._toasts.get(id);
if (t) t.close();
return;
}
this._toasts.forEach(t => t.close());
},
clear(position) {
this._toasts.forEach(t => {
if (!position || t.config.position === position) t.close();
});
},
destroy() {
this.dismiss();
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach(c => c.parentNode && c.parentNode.removeChild(c));
const style = document.getElementById('metona-toast-styles');
if (style) style.parentNode.removeChild(style);
}
this._toasts.clear();
_containerCache.clear();
},
getAll() {
return new Map(this._toasts);
},
count() {
return this._toasts.size;
},
/**
* 创建Toast分组 — 返回一个自动添加 group 参数的子对象
* @param {string} name - 分组名称
* @returns {Object}
*/
group(name) {
const self = this;
const methods = ['show','success','error','warning','info','loading','action'];
const g = { _group: name };
methods.forEach(m => {
g[m] = (...args) => {
const last = typeof args[args.length - 1] === 'object' && args[args.length - 1] !== null && !Array.isArray(args[args.length - 1])
? args.pop() : {};
return self[m](...args, { ...last, group: name });
};
});
g.dismiss = () => self.dismissGroup(name);
g.count = () => self._groupCount(name);
return g;
},
/**
* 按组关闭Toast
* @param {string} name - 分组名称
*/
dismissGroup(name) {
this._toasts.forEach(t => { if (t.group === name) t.close(); });
},
_groupCount(name) {
let c = 0;
this._toasts.forEach(t => { if (t.group === name) c++; });
return c;
},
};
export { meToast as default, meToast, Toast };