vanilla-jui 1.3.10 → 1.4.0

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 (90) hide show
  1. package/dist/index.d.ts +2927 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.umd.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +4 -3
  6. package/src/components/accordion.ts +522 -0
  7. package/src/components/drop.ts +497 -0
  8. package/src/components/flow.ts +1579 -0
  9. package/src/components/form.ts +953 -0
  10. package/src/components/{icons.js → icons.ts} +24 -11
  11. package/src/components/menu.ts +615 -0
  12. package/src/components/{modal.js → modal.ts} +560 -237
  13. package/src/components/offcanvas.ts +496 -0
  14. package/src/components/{pagination.js → pagination.ts} +251 -76
  15. package/src/components/parabola.ts +335 -0
  16. package/src/components/{sticky.js → sticky.ts} +123 -98
  17. package/src/components/{swiper.js → swiper.ts} +510 -232
  18. package/src/components/tabs.ts +984 -0
  19. package/src/components/theme.ts +437 -0
  20. package/src/components/toast.ts +371 -0
  21. package/src/components/toc.ts +385 -0
  22. package/src/components/tooltip.ts +179 -0
  23. package/src/components/validator.ts +596 -0
  24. package/src/core/{Component.js → Component.ts} +134 -27
  25. package/src/css/accordion.css +2 -2
  26. package/src/css/animation.css +31 -1
  27. package/src/css/breadcrumb.css +1 -1
  28. package/src/css/button.css +5 -5
  29. package/src/css/card.css +7 -7
  30. package/src/css/color.css +865 -1028
  31. package/src/css/color_p3.css +871 -1036
  32. package/src/css/components.css +1 -0
  33. package/src/css/drop.css +1 -1
  34. package/src/css/flow.css +1 -1
  35. package/src/css/form.css +21 -21
  36. package/src/css/gradient.css +17 -4
  37. package/src/css/icon.css +17 -1
  38. package/src/css/index.css +3 -3
  39. package/src/css/layout.css +1 -1
  40. package/src/css/menu.css +27 -27
  41. package/src/css/modal.css +10 -27
  42. package/src/css/offcanvas.css +2 -2
  43. package/src/css/pagination.css +1 -1
  44. package/src/css/popup.css +2 -2
  45. package/src/css/swiper.css +12 -12
  46. package/src/css/tabs.css +3 -2
  47. package/src/css/tag.css +3 -4
  48. package/src/css/theme-palette.css +6 -6
  49. package/src/css/tip.css +1 -1
  50. package/src/css/toast.css +8 -8
  51. package/src/css/tokens.css +197 -226
  52. package/src/css/toolbar.css +3 -3
  53. package/src/css/tooltip.css +1 -1
  54. package/src/index.ts +37 -0
  55. package/src/locales/{index.js → index.ts} +1 -2
  56. package/src/types.d.ts +1 -0
  57. package/src/utilities/browser.ts +131 -0
  58. package/src/utilities/cache.ts +57 -0
  59. package/src/utilities/core.ts +614 -0
  60. package/src/utilities/dom.ts +468 -0
  61. package/src/utilities/events.ts +106 -0
  62. package/src/utilities/http.ts +35 -0
  63. package/src/utilities/storage.ts +46 -0
  64. package/src/components/accordion.js +0 -373
  65. package/src/components/drop.js +0 -437
  66. package/src/components/flow.js +0 -1133
  67. package/src/components/form.js +0 -585
  68. package/src/components/menu.js +0 -422
  69. package/src/components/offcanvas.js +0 -332
  70. package/src/components/parabola.js +0 -295
  71. package/src/components/tabs.js +0 -746
  72. package/src/components/theme.js +0 -353
  73. package/src/components/toast.js +0 -329
  74. package/src/components/toc.js +0 -226
  75. package/src/components/tooltip.js +0 -86
  76. package/src/components/validator.js +0 -586
  77. package/src/css/toast.min.css +0 -1
  78. package/src/index.js +0 -45
  79. package/src/locales/index.min.js +0 -1
  80. package/src/utilities/browser.js +0 -111
  81. package/src/utilities/cache.js +0 -159
  82. package/src/utilities/core.js +0 -502
  83. package/src/utilities/debug.js +0 -103
  84. package/src/utilities/dom.js +0 -313
  85. package/src/utilities/events.js +0 -69
  86. package/src/utilities/http.js +0 -30
  87. package/src/utilities/legacy-dom.js +0 -179
  88. package/src/utilities/storage.js +0 -46
  89. /package/src/{components/components.js → css/themes/index.css} +0 -0
  90. /package/src/icons/{index.js → index.ts} +0 -0
