### Changed - 全部源码从 JavaScript 迁移到 TypeScript (strict mode) - core.js (1244行) 拆分为 toast.ts + api.ts + templates.ts - 删除手动维护的 types/index.d.ts,类型从源码自动生成 - 构建工具链: Babel → ts-jest, 新增 @rollup/plugin-typescript - 新增 rollup-plugin-dts 生成合并 .d.ts ### Added - tsconfig.json (strict: true) - src/types.ts 核心类型模块 (39 个导出类型) - .eslintrc.json (@typescript-eslint) - .gitea/workflows/ci.yml (Node 18/20/22/24 矩阵) - CHANGELOG.md ### Aligned with metona-starter - package.json: type:module, engines≥16, prepublishOnly - rollup.config.js: dts plugin, port 3001 - jest.config.cjs, build.sh, serve.sh, .gitignore (.npmrc) ### Removed - babel.config.js, types/ 目录, 所有 src/*.js
552 lines
18 KiB
TypeScript
552 lines
18 KiB
TypeScript
/**
|
|
* MetonaToast Toast — Toast 类
|
|
* @module toast
|
|
* @version 0.2.0
|
|
*/
|
|
|
|
import { generateId, escapeHTML } from './utils.js';
|
|
import { DEFAULTS, ICONS, TYPE_COLORS, THEMES } from './constants.js';
|
|
import { injectStyles } from './styles.js';
|
|
import { getTheme } from './themes.js';
|
|
import { t, getLocaleDirection, getCurrentLocale } from './i18n.js';
|
|
import type { ToastConfig, ToastOptions, ToastInstance, ErrorInfo, TypeColor, ThemeConfig } from './types.js';
|
|
|
|
/**
|
|
* Toast 类 — 核心通知组件
|
|
*/
|
|
export class Toast implements ToastInstance {
|
|
// 静态钩子系统
|
|
static _hooks: Map<string, Array<(toast: Toast) => void>> = new Map();
|
|
|
|
// 由 api.ts 注入的回调,避免循环依赖
|
|
static _onError: ((errorInfo: ErrorInfo) => void) | null = null;
|
|
static _removeToast: ((id: string) => void) | null = null;
|
|
|
|
static on(name: string, fn: (toast: Toast) => void): () => void {
|
|
if (!this._hooks.has(name)) this._hooks.set(name, []);
|
|
this._hooks.get(name)!.push(fn);
|
|
return () => this.off(name, fn);
|
|
}
|
|
|
|
static off(name: string, fn: (toast: Toast) => void): void {
|
|
const list = this._hooks.get(name);
|
|
if (list) this._hooks.set(name, list.filter(f => f !== fn));
|
|
}
|
|
|
|
static trigger(name: string, toast: Toast): void {
|
|
const list = this._hooks.get(name);
|
|
if (list) {
|
|
list.forEach(fn => {
|
|
try { fn(toast); }
|
|
catch (e) {
|
|
console.error('Hook error:', name, e);
|
|
if (Toast._onError) {
|
|
try { Toast._onError({ hook: name, error: e as Error, toast }); } catch (_e) { /* noop */ }
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
message: string;
|
|
html: string;
|
|
iconHTML: string;
|
|
config: ToastConfig;
|
|
el: HTMLElement | null = null;
|
|
barEl: HTMLElement | null = null;
|
|
rafId: number | null = null;
|
|
remaining: number;
|
|
startedAt: number = 0;
|
|
paused: boolean = false;
|
|
closing: boolean = false;
|
|
group: string | null = null;
|
|
_cleanups: Array<() => void> = [];
|
|
|
|
constructor(opts: ToastOptions) {
|
|
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 as unknown as ToastConfig, ...opts };
|
|
// 防止嵌套对象被多个 toast 实例共享引用
|
|
if (opts.style && typeof opts.style === 'object') {
|
|
this.config.style = { ...opts.style };
|
|
}
|
|
this.remaining = this.config.duration || 0;
|
|
this.group = opts.group || null;
|
|
}
|
|
|
|
_palette(): { theme: string; c: TypeColor; t: Partial<ThemeConfig> } {
|
|
const theme = getTheme(this.config.theme || 'auto');
|
|
const c = (TYPE_COLORS[this.type] as TypeColor) || (TYPE_COLORS.default as TypeColor);
|
|
const tc = (THEMES[theme] as ThemeConfig) || (THEMES.light as ThemeConfig);
|
|
const t: Partial<ThemeConfig> = { bg: tc.bg, border: tc.border, shadow: tc.shadow };
|
|
return { theme, c, t };
|
|
}
|
|
|
|
create(): this {
|
|
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(): HTMLElement {
|
|
const position = this.config.position || 'top-right';
|
|
const zIndex = this.config.zIndex || 9999;
|
|
|
|
// 从模块级缓存获取 (由 api.ts 管理)
|
|
const cached = _containerCache.get(document.body);
|
|
if (cached && cached.has(position)) {
|
|
return cached.get(position)!;
|
|
}
|
|
|
|
// 缓存未命中时创建新容器
|
|
if (!_containerCache.has(document.body)) {
|
|
_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');
|
|
|
|
const posStyles: Record<string, string> = {
|
|
'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: HTMLElement): void {
|
|
const max = this.config.max || 6;
|
|
const list = Array.from(container.querySelectorAll('.met-toast'));
|
|
if (list.length >= max) {
|
|
const first = list[0] as HTMLElement;
|
|
const id = first?.dataset.id;
|
|
if (id && Toast._removeToast) {
|
|
Toast._removeToast(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
_buildClassName(theme: string): string {
|
|
const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom',
|
|
'slideUp', 'slideDown', 'slideLeft', 'slideRight'];
|
|
const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide';
|
|
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: HTMLElement, _theme: string, _t: Partial<ThemeConfig>): void {
|
|
const widthValue = typeof this.config.width === 'number'
|
|
? `${this.config.width}px`
|
|
: (typeof this.config.width === 'string' ? this.config.width : '360px');
|
|
|
|
const set = (p: string, v: string) => 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 as unknown as Record<string, string>)[k] = v; });
|
|
}
|
|
}
|
|
|
|
_buildContent(el: HTMLElement, c: TypeColor): void {
|
|
// 自定义渲染函数 — 完全接管 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) > 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="${t('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: HTMLElement): void {
|
|
const eventHandler = (e: Event) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
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) > 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: HTMLElement): void {
|
|
let sx = 0, sy = 0, dx = 0, dy = 0, dragging = false;
|
|
|
|
const down = (e: PointerEvent) => {
|
|
if ((e.target as HTMLElement).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: PointerEvent) => {
|
|
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: PointerEvent) => {
|
|
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): void {
|
|
if ((this.config.duration || 0) <= 0) return;
|
|
|
|
if (!resuming) {
|
|
this.startedAt = Date.now();
|
|
this.remaining = this.config.duration || 0;
|
|
}
|
|
|
|
const tick = (): void => {
|
|
if (this.paused || this.closing) return;
|
|
|
|
try {
|
|
const elapsed = Date.now() - this.startedAt;
|
|
this.remaining = Math.max(0, (this.config.duration || 0) - elapsed);
|
|
|
|
if (this.barEl) {
|
|
const ratio = this.remaining / (this.config.duration || 1);
|
|
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 (Toast._onError) {
|
|
try { Toast._onError({ source: 'timer', error: e as Error, toast: this }); } catch (_e) { /* noop */ }
|
|
}
|
|
}
|
|
|
|
this.rafId = requestAnimationFrame(tick);
|
|
};
|
|
|
|
this.rafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
_pause(): void {
|
|
if (this.paused || (this.config.duration || 0) <= 0) return;
|
|
this.paused = true;
|
|
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
|
|
this.remaining = Math.max(0, (this.config.duration || 0) - (Date.now() - this.startedAt));
|
|
}
|
|
|
|
_resume(): void {
|
|
if (!this.paused) return;
|
|
this.paused = false;
|
|
this.startedAt = Date.now() - ((this.config.duration || 0) - this.remaining);
|
|
this._startTimer(true);
|
|
}
|
|
|
|
update(partial: Partial<ToastOptions>): this {
|
|
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] as TypeColor) || (TYPE_COLORS.default as TypeColor);
|
|
this.barEl.style.background = c.fg;
|
|
}
|
|
const side = this.el.querySelector('.met-side') as HTMLElement | null;
|
|
if (side) {
|
|
const c = (TYPE_COLORS[this.type] as TypeColor) || (TYPE_COLORS.default as TypeColor);
|
|
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) > 0) {
|
|
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
|
|
this.startedAt = Date.now();
|
|
this.remaining = this.config.duration || 0;
|
|
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): void {
|
|
if (this.closing) return;
|
|
this.closing = true;
|
|
|
|
Toast.trigger('beforeClose', this);
|
|
if (this.rafId !== null) cancelAnimationFrame(this.rafId);
|
|
this._cleanups.forEach(fn => { try { fn(); } catch (_e) { /* noop */ } });
|
|
this._cleanups = [];
|
|
|
|
const el = this.el;
|
|
if (!el) {
|
|
if (Toast._removeToast) Toast._removeToast(this.id);
|
|
return;
|
|
}
|
|
|
|
// 立即脱离文档流
|
|
const container = el.parentNode;
|
|
if (container && !immediate) {
|
|
const elRect = el.getBoundingClientRect();
|
|
const containerRect = (container as HTMLElement).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(): void {
|
|
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);
|
|
if (Toast._removeToast) Toast._removeToast(this.id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 模块级共享容器缓存,所有 Toast 实例共用
|
|
*/
|
|
export const _containerCache: Map<Node, Map<string, HTMLElement>> = new Map();
|