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
+617
View File
@@ -0,0 +1,617 @@
/**
* MetonaToast API — meToast 核心 API 对象
* @module api
* @version 0.2.0
*/
import { Toast, _containerCache } from './toast.js';
import { DEFAULTS } from './constants.js';
import { escapeHTML } from './utils.js';
import { t } from './i18n.js';
import { applyTheme } from './themes.js';
import { setCurrentLocale } from './i18n.js';
import { confirmHTML, promptHTML, progressHTML, actionHTML } from './templates.js';
import type {
ToastConfig, ToastOptions, ToastInstance,
LoadingControl, ProgressControl, CountdownControl,
ActionControl, QueueControl, GroupAPI,
ActionButton, PromiseOptions, ConfirmOptions,
PromptOptions, ProgressOptions, CountdownOptions,
QueueOptions, StackOptions, MeToast, ErrorInfo,
} from './types.js';
/**
* 参数标准化工具
*/
const normalizeArgs = (args: unknown[], defaultType = 'default'): ToastOptions => {
const [first, second] = args;
if (typeof first === 'string') {
return {
...((second as Record<string, unknown>) || {}),
type: (second as Record<string, unknown>)?.type as string || defaultType,
message: first,
};
}
if (first && typeof first === 'object') {
return {
...(first as Record<string, unknown>),
type: (first as Record<string, unknown>).type as string || defaultType,
} as ToastOptions;
}
return {
type: defaultType,
message: '',
};
};
/**
* meToast API 对象
*/
const meToast: MeToast = {
_toasts: new Map<string, ToastInstance>(),
_config: { ...DEFAULTS } as unknown as ToastConfig,
version: '0.2.0',
configure(opts: Partial<ToastConfig>): MeToast {
if (!opts || typeof opts !== 'object') return this;
Object.assign(this._config, opts);
if (opts.theme) {
applyTheme(opts.theme);
}
if (opts.locale) {
setCurrentLocale(opts.locale);
}
return this;
},
_emit(opts: ToastOptions): Toast {
const merged: ToastOptions = { ...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: string): void {
this._toasts.delete(id);
},
find(id: string): ToastInstance | undefined {
if (!id) return undefined;
return this._toasts.get(id);
},
show(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'default');
return this._emit(normalized);
},
success(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'success');
return this._emit(normalized);
},
error(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'error');
return this._emit(normalized);
},
warning(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'warning');
return this._emit(normalized);
},
info(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
return this._emit(normalized);
},
loading(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): LoadingControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'loading');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const toast = this._emit(normalized);
return {
id: toast.id,
success: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'success', msg || t('success'), o),
error: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'error', msg || t('error'), o),
info: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'info', msg || t('info'), o),
warning: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'warning', msg || t('warning'), o),
update: (p: Partial<ToastOptions>) => { toast.update(p); return this; },
dismiss: () => toast.close(),
};
},
promise<T>(promise: Promise<T>, opts: PromiseOptions = {}): Promise<T> {
if (!promise || typeof (promise as unknown as { then?: unknown }).then !== 'function') {
console.error('MeToast.promise: first argument must be a Promise');
return Promise.reject(new Error('Invalid promise'));
}
const loadingMsg = opts.loading || t('loading');
const successMsg = opts.success || t('success');
const errorMsg = opts.error || t('error');
const ctrl = this.loading(loadingMsg);
return Promise.resolve(promise)
.then((data: T) => {
ctrl.success(successMsg);
return data;
})
.catch((err: unknown) => {
ctrl.error(errorMsg);
throw err;
});
},
_resolve(loadingToast: ToastInstance, type: string, message: string, opts: ToastOptions): ToastInstance | null {
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 } as ToastOptions);
},
confirm(message: string, opts: ConfirmOptions = {}): Promise<boolean> {
if (typeof message !== 'string') {
console.error('MeToast.confirm: message must be a string');
return Promise.resolve(false);
}
return new Promise<boolean>((resolve) => {
let resolved = false;
const safeResolve = (val: boolean) => {
if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); }
};
const safetyTimeout = setTimeout(() => safeResolve(false), 10000);
const toast = this._emit({
...opts,
type: opts.type || 'warning',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: confirmHTML(opts),
});
setTimeout(() => {
const confirmBtn = toast.el?.querySelector('.met-confirm-btn') as HTMLElement | null;
const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null;
if (confirmBtn) {
confirmBtn.addEventListener('click', () => {
toast.close();
safeResolve(true);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
toast.close();
safeResolve(false);
});
}
}, 0);
});
},
prompt(message: string, opts: PromptOptions = {}): Promise<string | null> {
if (typeof message !== 'string') {
console.error('MeToast.prompt: message must be a string');
return Promise.resolve(null);
}
return new Promise<string | null>((resolve) => {
let resolved = false;
const safeResolve = (val: string | null) => {
if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); }
};
const safetyTimeout = setTimeout(() => safeResolve(null), 10000);
const toast = this._emit({
...opts,
type: opts.type || 'info',
message,
duration: 0,
closeButton: false,
closeOnClick: false,
draggable: false,
html: promptHTML(opts),
});
setTimeout(() => {
const input = toast.el?.querySelector('.met-input') as HTMLInputElement | null;
const submitBtn = toast.el?.querySelector('.met-submit-btn') as HTMLElement | null;
const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null;
if (input) {
input.focus();
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
toast.close();
safeResolve(input.value);
}
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => {
toast.close();
safeResolve(input?.value || null);
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
toast.close();
safeResolve(null);
});
}
}, 0);
});
},
progress(messageOrOpts: string | ToastOptions, opts: ProgressOptions = {}): ProgressControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = 0;
normalized.closeButton = false;
normalized.showProgress = false;
const toast = this._emit({
...normalized,
html: progressHTML(opts),
});
return {
id: toast.id,
setProgress(percent: number) {
const fill = toast.el?.querySelector('.met-progress-fill') as HTMLElement | null;
const text = toast.el?.querySelector('.met-progress-text') as HTMLElement | null;
if (fill) {
fill.style.width = `${Math.min(100, Math.max(0, percent))}%`;
}
if (text) {
text.textContent = `${Math.round(percent)}%`;
}
},
complete(message?: string) {
this.setProgress(100);
setTimeout(() => {
toast.update({
type: 'success',
message: message || t('success'),
html: '',
});
setTimeout(() => toast.close(), 1000);
}, 300);
},
error(message?: string) {
toast.update({
type: 'error',
message: message || t('error'),
html: '',
});
setTimeout(() => toast.close(), 2000);
},
dismiss() {
toast.close();
},
};
},
countdown(message: string, seconds = 10, opts: CountdownOptions = {}): CountdownControl {
if (typeof message !== 'string') {
console.error('MeToast.countdown: message must be a string');
return { id: '', cancel: () => {}, pause: () => {}, resume: () => {} };
}
let remaining = Math.max(1, parseInt(String(seconds)) || 10);
let timer: ReturnType<typeof setInterval> | null = null;
const toast = this._emit({
...opts,
type: opts.type || 'warning',
message: message.replace(/\{seconds\}/g, String(remaining)),
duration: 0,
closeButton: true,
showProgress: false,
});
const tick = (): void => {
remaining--;
if (remaining <= 0) {
if (timer !== null) clearInterval(timer);
toast.close();
if (typeof opts.onComplete === 'function') {
try { opts.onComplete(); } catch (e) { console.error('countdown onComplete error:', e); }
}
return;
}
toast.update({
message: message.replace(/\{seconds\}/g, String(remaining)),
});
};
timer = setInterval(tick, 1000);
return {
id: toast.id,
cancel() { if (timer !== null) clearInterval(timer); toast.close(); },
pause() { if (timer !== null) clearInterval(timer); },
resume() { if (timer !== null) clearInterval(timer); timer = setInterval(tick, 1000); },
};
},
action(messageOrOpts: string | ToastOptions, actions: ActionButton[] = [], opts: ToastOptions = {}): ActionControl {
const normalized = normalizeArgs([messageOrOpts, opts], 'info');
normalized.duration = opts.duration ?? 0;
normalized.closeButton = opts.closeButton ?? true;
const toast = this._emit({
...normalized,
html: (normalized.html || '') + actionHTML(actions),
});
if (Array.isArray(actions)) {
setTimeout(() => {
actions.forEach((a, i) => {
const btn = toast.el?.querySelector(`.met-action-btn-${i}`) as HTMLElement | null;
if (btn && typeof a.onClick === 'function') {
btn.addEventListener('click', () => {
try { a.onClick(toast); } catch (e) { console.error('Action onClick error:', e); }
if (a.close !== false) toast.close();
});
}
});
}, 0);
}
return { id: toast.id, toast, dismiss: () => toast.close() };
},
queue(messages: Array<string | ToastOptions>, opts: QueueOptions = {}): QueueControl {
if (!Array.isArray(messages)) {
console.error('MeToast.queue: messages must be an array');
const p = Promise.resolve();
return { then: (fn) => p.then(fn), catch: (rj) => p.catch(rj), cancel: () => {} };
}
let cancelled = false;
const promise = new Promise<void>((resolve) => {
let index = 0;
const delay = opts.delay || 1000;
const userOnClose = opts.onClose;
const showNext = (): void => {
if (cancelled || index >= messages.length) {
resolve();
return;
}
const message = messages[index];
index++;
const msg = typeof message === 'string' ? message : ((message as ToastOptions)?.message || '');
const msgObj: ToastOptions = (typeof message === 'object' && message !== null) ? message as ToastOptions : {};
const msgOnClose = msgObj.onClose;
this._emit({
...opts,
...msgObj,
message: msg,
duration: msgObj.duration || opts.duration || 3000,
onClose: (toast: ToastInstance) => {
if (typeof msgOnClose === 'function') {
try { msgOnClose(toast); } catch (_e) { /* noop */ }
}
if (typeof userOnClose === 'function') {
try { userOnClose(toast); } catch (_e) { /* noop */ }
}
setTimeout(showNext, delay);
},
} as ToastOptions);
};
showNext();
});
return {
then: (fn, rj) => promise.then(fn, rj),
catch: (rj) => promise.catch(rj),
cancel: () => { cancelled = true; },
};
},
stack(messages: Array<string | ToastOptions>, opts: StackOptions = {}): void {
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 as ToastOptions)?.message || '');
this._emit({
...opts,
...((typeof message === 'object' && message !== null) ? message as ToastOptions : {}),
message: msg,
} as ToastOptions);
}, index * (opts.stagger || 100));
});
},
dismiss(id?: string): void {
if (id) {
const t = this._toasts.get(id);
if (t) t.close();
return;
}
this._toasts.forEach(t => t.close());
},
clear(position?: string): void {
this._toasts.forEach(t => {
if (!position || t.config.position === position) t.close();
});
},
destroy(): void {
this.dismiss();
if (typeof document !== 'undefined') {
const containers = document.querySelectorAll('.met-container');
containers.forEach(c => { if (c.parentNode) c.parentNode.removeChild(c); });
const style = document.getElementById('metona-toast-styles');
if (style && style.parentNode) style.parentNode.removeChild(style);
}
this._toasts.clear();
_containerCache.clear();
},
getAll(): Map<string, ToastInstance> {
return new Map(this._toasts);
},
count(): number {
return this._toasts.size;
},
group(name: string): GroupAPI {
const self = this;
const methods = ['show', 'success', 'error', 'warning', 'info', 'loading', 'action'];
const g: GroupAPI = { _group: name } as unknown as GroupAPI;
methods.forEach(m => {
(g as unknown as Record<string, (...args: unknown[]) => unknown>)[m] = (...args: unknown[]) => {
const lastArg = args[args.length - 1];
const isObj = typeof lastArg === 'object' && lastArg !== null && !Array.isArray(lastArg);
const selfMethods = self as unknown as Record<string, (...a: unknown[]) => unknown>;
if (isObj && args.length === 1) {
return selfMethods[m]({ ...(lastArg as Record<string, unknown>), group: name });
}
if (isObj) {
args.pop();
return selfMethods[m](...args, { ...(lastArg as Record<string, unknown>), group: name });
}
return selfMethods[m](...args, { group: name });
};
});
g.dismiss = () => self.dismissGroup(name);
g.count = () => self._groupCount(name);
return g;
},
dismissGroup(name: string): void {
this._toasts.forEach(t => { if (t.group === name) t.close(); });
},
_groupCount(name: string): number {
let c = 0;
this._toasts.forEach(t => { if (t.group === name) c++; });
return c;
},
getToasts(): ToastInstance[] {
return Array.from(this._toasts.values());
},
hasToasts(): boolean {
return this._toasts.size > 0;
},
getToast(id: string): ToastInstance | null {
if (!id) return null;
return this._toasts.get(id) || null;
},
closeAll(): void {
this._toasts.forEach(t => t.close());
},
clearAll(): void {
this._toasts.forEach(t => t.close());
},
pauseAll(): void {
this._toasts.forEach(t => t._pause());
},
resumeAll(): void {
this._toasts.forEach(t => t._resume());
},
updateAll(partial: Partial<ToastOptions>): void {
if (partial && typeof partial === 'object') {
this._toasts.forEach(t => t.update(partial));
}
},
findToasts(predicate: (toast: ToastInstance) => boolean): ToastInstance[] {
if (typeof predicate !== 'function') return [];
return Array.from(this._toasts.values()).filter(predicate);
},
findByType(type: string): ToastInstance[] {
return this.findToasts(t => t.type === type);
},
findByPosition(position: string): ToastInstance[] {
return this.findToasts(t => t.config.position === position);
},
// ====== 以下由 index.ts 增强 ======
init(_options?: Record<string, unknown>): MeToast { return this; },
getStatus() { return { version: '0.2.0', toasts: 0, theme: '', locale: '', plugins: [], animations: 0 }; },
getConfig(): ToastConfig { return { ...this._config }; },
updateConfig(_config: Partial<ToastConfig>): MeToast { return this; },
resetConfig(): MeToast { return this; },
use(_plugin: string | Record<string, unknown>, _options?: Record<string, unknown>): MeToast { return this; },
animations: null as unknown as MeToast['animations'],
themes: null as unknown as MeToast['themes'],
i18n: null as unknown as MeToast['i18n'],
plugins: null as unknown as MeToast['plugins'],
presetPlugins: {} as MeToast['presetPlugins'],
};
// 连接 Toast 静态回调到 meToast 实例
Toast._onError = (info: ErrorInfo) => {
const onError = meToast._config.onError;
if (typeof onError === 'function') {
try { onError(info); } catch (_e) { /* noop */ }
}
};
Toast._removeToast = (id: string) => {
meToast._toasts.delete(id);
};
export { meToast as default, meToast };