release: v0.2.0 — TypeScript 源码重构
CI / test (18.x) (push) Successful in 9m52s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 2m49s

### 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
This commit is contained in:
tianhao
2026-07-25 11:22:07 +08:00
parent ee2b685f64
commit 9a6cd33d90
36 changed files with 3673 additions and 5473 deletions
+551
View File
@@ -0,0 +1,551 @@
/**
* 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();