Files
MetonaToast/src/core.js
T

1196 lines
35 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 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 而非 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); } });
}
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 ? `<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() {
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 ? `<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: '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<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: _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 };