vanilla-jui 1.3.9 → 1.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.umd.js +1 -1
  3. package/dist/style.css +1 -1
  4. package/package.json +3 -2
  5. package/src/components/accordion.js +373 -0
  6. package/src/components/components.js +0 -0
  7. package/src/components/drop.js +437 -0
  8. package/src/components/flow.js +1133 -0
  9. package/src/components/form.js +585 -0
  10. package/src/components/icons.js +83 -0
  11. package/src/components/menu.js +422 -0
  12. package/src/components/modal.js +1174 -0
  13. package/src/components/offcanvas.js +332 -0
  14. package/src/components/pagination.js +536 -0
  15. package/src/components/parabola.js +295 -0
  16. package/src/components/sticky.js +271 -0
  17. package/src/components/swiper.js +1123 -0
  18. package/src/components/tabs.js +746 -0
  19. package/src/components/theme.js +353 -0
  20. package/src/components/toast.js +329 -0
  21. package/src/components/toc.js +226 -0
  22. package/src/components/tooltip.js +86 -0
  23. package/src/components/validator.js +586 -0
  24. package/src/core/Component.js +248 -0
  25. package/src/css/accordion.css +72 -0
  26. package/src/css/animation.css +177 -0
  27. package/src/css/avatar.css +62 -0
  28. package/src/css/badge.css +66 -0
  29. package/src/css/breadcrumb.css +106 -0
  30. package/src/css/button.css +296 -0
  31. package/src/css/card.css +151 -0
  32. package/src/css/color.css +1163 -0
  33. package/src/css/color_p3.css +1172 -0
  34. package/src/css/components.css +26 -0
  35. package/src/css/content.css +716 -0
  36. package/src/css/divider.css +9 -0
  37. package/src/css/drop.css +28 -0
  38. package/src/css/flow.css +86 -0
  39. package/src/css/font.css +23 -0
  40. package/src/css/form.css +919 -0
  41. package/src/css/gradient.css +224 -0
  42. package/src/css/icon.css +63 -0
  43. package/src/css/index.css +12 -0
  44. package/src/css/layout.css +782 -0
  45. package/src/css/loading.css +38 -0
  46. package/src/css/menu.css +564 -0
  47. package/src/css/modal.css +94 -0
  48. package/src/css/offcanvas.css +104 -0
  49. package/src/css/pagination.css +25 -0
  50. package/src/css/popup.css +69 -0
  51. package/src/css/skeleton.css +76 -0
  52. package/src/css/status.css +56 -0
  53. package/src/css/swiper.css +160 -0
  54. package/src/css/table.css +52 -0
  55. package/src/css/tabs.css +95 -0
  56. package/src/css/tag.css +97 -0
  57. package/src/css/theme-palette.css +59 -0
  58. package/src/css/tip.css +84 -0
  59. package/src/css/toast.css +154 -0
  60. package/src/css/toc.css +38 -0
  61. package/src/css/tokens.css +1295 -0
  62. package/src/css/toolbar.css +115 -0
  63. package/src/css/tooltip.css +19 -0
  64. package/src/icons/index.js +30 -0
  65. package/src/index.js +45 -0
  66. package/src/locales/index.js +45 -0
  67. package/src/locales/index.min.js +1 -0
  68. package/src/utilities/browser.js +111 -0
  69. package/src/utilities/cache.js +159 -0
  70. package/src/utilities/core.js +502 -0
  71. package/src/utilities/debug.js +103 -0
  72. package/src/utilities/dom.js +313 -0
  73. package/src/utilities/events.js +69 -0
  74. package/src/utilities/http.js +30 -0
  75. package/src/utilities/legacy-dom.js +179 -0
  76. package/src/utilities/storage.js +46 -0