@@ -0,0 +1,984 @@
1
+ import {
2
+ bindAttr,
3
+ bindClass,
4
+ createDeepStore,
5
+ createRoot,
6
+ flushSync,
7
+ insert,
8
+ jsx,
9
+ } from 'vanilla-signal';
10
+
11
+ import Component, {
12
+ type ComponentDOM,
13
+ type ComponentRuntime,
14
+ } from '../core/Component.ts';
15
+ import {
16
+ type ResolveSchema,
17
+ randomId,
18
+ resolveProps,
19
+ validateParam,
20
+ } from '../utilities/core.ts';
21
+ import {
22
+ type DOMReference,
23
+ type RenderableContent,
24
+ createLoading,
25
+ isRenderableContent,
26
+ normalizeContentNodes,
27
+ q,
28
+ requireContainer,
29
+ } from '../utilities/dom.ts';
30
+
31
+ export type TabsDirection = 'top' | 'bottom' | 'left' | 'right';
32
+ export type TabsValue = number | string;
33
+ export type TabsDisabled = TabsValue | TabsValue[];
34
+
35
+ export interface TabsClassNames {
36
+ root: string;
37
+ top: string;
38
+ bottom: string;
39
+ left: string;
40
+ right: string;
41
+ wrap: string;
42
+ list: string;
43
+ tab: string;
44
+ panelWrap: string;
45
+ panel: string;
46
+ active: string;
47
+ disabled: string;
48
+ dragging: string;
49
+ }
50
+
51
+ export type TabsClassNameConfig = Partial<TabsClassNames>;
52
+
53
+ export interface TabsPanelContext {
54
+ tabs: Tabs;
55
+ item: TabItem;
56
+ index: number;
57
+ name: string | number;
58
+ }
59
+
60
+ export interface TabTitleContext {
61
+ tabs: Tabs;
62
+ item: TabItem;
63
+ }
64
+
65
+ export type TabPanel =
66
+ | RenderableContent<TabsPanelContext>
67
+ | ((context: TabsPanelContext) => RenderableContent<TabsPanelContext>)
68
+ | ((
69
+ context: TabsPanelContext
70
+ ) => Promise<RenderableContent<TabsPanelContext>>);
71
+
72
+ export interface TabItem extends Record<string, unknown> {
73
+ name?: string;
74
+ title: RenderableContent<TabTitleContext>;
75
+ panel: TabPanel;
76
+ cache?: boolean;
77
+ ttl?: number;
78
+ }
79
+
80
+ export interface TabsProps extends Record<string, unknown> {
81
+ id?: string | null;
82
+ direction?: TabsDirection;
83
+ active?: TabsValue;
84
+ disabled?: TabsDisabled;
85
+ onChange?:
86
+ | ((
87
+ index: number,
88
+ name: string | number,
89
+ tab: HTMLElement | undefined,
90
+ panel: HTMLElement | undefined
91
+ ) => void | Promise<void>)
92
+ | null;
93
+ tabs?: TabItem[];
94
+ className?: TabsClassNameConfig;
95
+ onAdd?:
96
+ | ((
97
+ index: number,
98
+ item: TabItem,
99
+ tab: HTMLElement | undefined,
100
+ panel: HTMLElement | undefined
101
+ ) => void | Promise<void>)
102
+ | null;
103
+ onRemove?:
104
+ | ((index: number, name: string | undefined) => void | Promise<void>)
105
+ | null;
106
+ }
107
+
108
+ interface ResolvedTabsProps extends Record<string, unknown> {
109
+ id: string;
110
+ direction: TabsDirection;
111
+ active: TabsValue;
112
+ disabled: TabsDisabled;
113
+ onChange: NonNullable<TabsProps['onChange']> | null;
114
+ tabs: TabItem[];
115
+ className: TabsClassNames;
116
+ onAdd: NonNullable<TabsProps['onAdd']> | null;
117
+ onRemove: NonNullable<TabsProps['onRemove']> | null;
118
+ }
119
+
120
+ interface TabsState extends Record<string, unknown> {
121
+ current: {
122
+ index: number;
123
+ name: string | null;
124
+ };
125
+ disabled: {
126
+ names: string[];
127
+ indexes: number[];
128
+ };
129
+ isVertical: boolean;
130
+ draggable: boolean;
131
+ loading: boolean;
132
+ }
133
+
134
+ interface TabsDOM extends ComponentDOM {
135
+ root: HTMLElement | null;
136
+ container: Element;
137
+ tabs: HTMLElement[];
138
+ panels: HTMLElement[];
139
+ }
140
+
141
+ interface TabsPanelCacheEntry {
142
+ content: RenderableContent<TabsPanelContext>;
143
+ updatedAt: number;
144
+ }
145
+
146
+ interface TabsRuntime extends ComponentRuntime {
147
+ cache: {
148
+ panels: Map<string, TabsPanelCacheEntry>;
149
+ };
150
+ panelLoadId: number;
151
+ }
152
+
153
+ type PointerDragEvent = MouseEvent | TouchEvent;
154
+
155
+ const DEFAULT_CLASS_NAMES: TabsClassNames = {
156
+ root: 'j-tabs',
157
+ top: 'is-top',
158
+ bottom: 'is-bottom',
159
+ left: 'is-left',
160
+ right: 'is-right',
161
+ wrap: 'tab-wrap',
162
+ list: 'tab-list',
163
+ tab: 'tab-item',
164
+ panelWrap: 'tab-panel',
165
+ panel: 'panel-item',
166
+ active: 'is-active',
167
+ disabled: 'is-disabled',
168
+ dragging: 'dragging',
169
+ };
170
+
171
+ const TABS_PROPS_SCHEMA = {
172
+ id: {
173
+ default: null,
174
+ types: ['string', 'null'],
175
+ normalize: (value: unknown) => {
176
+ if (typeof value === 'string') {
177
+ const id = value.trim();
178
+ return id || randomId();
179
+ }
180
+ if (value == null) return randomId();
181
+ return value;
182
+ },
183
+ },
184
+ direction: {
185
+ default: 'top',
186
+ type: 'string',
187
+ enum: ['top', 'bottom', 'left', 'right'],
188
+ },
189
+ active: { default: 0, types: ['number', 'string'] },
190
+ disabled: { default: [], types: ['number', 'string', 'array'] },
191
+ onChange: { default: null, types: ['function', 'null'] },
192
+ tabs: { default: [], type: 'array' },
193
+ className: {
194
+ default: DEFAULT_CLASS_NAMES,
195
+ type: 'object',
196
+ normalize: (value: unknown) => ({
197
+ ...DEFAULT_CLASS_NAMES,
198
+ ...(value && typeof value === 'object' ? value : {}),
199
+ }),
200
+ },
201
+ onAdd: { default: null, types: ['function', 'null'] },
202
+ onRemove: { default: null, types: ['function', 'null'] },
203
+ } satisfies ResolveSchema<TabsProps>;
204
+
205
+ const TAB_CONFIG_RULE = {
206
+ type: 'object',
207
+ validate: (value: unknown) => {
208
+ const item = value as Partial<TabItem>;
209
+ return (
210
+ !!item &&
211
+ typeof item === 'object' &&
212
+ isRenderableContent(item.title) &&
213
+ isRenderableContent(item.panel)
214
+ );
215
+ },
216
+ message:
217
+ 'expects an object with renderable title and panel: string, Node, array, function or null.',
218
+ };
219
+
220
+ function cloneTabItems(tabs: unknown): TabItem[] {
221
+ return Array.isArray(tabs)
222
+ ? tabs.map((item) => ({ ...(item as TabItem) }))
223
+ : [];
224
+ }
225
+
226
+ function normalizeTtl(ttl: unknown): number {
227
+ return typeof ttl === 'number' && ttl > 0 ? ttl : 0;
228
+ }
229
+
230
+ function normalizeProps(input: TabsProps): ResolvedTabsProps {
231
+ const props = resolveProps(input, TABS_PROPS_SCHEMA, 'Tabs');
232
+ return {
233
+ id: props.id as string,
234
+ direction: props.direction as TabsDirection,
235
+ active: props.active as TabsValue,
236
+ disabled: props.disabled as TabsDisabled,
237
+ onChange: props.onChange as ResolvedTabsProps['onChange'],
238
+ tabs: cloneTabItems(props.tabs),
239
+ className: props.className as TabsClassNames,
240
+ onAdd: props.onAdd as ResolvedTabsProps['onAdd'],
241
+ onRemove: props.onRemove as ResolvedTabsProps['onRemove'],
242
+ };
243
+ }
244
+
245
+ function joinClasses(
246
+ ...classes: Array<string | false | null | undefined>
247
+ ): string {
248
+ return classes.filter(Boolean).join(' ');
249
+ }
250
+
251
+ /**
252
+ * 标签页组件,继承 Component。
253
+ *
254
+ * DOM 创建一次,通过 createEffect 细粒度更新 class/ARIA。
255
+ */
256
+ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
257
+ declare runtime: TabsRuntime;
258
+ declare state: TabsState;
259
+ private bindingsDispose: (() => void) | null;
260
+ private isDragging: boolean;
261
+ private raf: number;
262
+ private resizeRaf: number;
263
+ private velocity: number;
264
+
265
+ /**
266
+ * @param {Element|Node|string|Array} container 挂载容器(元素、选择器或 JSX/h 返回节点)。
267
+ * @param {object} [input={}] 标签页配置。
268
+ */
269
+ constructor(container: DOMReference, input: TabsProps = {}) {
270
+ const el = requireContainer(container, 'Tabs');
271
+ const props = normalizeProps(input);
272
+ super(props);
273
+
274
+ this.dom.container = el;
275
+ this.dom.tabs = [];
276
+ this.dom.panels = [];
277
+ this.bindingsDispose = null;
278
+ this.isDragging = false;
279
+ this.raf = 0;
280
+ this.resizeRaf = 0;
281
+ this.velocity = 0;
282
+
283
+ this.state = createDeepStore({
284
+ current: {
285
+ index: -1,
286
+ name: null,
287
+ },
288
+ disabled: this.parseDisabled(props.disabled),
289
+ isVertical: props.direction === 'left' || props.direction === 'right',
290
+ draggable: false,
291
+ loading: false,
292
+ }) as TabsState;
293
+
294
+ this.runtime.cache = { panels: new Map() };
295
+ this.runtime.panelLoadId = 0;
296
+
297
+ try {
298
+ this.onInit(props);
299
+ } catch (error) {
300
+ this.destroy();
301
+ throw error;
302
+ }
303
+ }
304
+
305
+ protected onInit(props: ResolvedTabsProps): void {
306
+ this.root = this.buildRoot(props);
307
+ this.rebuildItems();
308
+ void this.activateInternal(props.active, false);
309
+ this.bindEvents();
310
+ this.initDrag();
311
+ }
312
+
313
+ private buildRoot(props: ResolvedTabsProps): HTMLElement {
314
+ const { id, direction, className } = props;
315
+ const nav = jsx('nav', {
316
+ className: className.list,
317
+ 'data-tabs-list': '',
318
+ }) as HTMLElement;
319
+ const wrap = jsx('div', {
320
+ className: className.wrap,
321
+ 'data-tabs-wrap': '',
322
+ children: nav,
323
+ }) as HTMLElement;
324
+ const panelWrapper = jsx('div', {
325
+ className: className.panelWrap,
326
+ 'data-tabs-panel-wrap': '',
327
+ }) as HTMLElement;
328
+
329
+ return jsx('div', {
330
+ className: joinClasses(className.root, className[direction]),
331
+ id,
332
+ 'data-tabs': 'root',
333
+ 'data-tabs-direction': direction,
334
+ children: [wrap, panelWrapper],
335
+ }) as HTMLElement;
336
+ }
337
+
338
+ private rebuildItems(): void {
339
+ if (!this.root) return;
340
+ const tabList = q<HTMLElement>('[data-tabs-list]', this.root);
341
+ const panelWrapper = q<HTMLElement>('[data-tabs-panel-wrap]', this.root);
342
+ if (!tabList || !panelWrapper) return;
343
+
344
+ tabList.textContent = '';
345
+ panelWrapper.textContent = '';
346
+
347
+ this.dom.tabs = [];
348
+ this.dom.panels = [];
349
+
350
+ const tabFragment = document.createDocumentFragment();
351
+ const panelFragment = document.createDocumentFragment();
352
+
353
+ this.props.tabs.forEach((item, index) => {
354
+ const name = item.name || randomId();
355
+
356
+ const tab = jsx('div', {
357
+ className: this.props.className.tab,
358
+ 'data-tabs-tab': name,
359
+ role: 'tab',
360
+ children: normalizeContentNodes(item.title, { tabs: this, item }),
361
+ }) as HTMLElement;
362
+
363
+ this.dom.tabs.push(tab);
364
+ const panel = jsx('div', {
365
+ className: this.props.className.panel,
366
+ 'data-tabs-panel': name,
367
+ role: 'tabpanel',
368
+ }) as HTMLElement;
369
+ this.dom.panels.push(panel);
370
+
371
+ if (typeof item.panel !== 'function') {
372
+ panel.append(
373
+ ...normalizeContentNodes(item.panel, {
374
+ tabs: this,
375
+ item,
376
+ index,
377
+ name,
378
+ })
379
+ );
380
+ }
381
+ tabFragment.append(tab);
382
+ panelFragment.append(panel);
383
+ });
384
+
385
+ tabList.append(tabFragment);
386
+ panelWrapper.append(panelFragment);
387
+
388
+ this.bindingsDispose?.();
389
+ this.bindingsDispose = createRoot((dispose) => {
390
+ this.dom.tabs.forEach((tab, index) => {
391
+ const name = tab.dataset.tabsTab || '';
392
+ bindClass(
393
+ tab,
394
+ this.props.className.active,
395
+ () => this.state.current.index === index
396
+ );
397
+ bindClass(tab, this.props.className.disabled, () =>
398
+ this.state.disabled.names.includes(name)
399
+ );
400
+ bindAttr(
401
+ tab,
402
+ 'aria-selected',
403
+ () => this.state.current.index === index
404
+ );
405
+ bindAttr(tab, 'aria-disabled', () =>
406
+ this.state.disabled.names.includes(name)
407
+ );
408
+ });
409
+ this.dom.panels.forEach((panel, index) => {
410
+ bindClass(
411
+ panel,
412
+ this.props.className.active,
413
+ () => this.state.current.index === index
414
+ );
415
+ bindAttr(
416
+ panel,
417
+ 'aria-hidden',
418
+ () => this.state.current.index !== index
419
+ );
420
+ });
421
+ return dispose;
422
+ });
423
+ }
424
+
425
+ private parseDisabled(disabled: TabsDisabled): TabsState['disabled'] {
426
+ if (disabled == null) return { names: [], indexes: [] };
427
+ const toName = (val: TabsValue): string | null => {
428
+ if (typeof val === 'number') return this.props.tabs[val]?.name || null;
429
+ if (typeof val === 'string') return val;
430
+ return null;
431
+ };
432
+ const names = Array.isArray(disabled)
433
+ ? disabled.map(toName).filter((name): name is string => !!name)
434
+ : (() => {
435
+ const name = toName(disabled);
436
+ return name ? [name] : [];
437
+ })();
438
+
439
+ return this.createDisabledState(names);
440
+ }
441
+
442
+ private createDisabledState(names: string[]): TabsState['disabled'] {
443
+ const uniqNames = Array.from(new Set(names));
444
+ return {
445
+ names: uniqNames,
446
+ indexes: uniqNames
447
+ .map((name) => this.props.tabs.findIndex((tab) => tab.name === name))
448
+ .filter((index) => index >= 0),
449
+ };
450
+ }
451
+
452
+ private syncCurrent(index: number): void {
453
+ this.state.current = {
454
+ index,
455
+ name:
456
+ index >= 0 && index < this.dom.tabs.length
457
+ ? this.dom.tabs[index]?.dataset.tabsTab || null
458
+ : null,
459
+ };
460
+ }
461
+
462
+ private getPanelKey(item: TabItem, index: number): string {
463
+ return item.name || this.dom.tabs[index]?.dataset.tabsTab || String(index);
464
+ }
465
+
466
+ private getCachedPanel(
467
+ item: TabItem,
468
+ index: number
469
+ ): TabsPanelCacheEntry | null {
470
+ if (!item.cache) return null;
471
+
472
+ const key = this.getPanelKey(item, index);
473
+ const entry = this.runtime.cache.panels.get(key);
474
+ if (!entry) return null;
475
+
476
+ const ttl = normalizeTtl(item.ttl);
477
+ if (ttl && Date.now() - entry.updatedAt > ttl) {
478
+ this.runtime.cache.panels.delete(key);
479
+ return null;
480
+ }
481
+
482
+ return entry;
483
+ }
484
+
485
+ private setCachedPanel(
486
+ item: TabItem,
487
+ index: number,
488
+ content: RenderableContent<TabsPanelContext>
489
+ ): void {
490
+ if (!item.cache) return;
491
+
492
+ this.runtime.cache.panels.set(this.getPanelKey(item, index), {
493
+ content,
494
+ updatedAt: Date.now(),
495
+ });
496
+ }
497
+
498
+ private renderPanelContent(
499
+ index: number,
500
+ content: RenderableContent<TabsPanelContext>
501
+ ): void {
502
+ const panel = this.dom.panels[index];
503
+ const item = this.props.tabs[index];
504
+ if (!panel || !item) return;
505
+
506
+ panel.textContent = '';
507
+ panel.append(
508
+ ...normalizeContentNodes(content, {
509
+ tabs: this,
510
+ item,
511
+ index,
512
+ name: this.dom.tabs[index]?.dataset.tabsTab || item.name || index,
513
+ })
514
+ );
515
+ }
516
+
517
+ private async loadPanel(index: number): Promise<void> {
518
+ const item = this.props.tabs[index];
519
+ const panel = this.dom.panels[index];
520
+ if (!item || !panel || typeof item.panel !== 'function') {
521
+ this.runtime.panelLoadId += 1;
522
+ flushSync(() => {
523
+ this.state.loading = false;
524
+ });
525
+ return;
526
+ }
527
+
528
+ const cached = this.getCachedPanel(item, index);
529
+ if (cached) {
530
+ this.runtime.panelLoadId += 1;
531
+ flushSync(() => {
532
+ this.state.loading = false;
533
+ });
534
+ this.renderPanelContent(index, cached.content);
535
+ return;
536
+ }
537
+
538
+ const loadId = ++this.runtime.panelLoadId;
539
+ flushSync(() => {
540
+ this.state.loading = true;
541
+ });
542
+ panel.setAttribute('aria-live', 'polite');
543
+ panel.setAttribute('aria-busy', 'true');
544
+ panel.textContent = '';
545
+ panel.appendChild(createLoading());
546
+
547
+ try {
548
+ const content = await Promise.resolve(
549
+ item.panel({
550
+ tabs: this,
551
+ item,
552
+ index,
553
+ name: this.dom.tabs[index]?.dataset.tabsTab || item.name || index,
554
+ })
555
+ );
556
+
557
+ if (this.runtime.destroyed || loadId !== this.runtime.panelLoadId) return;
558
+
559
+ this.setCachedPanel(item, index, content);
560
+ this.renderPanelContent(index, content);
561
+ } finally {
562
+ if (!this.runtime.destroyed && loadId === this.runtime.panelLoadId) {
563
+ flushSync(() => {
564
+ this.state.loading = false;
565
+ });
566
+ panel.setAttribute('aria-busy', 'false');
567
+ }
568
+ }
569
+ }
570
+
571
+ get activeIndex(): number {
572
+ return this.state.current.index;
573
+ }
574
+
575
+ get disabledNames(): string[] {
576
+ return this.state.disabled.names;
577
+ }
578
+
579
+ private bindEvents(): void {
580
+ this.unbindEvents();
581
+
582
+ if (!this.root) return;
583
+ const tabList = q<HTMLElement>('[data-tabs-list]', this.root);
584
+ if (!tabList) return;
585
+
586
+ this.cleanup.events.on('tabClick', tabList, 'click', (event) => {
587
+ if (!(event.target instanceof Element)) return;
588
+ const tab = event.target.closest<HTMLElement>('[data-tabs-tab]');
589
+ if (!tab || !tabList.contains(tab)) return;
590
+ const name = tab.dataset.tabsTab;
591
+ if (name && !this.state.disabled.names.includes(name)) {
592
+ void this.activate(name);
593
+ }
594
+ });
595
+ }
596
+
597
+ private unbindEvents(): void {
598
+ this.cleanup.events.clear();
599
+ }
600
+
601
+ private assertActive(method: string): void {
602
+ if (this.runtime.destroyed) {
603
+ throw new Error(`Tabs.${method}: instance has been destroyed.`);
604
+ }
605
+ }
606
+
607
+ private getIndex(val: TabsValue | undefined | null): number {
608
+ if (typeof val === 'number') return val;
609
+ if (typeof val === 'string') {
610
+ return this.dom.tabs.findIndex((tab) => tab.dataset.tabsTab === val);
611
+ }
612
+ return -1;
613
+ }
614
+
615
+ private async activateInternal(
616
+ val: TabsValue,
617
+ fireEvent = true
618
+ ): Promise<void> {
619
+ const index = this.getIndex(val);
620
+ const name = this.dom.tabs[index]?.dataset.tabsTab || '';
621
+ if (
622
+ index < 0 ||
623
+ index >= this.dom.tabs.length ||
624
+ this.state.disabled.names.includes(name) ||
625
+ this.state.current.index === index
626
+ ) {
627
+ return;
628
+ }
629
+
630
+ flushSync(() => {
631
+ this.syncCurrent(index);
632
+ });
633
+
634
+ await this.loadPanel(index);
635
+
636
+ if (fireEvent && this.props.onChange) {
637
+ const tabEl = this.dom.tabs[index];
638
+ const panelEl = this.dom.panels[index];
639
+ await Promise.resolve(
640
+ this.props.onChange(
641
+ index,
642
+ tabEl?.dataset.tabsTab || index,
643
+ tabEl,
644
+ panelEl
645
+ )
646
+ );
647
+ }
648
+ }
649
+
650
+ /**
651
+ * 激活指定标签。
652
+ * @param {number|string} val 标签索引或名称。
653
+ */
654
+ async activate(val: TabsValue): Promise<void> {
655
+ this.assertActive('activate');
656
+ await this.activateInternal(val, true);
657
+ }
658
+
659
+ /**
660
+ * 将组件挂载到构造器指定的容器中。
661
+ */
662
+ build(): this {
663
+ this.assertActive('build');
664
+ insert(this.dom.container, () => this.root);
665
+ return this;
666
+ }
667
+
668
+ render(): this {
669
+ return this.build();
670
+ }
671
+
672
+ /**
673
+ * 动态新增标签。
674
+ * @param {object} tabConfig 标签配置。
675
+ */
676
+ async add(tabConfig: TabItem): Promise<void> {
677
+ this.assertActive('add');
678
+ validateParam('tabConfig', tabConfig, TAB_CONFIG_RULE, 'Tabs.add');
679
+
680
+ tabConfig.name = tabConfig.name || randomId();
681
+ this.props.tabs = [...cloneTabItems(this.props.tabs), tabConfig];
682
+ this.runtime.cache.panels.clear();
683
+
684
+ this.rebuildItems();
685
+ this.syncActiveNames(this.resolveActiveIndex(this.props.active));
686
+ this.bindEvents();
687
+ this.refreshDrag();
688
+
689
+ const { onAdd } = this.props;
690
+ if (onAdd) {
691
+ const index = this.props.tabs.length - 1;
692
+ await Promise.resolve(
693
+ onAdd(index, tabConfig, this.dom.tabs[index], this.dom.panels[index])
694
+ );
695
+ }
696
+ }
697
+
698
+ /**
699
+ * 根据索引或名称删除标签。
700
+ * @param {number|string} val 标签索引或名称。
701
+ */
702
+ async delete(val: TabsValue): Promise<void> {
703
+ this.assertActive('delete');
704
+ if (this.props.tabs.length <= 1) return;
705
+
706
+ const index = this.getIndex(val);
707
+ if (index < 0 || index >= this.props.tabs.length) return;
708
+
709
+ const removedName = this.props.tabs[index].name;
710
+ const { onRemove } = this.props;
711
+
712
+ this.props.tabs = this.props.tabs.filter((_, i) => i !== index);
713
+ if (removedName) this.runtime.cache.panels.delete(removedName);
714
+
715
+ if (this.state.current.index >= this.props.tabs.length) {
716
+ flushSync(() => {
717
+ this.syncCurrent(this.props.tabs.length - 1);
718
+ });
719
+ } else if (this.state.current.index > index) {
720
+ flushSync(() => {
721
+ this.syncCurrent(this.state.current.index - 1);
722
+ });
723
+ }
724
+
725
+ this.rebuildItems();
726
+ this.bindEvents();
727
+ await this.loadPanel(this.state.current.index);
728
+ this.refreshDrag();
729
+
730
+ if (onRemove) await Promise.resolve(onRemove(index, removedName));
731
+ }
732
+
733
+ /**
734
+ * 根据索引或名称禁用标签。
735
+ * @param {number|string} val 标签索引或名称。
736
+ */
737
+ disable(val: TabsValue): void {
738
+ this.assertActive('disable');
739
+ const name =
740
+ typeof val === 'number' ? this.dom.tabs[val]?.dataset.tabsTab : val;
741
+ if (name && !this.state.disabled.names.includes(name)) {
742
+ flushSync(() => {
743
+ this.state.disabled = this.createDisabledState([
744
+ ...this.state.disabled.names,
745
+ name,
746
+ ]);
747
+ });
748
+ }
749
+ }
750
+
751
+ /**
752
+ * 根据索引或名称启用标签。
753
+ * @param {number|string} val 标签索引或名称。
754
+ */
755
+ enable(val: TabsValue): void {
756
+ this.assertActive('enable');
757
+ const name =
758
+ typeof val === 'number' ? this.dom.tabs[val]?.dataset.tabsTab : val;
759
+ if (name) {
760
+ flushSync(() => {
761
+ this.state.disabled = this.createDisabledState(
762
+ this.state.disabled.names.filter((n) => n !== name)
763
+ );
764
+ });
765
+ }
766
+ }
767
+
768
+ private resolveActiveIndex(active: TabsValue | null | undefined): number {
769
+ if (active == null) return -1;
770
+ if (typeof active === 'number') return active;
771
+ if (typeof active === 'string') {
772
+ return this.dom.tabs.findIndex((tab) => tab.dataset.tabsTab === active);
773
+ }
774
+ return 0;
775
+ }
776
+
777
+ private syncActiveNames(index: number): void {
778
+ flushSync(() => {
779
+ this.syncCurrent(index);
780
+ });
781
+ }
782
+
783
+ /**
784
+ * 使用新配置重新初始化状态。
785
+ * @param {object} [patch={}] 需要覆盖的配置。
786
+ */
787
+ async reInit(patch: TabsProps = {}): Promise<void> {
788
+ this.assertActive('reInit');
789
+ Object.assign(
790
+ this.props,
791
+ normalizeProps({
792
+ ...this.props,
793
+ ...patch,
794
+ className: {
795
+ ...this.props.className,
796
+ ...patch.className,
797
+ },
798
+ })
799
+ );
800
+
801
+ flushSync(() => {
802
+ this.state.disabled = this.parseDisabled(this.props.disabled);
803
+ });
804
+
805
+ this.rebuildItems();
806
+ this.syncActiveNames(this.resolveActiveIndex(this.props.active));
807
+ this.bindEvents();
808
+ await this.loadPanel(this.state.current.index);
809
+ this.refreshDrag();
810
+ }
811
+
812
+ private get dragContainer(): HTMLElement | null {
813
+ return this.root ? q<HTMLElement>('[data-tabs-wrap]', this.root) : null;
814
+ }
815
+
816
+ private get dragInner(): HTMLElement | null {
817
+ return this.root ? q<HTMLElement>('[data-tabs-list]', this.root) : null;
818
+ }
819
+
820
+ private initDrag(): void {
821
+ const { direction } = this.props;
822
+ const container = this.dragContainer;
823
+ const inner = this.dragInner;
824
+ if (!container || !inner) return;
825
+
826
+ const isVertical = direction === 'left' || direction === 'right';
827
+ const draggable = isVertical
828
+ ? inner.scrollHeight > container.clientHeight + 5
829
+ : inner.scrollWidth > container.clientWidth + 5;
830
+
831
+ flushSync(() => {
832
+ this.state.isVertical = isVertical;
833
+ this.state.draggable = draggable;
834
+ });
835
+
836
+ if (!draggable) {
837
+ this.removeDragEvents();
838
+ return;
839
+ }
840
+
841
+ this.bindDragEvents();
842
+
843
+ this.cleanup.events.on('resize', window, 'resize', () => {
844
+ cancelAnimationFrame(this.resizeRaf);
845
+ this.resizeRaf = requestAnimationFrame(() => {
846
+ this.refreshDrag();
847
+ });
848
+ });
849
+ }
850
+
851
+ private bindDragEvents(): void {
852
+ this.removeDragEvents();
853
+
854
+ const container = this.dragContainer;
855
+ const inner = this.dragInner;
856
+ if (!container || !inner) return;
857
+ const isVertical = this.state.isVertical;
858
+
859
+ let posStart = 0;
860
+ let scrollStart = 0;
861
+ let lastPos = 0;
862
+ let frameRequested = false;
863
+
864
+ const getPos = (event: PointerDragEvent): number => {
865
+ if ('touches' in event) {
866
+ const touch = event.touches[0] || event.changedTouches[0];
867
+ return isVertical ? touch.pageY : touch.pageX;
868
+ }
869
+ return isVertical ? event.pageY : event.pageX;
870
+ };
871
+
872
+ const onDragStart = (event: Event): void => {
873
+ if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) {
874
+ return;
875
+ }
876
+ this.isDragging = true;
877
+ inner.classList.add(this.props.className.dragging);
878
+ posStart = getPos(event);
879
+ lastPos = posStart;
880
+ scrollStart = isVertical ? container.scrollTop : container.scrollLeft;
881
+ this.velocity = 0;
882
+ cancelAnimationFrame(this.raf);
883
+ };
884
+
885
+ const onDragMove = (event: Event): void => {
886
+ if (!this.isDragging) return;
887
+ if (!(event instanceof MouseEvent) && !(event instanceof TouchEvent)) {
888
+ return;
889
+ }
890
+ event.preventDefault();
891
+ const current = getPos(event);
892
+ const dist = posStart - current;
893
+ this.velocity = lastPos - current;
894
+ lastPos = current;
895
+
896
+ if (!frameRequested) {
897
+ frameRequested = true;
898
+ requestAnimationFrame(() => {
899
+ frameRequested = false;
900
+ if (isVertical) container.scrollTop = scrollStart + dist;
901
+ else container.scrollLeft = scrollStart + dist;
902
+ });
903
+ }
904
+ };
905
+
906
+ const onDragEnd = (): void => {
907
+ if (!this.isDragging) return;
908
+ this.isDragging = false;
909
+ inner.classList.remove(this.props.className.dragging);
910
+ this.startInertiaScroll();
911
+ };
912
+
913
+ this.cleanup.events.on('drag:mousedown', inner, 'mousedown', onDragStart);
914
+ this.cleanup.events.on(
915
+ 'drag:touchstart',
916
+ inner,
917
+ 'touchstart',
918
+ onDragStart,
919
+ {
920
+ passive: true,
921
+ }
922
+ );
923
+ this.cleanup.events.on('drag:mousemove', window, 'mousemove', onDragMove, {
924
+ passive: false,
925
+ });
926
+ this.cleanup.events.on('drag:touchmove', window, 'touchmove', onDragMove, {
927
+ passive: false,
928
+ });
929
+ this.cleanup.events.on('drag:mouseup', window, 'mouseup', onDragEnd);
930
+ this.cleanup.events.on('drag:touchend', window, 'touchend', onDragEnd);
931
+ }
932
+
933
+ private startInertiaScroll(): void {
934
+ const container = this.dragContainer;
935
+ if (!container) return;
936
+ let v = this.velocity;
937
+ const isVertical = this.state.isVertical;
938
+ let last = performance.now();
939
+
940
+ const step = (now: number): void => {
941
+ const dt = now - last;
942
+ last = now;
943
+ v *= 0.92;
944
+ if (Math.abs(v) < 0.3) return;
945
+ if (isVertical) container.scrollTop += v * dt * 0.05;
946
+ else container.scrollLeft += v * dt * 0.05;
947
+ this.raf = requestAnimationFrame(step);
948
+ };
949
+
950
+ this.raf = requestAnimationFrame(step);
951
+ }
952
+
953
+ private removeDragEvents(): void {
954
+ this.cleanup.events.off('drag:mousedown');
955
+ this.cleanup.events.off('drag:touchstart');
956
+ this.cleanup.events.off('drag:mousemove');
957
+ this.cleanup.events.off('drag:touchmove');
958
+ this.cleanup.events.off('drag:mouseup');
959
+ this.cleanup.events.off('drag:touchend');
960
+ }
961
+
962
+ private refreshDrag(): void {
963
+ this.initDrag();
964
+ }
965
+
966
+ protected onDestroy(): void {
967
+ this.unbindEvents();
968
+ this.removeDragEvents();
969
+ this.bindingsDispose?.();
970
+ this.bindingsDispose = null;
971
+ cancelAnimationFrame(this.raf);
972
+ cancelAnimationFrame(this.resizeRaf);
973
+ this.cleanup.events.off('resize');
974
+
975
+ if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
976
+ }
977
+ }
978
+
979
+ export function createTabs(
980
+ container: DOMReference,
981
+ input: TabsProps = {}
982
+ ): Tabs {
983
+ return new Tabs(container, input);
984
+ }