Files
MetonaToast/src/core.js
T
thzxx f7d74d152d chore: v0.1.2 — 全面修复、优化与增强
修复:
- core.js: _resume 计时器 BUG (startedAt 被 _startTimer 覆盖导致暂停恢复失效)
- plugins.js: register 在 install 前展开导致 handler 实例丢失
- locales.js: zh-CN/en-US 大量重复 key 覆盖正确翻译值
- core.js: configure 改为原地更新避免 _config 引用断裂

优化:
- core.js: update() 类型变更时同步更新 DOM 类名和进度条颜色
- animations.js: getActiveCount 返回已注册动画数量
- themes.js: 移除 presetThemes 死代码和重复注册循环
- constants.js: THEMES.auto 从字符串改为规范化对象
- index.js: destroy 添加防重复调用保护

增强:
- core.js: 新增 onError 全局错误回调,钩子异常和定时器错误可被外部捕获
- styles.js: _resume 校准时间基准补偿注释
- 139 tests 全部通过
2026-07-24 14:42:47 +08:00

1244 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MetonaToast Core - 核心Toast逻辑
* @module core
* @version 0.1.2
* @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 而非 WeakMapdocument.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);
if (meToast._config.onError) {
try { meToast._config.onError({ hook: name, error: e, toast }); } catch (_) {}
}
}
});
}
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:${this.config.gap || 12}px;
box-sizing:border-box;
padding:${this.config.offset || 24}px;
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.keys(this.config.style).length > 0) {
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 ? `<div class="met-title">${escapeHTML(this.title)}</div>` : '';
const safeMessage = this.html
? `<div class="met-message">${this.html}</div>`
: (this.message ? `<div class="met-message">${escapeHTML(this.message)}</div>` : '');
const iconHTML = showIcon
? `<div class="met-icon" style="color:${c.fg}">${this.iconHTML || ICONS[this.type]}</div>` : '';
const closeHTML = showClose
? `<button class="met-close" type="button" aria-label="${i18nT('close')}">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>`
: '';
const progressHTML = showProgress
? (this.config.progressDirection === 'vertical'
? `<div class="met-progress-v"><div class="met-bar-v" style="background:${c.fg}"></div></div>`
: `<div class="met-progress"><div class="met-bar" style="background:${c.fg}"></div></div>`)
: '';
const sideHTML = showSide
? `<div class="met-side" style="background:${c.fg}"></div>` : '';
el.innerHTML = `
${iconHTML}
${sideHTML}
<div class="met-content">
${safeTitle}
${safeMessage}
</div>
${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(resuming = false) {
if (this.config.duration <= 0) return;
// 仅在首次启动时重置时间基准;resume 场景由 _resume() 预先调整 startedAt
if (!resuming) {
this.startedAt = Date.now();
this.remaining = this.config.duration;
}
const tick = () => {
if (this.paused || this.closing) return;
try {
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;
}
} catch (e) {
console.error('Timer tick error:', e);
if (meToast._config.onError) {
try { meToast._config.onError({ source: 'timer', error: e, toast: this }); } catch (_) {}
}
}
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(true);
}
update(partial) {
Toast.trigger('beforeUpdate', this);
const typeChanged = partial.type && partial.type !== this.type;
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; }
// 类型变更时同步更新 DOM 类名、边框颜色和进度条颜色
if (typeChanged) {
const typeClasses = ['met-success', 'met-error', 'met-warning', 'met-info', 'met-loading', 'met-default'];
typeClasses.forEach(c => this.el.classList.remove(c));
this.el.classList.add(`met-${this.type}`);
if (this.barEl) {
const c = (TYPE_COLORS[this.type] || TYPE_COLORS.default);
this.barEl.style.background = c.fg;
}
const side = this.el.querySelector('.met-side');
if (side) {
const c = (TYPE_COLORS[this.type] || TYPE_COLORS.default);
side.style.background = c.fg;
}
}
const content = this.el.querySelector('.met-content');
if (content) {
const safeTitle = this.title ? `<div class="met-title">${escapeHTML(this.title)}</div>` : '';
const safeMessage = this.html
? `<div class="met-message">${this.html}</div>`
: (this.message ? `<div class="met-message">${escapeHTML(this.message)}</div>` : '');
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) => `
<div style="display: flex; gap: 8px; margin-top: 12px;">
${buttons}
</div>`;
/**
* 确认对话框模板
* @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(`
<button class="met-confirm-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${confirmColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(confirmText)}</button>
<button class="met-cancel-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${cancelColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(cancelText)}</button>
`);
};
/**
* 输入对话框模板
* @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 `
<input type="${opts.inputType || 'text'}"
class="met-input"
placeholder="${escapeHTML(opts.placeholder || '')}"
value="${escapeHTML(opts.defaultValue || '')}"
style="width:100%;padding:8px 12px;border:1px solid rgba(0,0,0,0.1);border-radius:6px;font-size:13px;margin-top:8px;background:transparent;color:inherit;box-sizing:border-box">
${_btnRow(`
<button class="met-submit-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${submitColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(submitText)}</button>
<button class="met-cancel-btn" style="flex:1;padding:8px 16px;border:none;border-radius:6px;background:${cancelColor};color:white;cursor:pointer;font-size:13px">${escapeHTML(cancelText)}</button>
`)}
`;
};
/**
* 进度条模板
* @param {Object} opts - 选项
* @returns {string} HTML
*/
const _progressHTML = (opts) => {
const color = opts.progressColor || '#3b82f6';
return `
<div class="met-progress-bar" style="width:100%;height:8px;background:rgba(0,0,0,0.1);border-radius:4px;margin-top:12px;overflow:hidden">
<div class="met-progress-fill" style="width:0%;height:100%;background:${color};border-radius:4px;transition:width 0.3s ease"></div>
</div>
<div class="met-progress-text" style="font-size:12px;opacity:0.7;margin-top:4px;text-align:right">0%</div>
`;
};
/**
* 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 `<button class="${cls}" style="flex:1;padding:6px 12px;border:none;border-radius:6px;background:${bg};color:white;cursor:pointer;font-size:12px;font-weight:500">${escapeHTML(a.text || '')}</button>`;
}).join('');
return _btnRow(buttons);
};
/**
* 主对象
*/
const meToast = {
_toasts: new Map(),
_config: { ...DEFAULTS },
version: '0.1.2',
configure(opts) {
if (!opts || typeof opts !== 'object') return this;
// 原地更新,保持 _config 引用一致(index.js 的增强对象共享同一个 _config)
Object.assign(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<boolean>}
*/
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<string|null>}
*/
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: (normalized.html || '') + _actionHTML(actions),
});
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),
catch: (rj) => promise.catch(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 lastArg = args[args.length - 1];
const isObj = typeof lastArg === 'object' && lastArg !== null && !Array.isArray(lastArg);
// 单对象参数:直接合并group
if (isObj && args.length === 1) {
return self[m]({ ...lastArg, group: name });
}
// 末尾是配置对象:pop后合并
if (isObj) {
args.pop();
return self[m](...args, { ...lastArg, group: name });
}
// 无配置对象
return self[m](...args, { 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 };