@@ -0,0 +1,353 @@
1
+ import { jsx } from 'vanilla-signal';
2
+ import { t } from 'vanilla-signal-i18n';
3
+
4
+ import locales from '../locales/index.js';
5
+ import { all, requireRenderDOM } from '../utilities/dom.js';
6
+ import { createEventManager } from '../utilities/events.js';
7
+ import { getCookie, setCookie } from '../utilities/storage.js';
8
+
9
+ /**
10
+ * @typedef {object} ThemeOptions
11
+ * @property {string} [mode="dark"] 主题明暗模式,支持 light、dark、auto 或自定义值。
12
+ * @property {string} [theme="indigo"] 色彩主题名。
13
+ * @property {string} [radius="sm"] 圆角等级。
14
+ * @property {string} [shadow="sm"] 阴影等级。
15
+ * @property {string} [font="sm"] 字号等级。
16
+ * @property {string} [key="jui-theme"] Cookie 存储 key。
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} ThemePanelGroup
21
+ * @property {string} title 分组标题。
22
+ * @property {string} type 对应 ThemeOptions 中的配置项。
23
+ * @property {Array<[string,string]>} buttons 按钮配置,格式为 `[值, 展示文案]`。
24
+ */
25
+
26
+ /**
27
+ * 主题管理组件。
28
+ *
29
+ * 负责主题配置的实例化、主题面板交互和 Cookie 读写。实例初始化不修改 html
30
+ * 类名,仅在面板点击交互时同步当前点击项对应的 html class。
31
+ *
32
+ * 如需首屏按 Cookie 渲染 html class,可在 <head> 中放置:
33
+ *
34
+ * ```html
35
+ * <script>
36
+ * (function(d,k){var m=d.cookie.match(new RegExp('(?:^|; )'+k+'=([^;]*)'));if(!m)return;try{var o=JSON.parse(m[1]),r=o.mode==='auto'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):o.mode,h=d.documentElement;h.classList.add(r||'dark','j-theme-'+(o.theme||'indigo'),'j-radius-'+(o.radius||'sm'),'j-shadow-'+(o.shadow||'sm'),'j-font-'+(o.font||'sm'));}catch(e){}})(document,'jui-theme');
37
+ * </script>
38
+ * ```
39
+ */
40
+ export class Theme {
41
+ /**
42
+ * 创建主题实例。
43
+ * @param {ThemeOptions} [options={}] 主题配置。
44
+ */
45
+ constructor(options = {}) {
46
+ requireRenderDOM('Theme');
47
+
48
+ this.props = {
49
+ mode: 'dark',
50
+ theme: 'indigo',
51
+ radius: 'sm',
52
+ shadow: 'sm',
53
+ font: 'sm',
54
+ key: 'jui-theme',
55
+ ...options,
56
+ };
57
+
58
+ this.languages = locales;
59
+
60
+ this._init();
61
+ }
62
+
63
+ /**
64
+ * 初始化主题状态、读取缓存并绑定事件。
65
+ * @private
66
+ * @returns {void}
67
+ */
68
+ _init() {
69
+ this.cleanup = { events: createEventManager() };
70
+ this.runtime = { destroyed: false };
71
+ this._loadConfig();
72
+ this._bindEvent();
73
+ this._syncActiveButtons();
74
+ }
75
+
76
+ /**
77
+ * 获取主题面板文案。
78
+ * @private
79
+ * @param {string} key 文案 key。
80
+ * @returns {string}
81
+ */
82
+ _t(key) {
83
+ return t(key, this.languages);
84
+ }
85
+
86
+ /**
87
+ * 从 Cookie 读取主题配置。
88
+ * @private
89
+ * @returns {void}
90
+ */
91
+ _loadConfig() {
92
+ try {
93
+ const result = getCookie(this.props.key);
94
+ if (result && result.trim()) {
95
+ this.props = Object.assign({}, this.props, JSON.parse(result));
96
+ }
97
+ } catch {}
98
+ }
99
+
100
+ /**
101
+ * 将当前主题配置写入 Cookie。
102
+ * @private
103
+ * @returns {void}
104
+ */
105
+ _saveConfig() {
106
+ const { mode, theme, radius, shadow, font } = this.props;
107
+ const render = mode === 'auto' ? this._scheme() : mode;
108
+ setCookie(
109
+ this.props.key,
110
+ JSON.stringify({ mode, theme, radius, shadow, font, render })
111
+ );
112
+ }
113
+
114
+ _scheme() {
115
+ return window?.matchMedia('(prefers-color-scheme: dark)')?.matches
116
+ ? 'dark'
117
+ : 'light';
118
+ }
119
+
120
+ /**
121
+ * 同步主题面板按钮激活态。
122
+ * @private
123
+ * @returns {void}
124
+ */
125
+ _syncActiveButtons() {
126
+ const items = all('.palette-item');
127
+ for (const item of items) {
128
+ const type = item.dataset.palette;
129
+ const val = this.props[type];
130
+ const buttons = all('button[data-palette]', item);
131
+ for (const btn of buttons) {
132
+ const v = btn.dataset.palette;
133
+ const isActive = v === (val === undefined ? null : val);
134
+ btn.classList.toggle('is-active', isActive);
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 绑定主题面板点击事件。
141
+ * @private
142
+ * @returns {void}
143
+ */
144
+ _bindEvent() {
145
+ this.cleanup.events.on('palette', document.body, 'click', (e) => {
146
+ const btn = e.target.closest(
147
+ '.palette-item .items > button[data-palette]'
148
+ );
149
+ if (!btn) return;
150
+
151
+ const groupEl = btn.closest('.palette-item');
152
+ if (!groupEl) return;
153
+
154
+ const type = groupEl.dataset.palette;
155
+ const value = btn.dataset.palette;
156
+
157
+ if (btn.classList.contains('is-active')) return;
158
+
159
+ const previous = this.props[type];
160
+ this.props[type] = value;
161
+
162
+ const h = document.documentElement;
163
+ if (type === 'mode') {
164
+ const actual = value === 'auto' ? this._scheme() : value;
165
+ h.classList.remove('light', 'dark', previous);
166
+ if (actual) h.classList.add(actual);
167
+ } else {
168
+ const prefixes = {
169
+ theme: 'j-theme-',
170
+ radius: 'j-radius-',
171
+ shadow: 'j-shadow-',
172
+ font: 'j-font-',
173
+ };
174
+ const prefix = prefixes[type];
175
+ if (prefix) {
176
+ const toRemove = Array.from(h.classList).filter((c) =>
177
+ c.startsWith(prefix)
178
+ );
179
+ if (toRemove.length) h.classList.remove(...toRemove);
180
+ h.classList.add(`${prefix}${value}`);
181
+ }
182
+ }
183
+
184
+ this._saveConfig();
185
+ this._syncActiveButtons();
186
+ });
187
+ }
188
+
189
+ /**
190
+ * 解绑主题面板点击事件。
191
+ * @private
192
+ * @returns {void}
193
+ */
194
+ _unbindEvent() {
195
+ this.cleanup.events.clear();
196
+ }
197
+
198
+ /**
199
+ * 创建主题交互面板。
200
+ * @param {string} [containerClass="j-theme-palette"] 主题交互面板容器类名。
201
+ * @param {ThemePanelGroup[]|null} [panelConfig=null] 自定义面板分组配置。
202
+ * @returns {HTMLElement} 主题交互面板节点。
203
+ */
204
+ createPanel(containerClass = 'j-theme-palette', panelConfig = null) {
205
+ const groups = panelConfig || [
206
+ {
207
+ title: this._t('Theme'),
208
+ type: 'theme',
209
+ buttons: [
210
+ ['gray', this._t('Gray')],
211
+ ['olive', this._t('Olive')],
212
+ ['tomato', this._t('Tomato')],
213
+ ['ruby', this._t('Ruby')],
214
+ ['pink', this._t('Pink')],
215
+ ['violet', this._t('Violet')],
216
+ ['indigo', this._t('Indigo')],
217
+ ['blue', this._t('Blue')],
218
+ ['teal', this._t('Teal')],
219
+ ['grass', this._t('Grass')],
220
+ ['mint', this._t('Mint')],
221
+ ['lime', this._t('Lime')],
222
+ ['yellow', this._t('Yellow')],
223
+ ['orange', this._t('Orange')],
224
+ ['gold', this._t('Gold')],
225
+ ],
226
+ },
227
+ {
228
+ title: this._t('Radius'),
229
+ type: 'radius',
230
+ buttons: [
231
+ ['none', this._t('n')],
232
+ ['sm', this._t('sm')],
233
+ ['md', this._t('md')],
234
+ ['lg', this._t('lg')],
235
+ ['xl', this._t('XL')],
236
+ ['round', this._t('Round')],
237
+ ],
238
+ },
239
+ {
240
+ title: this._t('Shadow'),
241
+ type: 'shadow',
242
+ buttons: [
243
+ ['none', this._t('n')],
244
+ ['sm', this._t('sm')],
245
+ ['md', this._t('md')],
246
+ ['lg', this._t('lg')],
247
+ ],
248
+ },
249
+ {
250
+ title: this._t('Font'),
251
+ type: 'font',
252
+ buttons: [
253
+ ['sm', this._t('sm')],
254
+ ['md', this._t('md')],
255
+ ],
256
+ },
257
+ {
258
+ title: this._t('Mode'),
259
+ type: 'mode',
260
+ buttons: [
261
+ ['light', this._t('Light')],
262
+ ['dark', this._t('Dark')],
263
+ ['auto', this._t('Auto')],
264
+ ],
265
+ },
266
+ ];
267
+
268
+ const panel = jsx('div', {
269
+ className: containerClass,
270
+ children: [
271
+ jsx('div', {
272
+ style: {
273
+ display: 'flex',
274
+ justifyContent: 'center',
275
+ },
276
+ children: jsx('button', {
277
+ className: 'j-button is-text',
278
+ 'data-action': 'close',
279
+ children: this._t('b'),
280
+ }),
281
+ }),
282
+ jsx('h3', { children: this._t('t') }),
283
+ jsx('p', {
284
+ style: {
285
+ marginTop: 0,
286
+ fontSize: 'var(--text-sm, 0.875rem)',
287
+ },
288
+ children: this._t('d'),
289
+ }),
290
+ jsx('div', {
291
+ className: 'palette-container',
292
+ children: groups.map((group) =>
293
+ jsx('div', {
294
+ className: 'palette-item',
295
+ 'data-palette': group.type,
296
+ children: [
297
+ jsx('div', { className: 'item-title', children: group.title }),
298
+ jsx('div', {
299
+ className: 'items',
300
+ children: group.buttons.map(([val, label]) => {
301
+ const isActive =
302
+ String(this.props[group.type]) === String(val);
303
+ return jsx('button', {
304
+ className: `j-button is-default${isActive ? ' is-active' : ''}`,
305
+ 'data-palette': val,
306
+ children: [
307
+ group.type === 'theme'
308
+ ? jsx('span', {
309
+ className: 'el-prefix item-hex',
310
+ style: {
311
+ backgroundColor: `var(--ui-${val})`,
312
+ },
313
+ })
314
+ : null,
315
+ jsx('span', {
316
+ className: 'button-text',
317
+ children: label,
318
+ }),
319
+ ],
320
+ });
321
+ }),
322
+ }),
323
+ ],
324
+ })
325
+ ),
326
+ }),
327
+ ],
328
+ });
329
+ return panel;
330
+ }
331
+
332
+ /**
333
+ * 更新主题配置并写入 Cookie。
334
+ * @param {Partial<ThemeOptions>} newConfig 需要覆盖的主题配置。
335
+ * @returns {void}
336
+ */
337
+ setConfig(newConfig) {
338
+ this.props = Object.assign({}, this.props, newConfig);
339
+ this._saveConfig();
340
+ this._syncActiveButtons();
341
+ }
342
+
343
+ /**
344
+ * 销毁当前主题实例并解绑事件。
345
+ * @returns {void}
346
+ */
347
+ destroy() {
348
+ if (this.runtime.destroyed) return;
349
+ this.runtime.destroyed = true;
350
+ this._unbindEvent();
351
+ this.cleanup = null;
352
+ }
353
+ }
@@ -0,0 +1,329 @@
1
+ import { jsx } from 'vanilla-signal';
2
+
3
+ import { randomId, timer, validateParam } from '../utilities/core.js';
4
+ import { q, requireRenderDOM } from '../utilities/dom.js';
5
+ import { listen } from '../utilities/events.js';
6
+ import { icon } from './icons.js';
7
+
8
+ const TOAST_TYPE_RULE = {
9
+ type: 'string',
10
+ enum: ['info', 'success', 'warning', 'error', 'primary'],
11
+ };
12
+
13
+ const TOAST_DURATION_RULE = {
14
+ type: 'number',
15
+ validate: (value) => value >= 0,
16
+ message: 'expects a positive number or 0.',
17
+ };
18
+
19
+ const LITE_DURATION_RULE = {
20
+ type: 'number',
21
+ validate: (value) => value > 0,
22
+ message: 'expects a number greater than 0.',
23
+ };
24
+
25
+ /**
26
+ * Toast 消息提示工具。
27
+ *
28
+ * 以静态方法方式使用,支持多类型堆叠消息和单实例轻提示。
29
+ */
30
+ export class Toast {
31
+ /**
32
+ * 当前 Toast 使用的 timer key 集合,用于统一清理。
33
+ * @type {Set<string>}
34
+ */
35
+ static timers = new Set();
36
+
37
+ static disposers = new Map();
38
+ /**
39
+ * 展示一条 Toast 消息。
40
+ * @param {string} [message=""] 消息内容。
41
+ * @param {number} [duration=3000] 展示时长,单位毫秒;传 0 时不自动关闭。
42
+ * @param {"info"|"success"|"warning"|"error"|"primary"} [type="info"] 消息类型。
43
+ * @returns {HTMLElement} Toast 节点。
44
+ */
45
+ static show(message = '', duration = 3000, type = 'info') {
46
+ requireRenderDOM('Toast');
47
+
48
+ validateParam('message', message, 'string', 'Toast.show');
49
+ validateParam('duration', duration, TOAST_DURATION_RULE, 'Toast.show');
50
+ validateParam('type', type, TOAST_TYPE_RULE, 'Toast.show');
51
+
52
+ const toastContainer = Toast._getOrCreateContainer();
53
+
54
+ const id = randomId();
55
+ const toast = jsx('div', {
56
+ className: `j-toast is-${type}`,
57
+ 'data-toast': id,
58
+ children: [
59
+ jsx('span', {
60
+ className: 'toast-icon',
61
+ children: icon(type === 'primary' ? 'info' : type),
62
+ }),
63
+ jsx('span', { className: 'toast-message', children: message }),
64
+ ],
65
+ });
66
+
67
+ toastContainer.appendChild(toast);
68
+
69
+ Toast._setTimer(
70
+ id,
71
+ 'show',
72
+ () => {
73
+ toast.classList.add('is-shown');
74
+ },
75
+ 10
76
+ );
77
+
78
+ if (duration > 0) {
79
+ Toast._setTimer(
80
+ id,
81
+ 'hide',
82
+ () => {
83
+ Toast.hide(toast);
84
+ },
85
+ duration
86
+ );
87
+ }
88
+
89
+ const disposeClick = listen(toast, 'click', () => {
90
+ Toast.hide(toast);
91
+ });
92
+ Toast.disposers.set(toast, disposeClick);
93
+
94
+ return toast;
95
+ }
96
+
97
+ /**
98
+ * 展示成功消息。
99
+ * @param {string} [message=""] 消息内容。
100
+ * @param {number} [duration=3000] 展示时长,单位毫秒。
101
+ * @returns {HTMLElement}
102
+ */
103
+ static success(message = '', duration = 3000) {
104
+ return Toast.show(message, duration, 'success');
105
+ }
106
+
107
+ /**
108
+ * 展示信息消息。
109
+ * @param {string} [message=""] 消息内容。
110
+ * @param {number} [duration=3000] 展示时长,单位毫秒。
111
+ * @returns {HTMLElement}
112
+ */
113
+ static info(message = '', duration = 3000) {
114
+ return Toast.show(message, duration, 'info');
115
+ }
116
+
117
+ /**
118
+ * 展示主色消息。
119
+ * @param {string} [message=""] 消息内容。
120
+ * @param {number} [duration=3000] 展示时长,单位毫秒。
121
+ * @returns {HTMLElement}
122
+ */
123
+ static primary(message = '', duration = 3000) {
124
+ return Toast.show(message, duration, 'primary');
125
+ }
126
+
127
+ /**
128
+ * 展示警告消息。
129
+ * @param {string} [message=""] 消息内容。
130
+ * @param {number} [duration=3000] 展示时长,单位毫秒。
131
+ * @returns {HTMLElement}
132
+ */
133
+ static warning(message = '', duration = 3000) {
134
+ return Toast.show(message, duration, 'warning');
135
+ }
136
+
137
+ /**
138
+ * 展示错误消息。
139
+ * @param {string} [message=""] 消息内容。
140
+ * @param {number} [duration=3000] 展示时长,单位毫秒。
141
+ * @returns {HTMLElement}
142
+ */
143
+ static error(message = '', duration = 3000) {
144
+ return Toast.show(message, duration, 'error');
145
+ }
146
+
147
+ /**
148
+ * 隐藏指定 Toast。
149
+ * @param {HTMLElement} toast 需要隐藏的 Toast 节点。
150
+ * @returns {void}
151
+ */
152
+ static hide(toast) {
153
+ if (toast) {
154
+ Toast.disposers.get(toast)?.();
155
+ Toast.disposers.delete(toast);
156
+
157
+ toast.classList.remove('is-shown');
158
+ toast.classList.add('is-hidden');
159
+
160
+ const id = toast.dataset.toast;
161
+ Toast._setTimer(
162
+ id,
163
+ 'remove',
164
+ () => {
165
+ if (toast.parentNode) {
166
+ toast.parentNode.removeChild(toast);
167
+ }
168
+
169
+ const container = q('.j-toast-container');
170
+ if (container && container.children.length === 0) {
171
+ container.parentNode.removeChild(container);
172
+ }
173
+ },
174
+ 300
175
+ );
176
+ }
177
+ }
178
+
179
+ /**
180
+ * 展示轻量单例提示。
181
+ *
182
+ * 页面上同一时间只保留一个 lite toast。
183
+ * @param {string} [message=""] 消息内容。
184
+ * @param {number} [duration=2000] 展示时长,单位毫秒。
185
+ * @returns {HTMLElement} 轻提示节点。
186
+ */
187
+ static lite(message = '', duration = 2000) {
188
+ requireRenderDOM('Toast');
189
+
190
+ validateParam('message', message, 'string', 'Toast.lite');
191
+ validateParam('duration', duration, LITE_DURATION_RULE, 'Toast.lite');
192
+
193
+ const existing = q('.j-toast-lite');
194
+ if (existing) existing.remove();
195
+
196
+ const id = randomId();
197
+ const lite = jsx('div', {
198
+ className: 'j-toast-lite',
199
+ 'data-toast': id,
200
+ children: message,
201
+ });
202
+ document.body.appendChild(lite);
203
+
204
+ Toast._setTimer(id, 'show', () => lite.classList.add('is-shown'), 10);
205
+
206
+ Toast._setTimer(
207
+ id,
208
+ 'hide',
209
+ () => {
210
+ lite.classList.remove('is-shown');
211
+ lite.classList.add('is-hidden');
212
+ Toast._setTimer(id, 'remove', () => lite.remove(), 300);
213
+ },
214
+ duration
215
+ );
216
+
217
+ return lite;
218
+ }
219
+
220
+ // action 模式不支持自动关闭,必须通过按钮点击触发关闭。
221
+ static action(message = '', props = {}) {
222
+ requireRenderDOM('Toast');
223
+
224
+ // DOM 结构如下:
225
+ // <div class="j-toast-container">
226
+ // <div class="j-toast is-action is-shown">
227
+ // <div class="toast-message">这是一条成功提示消息。</div>
228
+ // <div class="toast-action">
229
+ // <button class="j-button is-sm is-ghost">cancel</button>
230
+ // <button class="j-button is-sm is-outline">action</button>
231
+ // </div>
232
+ // </div>
233
+ // </div>
234
+
235
+ // const existing = q('.j-toast.is-action');
236
+ // if (existing) existing.remove();
237
+
238
+ const toastContainer = Toast._getOrCreateContainer();
239
+
240
+ const id = randomId();
241
+ const action = jsx('div', {
242
+ className: 'j-toast is-action',
243
+ 'data-toast': id,
244
+ children: [
245
+ jsx('div', { className: 'toast-message', children: message }),
246
+ jsx('div', {
247
+ className: 'toast-actions',
248
+ children: [
249
+ jsx('button', {
250
+ className: 'j-button is-sm is-ghost',
251
+ children: props.text.cancel || 'cancel',
252
+ 'data-action': 'cancel',
253
+ onClick: () => {
254
+ action.classList.remove('is-shown');
255
+ action.classList.add('is-hidden');
256
+ Toast.hide(action);
257
+ },
258
+ }),
259
+ jsx('button', {
260
+ className: 'j-button is-sm is-outline',
261
+ children: props.text.action || 'action',
262
+ 'data-action': 'toast-action',
263
+ onClick: async () => {
264
+ await props.onAction?.();
265
+ Toast.hide(action);
266
+ },
267
+ }),
268
+ ],
269
+ }),
270
+ ],
271
+ });
272
+ toastContainer.appendChild(action);
273
+
274
+ // 在挂载后异步添加显示态,确保 CSS transition 能从初始态过渡。
275
+ Toast._setTimer(
276
+ id,
277
+ 'show',
278
+ () => {
279
+ action.classList.add('is-shown');
280
+ const actionButton = q('[data-action="toast-action"]', action);
281
+ actionButton?.focus();
282
+ },
283
+ 10
284
+ );
285
+
286
+ return action;
287
+ }
288
+
289
+ static _getOrCreateContainer() {
290
+ let toastContainer = q('.j-toast-container');
291
+ if (!toastContainer) {
292
+ toastContainer = jsx('div', { className: 'j-toast-container' });
293
+ document.body.appendChild(toastContainer);
294
+ }
295
+ return toastContainer;
296
+ }
297
+
298
+ /**
299
+ * 创建可统一清理的定时器。
300
+ * @private
301
+ * @param {Function} callback 定时回调。
302
+ * @param {number} delay 延迟毫秒。
303
+ * @returns {string} timer key。
304
+ */
305
+ static _setTimer(id, action, callback, delay) {
306
+ const key = `${id}-${action}`;
307
+ Toast.timers.add(key);
308
+ timer.start(key, delay, () => {
309
+ Toast.timers.delete(key);
310
+ callback();
311
+ });
312
+ return key;
313
+ }
314
+
315
+ /**
316
+ * 清理所有 Toast 节点和定时器。
317
+ * @returns {void}
318
+ */
319
+ static clearAll() {
320
+ for (const key of Toast.timers) {
321
+ timer.cancel(key);
322
+ }
323
+ Toast.timers.clear();
324
+ for (const dispose of Toast.disposers.values()) dispose();
325
+ Toast.disposers.clear();
326
+ q('.j-toast-container')?.remove();
327
+ q('.j-toast-lite')?.remove();
328
+ }
329
+ }