vanilla-jui 1.4.5 → 1.4.7

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 (103) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +1273 -1896
  3. package/dist/index.js +1 -1
  4. package/dist/index.umd.js +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +21 -2
  7. package/src/components/accordion.ts +297 -216
  8. package/src/components/flow.ts +222 -258
  9. package/src/components/form.ts +44 -102
  10. package/src/components/menu.ts +268 -356
  11. package/src/components/modal.ts +181 -641
  12. package/src/components/offcanvas.ts +225 -106
  13. package/src/components/pagination.ts +296 -326
  14. package/src/components/sticky.ts +55 -54
  15. package/src/components/swiper.ts +237 -247
  16. package/src/components/tabs.ts +278 -271
  17. package/src/components/toc.ts +42 -46
  18. package/src/core/Component.ts +69 -47
  19. package/src/css/accordion.css +2 -2
  20. package/src/css/animation.css +1 -5
  21. package/src/css/avatar.css +1 -1
  22. package/src/css/badge.css +1 -1
  23. package/src/css/breadcrumb.css +9 -10
  24. package/src/css/button.css +1 -1
  25. package/src/css/card.css +2 -2
  26. package/src/css/color.css +974 -1002
  27. package/src/css/color_p3.css +974 -1004
  28. package/src/css/components.css +1 -1
  29. package/src/css/content.css +72 -63
  30. package/src/css/drop.css +1 -1
  31. package/src/css/editor.css +14 -14
  32. package/src/css/flow.css +4 -14
  33. package/src/css/font.css +5 -4
  34. package/src/css/form.css +76 -52
  35. package/src/css/gradient.css +299 -222
  36. package/src/css/icon.css +57 -57
  37. package/src/css/index.css +1 -1
  38. package/src/css/layout.css +13 -10
  39. package/src/css/loading.css +60 -12
  40. package/src/css/menu.css +262 -188
  41. package/src/css/modal.css +1 -1
  42. package/src/css/offcanvas.css +9 -13
  43. package/src/css/pagination.css +5 -1
  44. package/src/css/popup.css +1 -1
  45. package/src/css/skeleton.css +3 -2
  46. package/src/css/status.css +2 -3
  47. package/src/css/swiper.css +1 -1
  48. package/src/css/table.css +1 -1
  49. package/src/css/tabs.css +12 -11
  50. package/src/css/tag.css +1 -1
  51. package/src/css/theme-palette.css +19 -5
  52. package/src/css/themes/default/accordion.css +61 -61
  53. package/src/css/themes/default/avatar.css +54 -54
  54. package/src/css/themes/default/badge.css +62 -62
  55. package/src/css/themes/default/breadcrumb.css +23 -23
  56. package/src/css/themes/default/button.css +203 -203
  57. package/src/css/themes/default/content.css +53 -53
  58. package/src/css/themes/default/drop.css +10 -10
  59. package/src/css/themes/default/editor.css +9 -9
  60. package/src/css/themes/default/index.css +1 -1
  61. package/src/css/themes/default/loading.css +26 -20
  62. package/src/css/themes/default/menu.css +75 -11
  63. package/src/css/themes/default/modal.css +32 -36
  64. package/src/css/themes/default/pagination.css +7 -7
  65. package/src/css/themes/default/popup.css +6 -6
  66. package/src/css/themes/default/swiper.css +32 -32
  67. package/src/css/themes/default/table.css +16 -16
  68. package/src/css/themes/default/tabs.css +30 -30
  69. package/src/css/themes/default/tag.css +71 -71
  70. package/src/css/themes/default/theme-palette.css +11 -11
  71. package/src/css/themes/default/tip.css +42 -42
  72. package/src/css/themes/default/toast.css +60 -60
  73. package/src/css/themes/default/toolbar.css +23 -29
  74. package/src/css/themes/default/tooltip.css +43 -13
  75. package/src/css/themes/index.css +1 -1
  76. package/src/css/tip.css +2 -2
  77. package/src/css/toast.css +3 -3
  78. package/src/css/tokens.css +12 -17
  79. package/src/css/toolbar.css +9 -10
  80. package/src/css/tooltip.css +3 -2
  81. package/src/index.ts +22 -19
  82. package/src/{components → primitives}/drop.ts +58 -49
  83. package/src/{components → primitives}/icons.ts +1 -1
  84. package/src/primitives/loading.ts +23 -0
  85. package/src/primitives/parabola.ts +388 -0
  86. package/src/primitives/popup.ts +61 -0
  87. package/src/{components → primitives}/theme.ts +89 -57
  88. package/src/{components → primitives}/toast.ts +18 -25
  89. package/src/{components → primitives}/tooltip.ts +100 -25
  90. package/src/utilities/browser.ts +23 -49
  91. package/src/utilities/class-name.ts +5 -0
  92. package/src/utilities/dom.ts +14 -32
  93. package/src/utilities/function.ts +4 -0
  94. package/src/utilities/http.ts +7 -7
  95. package/src/utilities/id.ts +57 -0
  96. package/src/utilities/object.ts +5 -0
  97. package/src/utilities/timer.ts +38 -0
  98. package/src/utilities/types.ts +420 -0
  99. package/src/{components → validation}/validator.ts +64 -53
  100. package/src/components/parabola.ts +0 -335
  101. package/src/utilities/cache.ts +0 -57
  102. package/src/utilities/core.ts +0 -614
  103. package/src/utilities/storage.ts +0 -46
@@ -1,31 +1,29 @@
1
1
  import {
2
2
  bindAttr,
3
3
  createDeepStore,
4
+ createEffect,
4
5
  createRoot,
5
6
  flushSync,
6
- insert,
7
7
  jsx,
8
+ untrack,
8
9
  } from 'vanilla-signal';
9
10
 
10
11
  import Component, {
11
12
  type ComponentDOM,
12
13
  type ComponentRuntime,
13
14
  } from '../core/Component.ts';
15
+ import { createLoading } from '../primitives/loading.ts';
14
16
  import {
15
- type ResolveSchema,
16
- randomId,
17
- resolveProps,
18
- validateParam,
19
- } from '../utilities/core.ts';
20
- import {
21
- type DOMReference,
22
17
  type RenderableContent,
23
- createLoading,
24
- isRenderableContent,
25
18
  normalizeContentNodes,
26
19
  q,
27
- requireContainer,
28
20
  } from '../utilities/dom.ts';
21
+ import { randomId } from '../utilities/id.ts';
22
+ import {
23
+ type ResolveSchema,
24
+ resolveProps,
25
+ validateParam,
26
+ } from '../utilities/types.ts';
29
27
 
30
28
  export type TabsDirection = 'top' | 'bottom' | 'left' | 'right';
31
29
  export type TabsValue = number | string;
@@ -33,10 +31,6 @@ export type TabsDisabled = TabsValue | TabsValue[];
33
31
 
34
32
  export interface TabsClassNames {
35
33
  root: string;
36
- top: string;
37
- bottom: string;
38
- left: string;
39
- right: string;
40
34
  wrap: string;
41
35
  list: string;
42
36
  tab: string;
@@ -88,19 +82,8 @@ export interface TabsProps extends Record<string, unknown> {
88
82
  panel: HTMLElement | undefined
89
83
  ) => void | Promise<void>)
90
84
  | null;
91
- tabs?: TabItem[];
85
+ data?: TabItem[];
92
86
  className?: TabsClassNameConfig;
93
- onAdd?:
94
- | ((
95
- index: number,
96
- item: TabItem,
97
- tab: HTMLElement | undefined,
98
- panel: HTMLElement | undefined
99
- ) => void | Promise<void>)
100
- | null;
101
- onRemove?:
102
- | ((index: number, name: string | undefined) => void | Promise<void>)
103
- | null;
104
87
  }
105
88
 
106
89
  interface ResolvedTabsProps extends Record<string, unknown> {
@@ -109,29 +92,31 @@ interface ResolvedTabsProps extends Record<string, unknown> {
109
92
  active: TabsValue;
110
93
  disabled: TabsDisabled;
111
94
  onChange: NonNullable<TabsProps['onChange']> | null;
112
- tabs: TabItem[];
95
+ data: TabItem[];
113
96
  className: TabsClassNames;
114
- onAdd: NonNullable<TabsProps['onAdd']> | null;
115
- onRemove: NonNullable<TabsProps['onRemove']> | null;
116
97
  }
117
98
 
118
99
  interface TabsState extends Record<string, unknown> {
100
+ data: TabItem[];
101
+ active: TabsValue;
102
+ disabled: TabsDisabled;
103
+ direction: TabsDirection;
119
104
  current: {
120
105
  index: number;
121
106
  name: string | null;
122
107
  };
123
- disabled: {
124
- names: string[];
125
- indexes: number[];
126
- };
127
108
  isVertical: boolean;
128
109
  draggable: boolean;
129
110
  loading: boolean;
130
111
  }
131
112
 
113
+ interface TabsDisabledState {
114
+ names: string[];
115
+ indexes: number[];
116
+ }
117
+
132
118
  interface TabsDOM extends ComponentDOM {
133
119
  root: HTMLElement | null;
134
- container: Element;
135
120
  tabs: HTMLElement[];
136
121
  panels: HTMLElement[];
137
122
  }
@@ -142,6 +127,7 @@ interface TabsPanelCacheEntry {
142
127
  }
143
128
 
144
129
  interface TabsRuntime extends ComponentRuntime {
130
+ built: boolean;
145
131
  cache: {
146
132
  panels: Map<string, TabsPanelCacheEntry>;
147
133
  };
@@ -152,10 +138,6 @@ type PointerDragEvent = MouseEvent | TouchEvent;
152
138
 
153
139
  const DEFAULT_CLASS_NAMES: TabsClassNames = {
154
140
  root: 'j-tabs',
155
- top: 'is-top',
156
- bottom: 'is-bottom',
157
- left: 'is-left',
158
- right: 'is-right',
159
141
  wrap: 'tab-wrap',
160
142
  list: 'tab-list',
161
143
  tab: 'tab-item',
@@ -186,7 +168,7 @@ const TABS_PROPS_SCHEMA = {
186
168
  active: { default: 0, types: ['number', 'string'] },
187
169
  disabled: { default: [], types: ['number', 'string', 'array'] },
188
170
  onChange: { default: null, types: ['function', 'null'] },
189
- tabs: { default: [], type: 'array' },
171
+ data: { default: [], type: 'array' },
190
172
  className: {
191
173
  default: DEFAULT_CLASS_NAMES,
192
174
  type: 'object',
@@ -195,31 +177,55 @@ const TABS_PROPS_SCHEMA = {
195
177
  ...(value && typeof value === 'object' ? value : {}),
196
178
  }),
197
179
  },
198
- onAdd: { default: null, types: ['function', 'null'] },
199
- onRemove: { default: null, types: ['function', 'null'] },
200
180
  } satisfies ResolveSchema<TabsProps>;
201
181
 
202
182
  const TAB_CONFIG_RULE = {
203
- type: 'object',
204
- validate: (value: unknown) => {
205
- const item = value as Partial<TabItem>;
206
- return (
207
- !!item &&
208
- typeof item === 'object' &&
209
- isRenderableContent(item.title) &&
210
- isRenderableContent(item.panel)
211
- );
183
+ type: 'plainObject',
184
+ shape: {
185
+ title: 'renderable',
186
+ panel: 'renderable',
212
187
  },
213
- message:
214
- 'expects an object with renderable title and panel: string, Node, array, function or null.',
215
188
  };
216
189
 
217
190
  function cloneTabItems(tabs: unknown): TabItem[] {
218
191
  return Array.isArray(tabs)
219
- ? tabs.map((item) => ({ ...(item as TabItem) }))
192
+ ? tabs.map((item) => {
193
+ const nextItem = { ...(item as TabItem) };
194
+ nextItem.name = nextItem.name || randomId();
195
+ return nextItem;
196
+ })
220
197
  : [];
221
198
  }
222
199
 
200
+ function cloneDisabled(disabled: TabsDisabled): TabsDisabled {
201
+ return Array.isArray(disabled) ? disabled.slice() : disabled;
202
+ }
203
+
204
+ function resolveDisabledState(
205
+ disabled: TabsDisabled,
206
+ data: TabItem[]
207
+ ): TabsDisabledState {
208
+ const toName = (val: TabsValue): string | null => {
209
+ if (typeof val === 'number') return data[val]?.name || null;
210
+ if (typeof val === 'string') return val;
211
+ return null;
212
+ };
213
+ const names = Array.isArray(disabled)
214
+ ? disabled.map(toName).filter((name): name is string => !!name)
215
+ : (() => {
216
+ const name = toName(disabled);
217
+ return name ? [name] : [];
218
+ })();
219
+ const uniqNames = Array.from(new Set(names));
220
+
221
+ return {
222
+ names: uniqNames,
223
+ indexes: uniqNames
224
+ .map((name) => data.findIndex((tab) => tab.name === name))
225
+ .filter((index) => index >= 0),
226
+ };
227
+ }
228
+
223
229
  function normalizeTtl(ttl: unknown): number {
224
230
  return typeof ttl === 'number' && ttl > 0 ? ttl : 0;
225
231
  }
@@ -232,83 +238,79 @@ function normalizeProps(input: TabsProps): ResolvedTabsProps {
232
238
  active: props.active as TabsValue,
233
239
  disabled: props.disabled as TabsDisabled,
234
240
  onChange: props.onChange as ResolvedTabsProps['onChange'],
235
- tabs: cloneTabItems(props.tabs),
241
+ data: cloneTabItems(props.data),
236
242
  className: props.className as TabsClassNames,
237
- onAdd: props.onAdd as ResolvedTabsProps['onAdd'],
238
- onRemove: props.onRemove as ResolvedTabsProps['onRemove'],
239
243
  };
240
244
  }
241
245
 
242
- function joinClasses(
243
- ...classes: Array<string | false | null | undefined>
244
- ): string {
245
- return classes.filter(Boolean).join(' ');
246
- }
246
+ const TABS_STATE_SCHEMA = {
247
+ data: TABS_PROPS_SCHEMA.data,
248
+ active: TABS_PROPS_SCHEMA.active,
249
+ disabled: TABS_PROPS_SCHEMA.disabled,
250
+ direction: TABS_PROPS_SCHEMA.direction,
251
+ };
247
252
 
248
253
  /**
249
254
  * 标签页组件,继承 Component。
250
255
  *
251
256
  * DOM 创建一次,通过 createEffect 细粒度更新 class/ARIA。
252
257
  */
253
- export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
258
+ class TabsComponent extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
254
259
  declare runtime: TabsRuntime;
255
260
  declare state: TabsState;
256
261
  private bindingsDispose: (() => void) | null;
262
+ private stateDispose: (() => void) | null;
257
263
  private isDragging: boolean;
258
264
  private raf: number;
259
265
  private resizeRaf: number;
260
266
  private velocity: number;
261
267
 
262
268
  /**
263
- * @param {Element|Node|string|Array} container 挂载容器(元素、选择器或 JSX/h 返回节点)。
264
269
  * @param {object} [input={}] 标签页配置。
265
270
  */
266
- constructor(container: DOMReference, input: TabsProps = {}) {
267
- const el = requireContainer(container, 'Tabs');
271
+ constructor(input: TabsProps = {}) {
268
272
  const props = normalizeProps(input);
269
273
  super(props);
270
274
 
271
- this.dom.container = el;
272
275
  this.dom.tabs = [];
273
276
  this.dom.panels = [];
274
277
  this.bindingsDispose = null;
278
+ this.stateDispose = null;
275
279
  this.isDragging = false;
276
280
  this.raf = 0;
277
281
  this.resizeRaf = 0;
278
282
  this.velocity = 0;
279
283
 
280
284
  this.state = createDeepStore({
285
+ data: cloneTabItems(props.data),
286
+ active: props.active,
287
+ disabled: cloneDisabled(props.disabled),
288
+ direction: props.direction,
281
289
  current: {
282
290
  index: -1,
283
291
  name: null,
284
292
  },
285
- disabled: this.parseDisabled(props.disabled),
286
293
  isVertical: props.direction === 'left' || props.direction === 'right',
287
294
  draggable: false,
288
295
  loading: false,
289
296
  }) as TabsState;
290
297
 
291
298
  this.runtime.cache = { panels: new Map() };
299
+ this.runtime.built = false;
292
300
  this.runtime.panelLoadId = 0;
293
-
294
- try {
295
- this.onInit(props);
296
- } catch (error) {
297
- this.destroy();
298
- throw error;
299
- }
300
301
  }
301
302
 
302
303
  protected onInit(props: ResolvedTabsProps): void {
303
- this.root = this.buildRoot(props);
304
- this.rebuildItems();
305
- void this.activateInternal(props.active, false);
304
+ this.dom.root = this.buildRoot(props);
305
+ this.renderItems();
306
+ void this.activateInternal(this.state.active, false);
306
307
  this.bindEvents();
307
308
  this.initDrag();
309
+ this.bindState();
308
310
  }
309
311
 
310
312
  private buildRoot(props: ResolvedTabsProps): HTMLElement {
311
- const { id, direction, className } = props;
313
+ const { id, className } = props;
312
314
  const nav = jsx('nav', {
313
315
  className: className.list,
314
316
  'data-tabs-list': '',
@@ -324,18 +326,21 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
324
326
  }) as HTMLElement;
325
327
 
326
328
  return jsx('div', {
327
- className: joinClasses(className.root, className[direction]),
329
+ className: className.root,
328
330
  id,
329
331
  'data-tabs': 'root',
330
- 'data-tabs-direction': direction,
332
+ 'data-tabs-direction': this.state.direction,
331
333
  children: [wrap, panelWrapper],
332
334
  }) as HTMLElement;
333
335
  }
334
336
 
335
- private rebuildItems(): void {
336
- if (!this.root) return;
337
- const tabList = q<HTMLElement>('[data-tabs-list]', this.root);
338
- const panelWrapper = q<HTMLElement>('[data-tabs-panel-wrap]', this.root);
337
+ private renderItems(): void {
338
+ if (!this.dom.root) return;
339
+ const tabList = q<HTMLElement>('[data-tabs-list]', this.dom.root);
340
+ const panelWrapper = q<HTMLElement>(
341
+ '[data-tabs-panel-wrap]',
342
+ this.dom.root
343
+ );
339
344
  if (!tabList || !panelWrapper) return;
340
345
 
341
346
  tabList.textContent = '';
@@ -347,7 +352,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
347
352
  const tabFragment = document.createDocumentFragment();
348
353
  const panelFragment = document.createDocumentFragment();
349
354
 
350
- this.props.tabs.forEach((item, index) => {
355
+ this.state.data.forEach((item, index) => {
351
356
  const name = item.name || randomId();
352
357
 
353
358
  const tab = jsx('div', {
@@ -390,7 +395,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
390
395
  this.state.current.index === index ? 'true' : 'false'
391
396
  );
392
397
  bindAttr(tab, 'aria-disabled', () =>
393
- this.state.disabled.names.includes(name) ? 'true' : 'false'
398
+ this.isDisabledName(name) ? 'true' : 'false'
394
399
  );
395
400
  });
396
401
  this.dom.panels.forEach((panel, index) => {
@@ -402,31 +407,100 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
402
407
  });
403
408
  }
404
409
 
405
- private parseDisabled(disabled: TabsDisabled): TabsState['disabled'] {
406
- if (disabled == null) return { names: [], indexes: [] };
407
- const toName = (val: TabsValue): string | null => {
408
- if (typeof val === 'number') return this.props.tabs[val]?.name || null;
409
- if (typeof val === 'string') return val;
410
- return null;
411
- };
412
- const names = Array.isArray(disabled)
413
- ? disabled.map(toName).filter((name): name is string => !!name)
414
- : (() => {
415
- const name = toName(disabled);
416
- return name ? [name] : [];
417
- })();
418
-
419
- return this.createDisabledState(names);
420
- }
421
-
422
- private createDisabledState(names: string[]): TabsState['disabled'] {
423
- const uniqNames = Array.from(new Set(names));
424
- return {
425
- names: uniqNames,
426
- indexes: uniqNames
427
- .map((name) => this.props.tabs.findIndex((tab) => tab.name === name))
428
- .filter((index) => index >= 0),
429
- };
410
+ private bindState(): void {
411
+ if (this.stateDispose) return;
412
+
413
+ this.stateDispose = createRoot((dispose) => {
414
+ let initialized = false;
415
+ let previousData = this.state.data;
416
+ let previousDirection = this.state.direction;
417
+ let previousActive = this.state.active;
418
+ createEffect(() => {
419
+ const data = this.state.data;
420
+ const direction = this.state.direction;
421
+ const disabled = this.state.disabled;
422
+ const active = this.state.active;
423
+ if (!initialized) {
424
+ initialized = true;
425
+ return;
426
+ }
427
+
428
+ const dataChanged = data !== previousData;
429
+ const directionChanged = direction !== previousDirection;
430
+ const activeChanged = active !== previousActive;
431
+ previousData = data;
432
+ previousDirection = direction;
433
+ previousActive = active;
434
+
435
+ void untrack(() =>
436
+ this.syncStateView({ dataChanged, directionChanged, activeChanged })
437
+ );
438
+ void disabled;
439
+ });
440
+ return dispose;
441
+ });
442
+ }
443
+
444
+ private async syncStateView({
445
+ dataChanged,
446
+ directionChanged,
447
+ activeChanged,
448
+ }: {
449
+ dataChanged: boolean;
450
+ directionChanged: boolean;
451
+ activeChanged: boolean;
452
+ }): Promise<void> {
453
+ if (!this.runtime.built || !this.dom.root) return;
454
+ this.validateData(this.state.data);
455
+ validateParam(
456
+ 'direction',
457
+ this.state.direction,
458
+ TABS_STATE_SCHEMA.direction,
459
+ 'Tabs.state'
460
+ );
461
+ validateParam(
462
+ 'disabled',
463
+ this.state.disabled,
464
+ TABS_STATE_SCHEMA.disabled,
465
+ 'Tabs.state'
466
+ );
467
+ validateParam(
468
+ 'active',
469
+ this.state.active,
470
+ TABS_STATE_SCHEMA.active,
471
+ 'Tabs.state'
472
+ );
473
+
474
+ if (directionChanged) {
475
+ this.dom.root.setAttribute('data-tabs-direction', this.state.direction);
476
+ flushSync(() => {
477
+ this.state.isVertical =
478
+ this.state.direction === 'left' || this.state.direction === 'right';
479
+ });
480
+ }
481
+
482
+ if (dataChanged) {
483
+ this.runtime.cache.panels.clear();
484
+ this.renderItems();
485
+ this.bindEvents();
486
+ flushSync(() => {
487
+ this.syncCurrent(-1);
488
+ });
489
+ }
490
+
491
+ if (dataChanged || activeChanged) {
492
+ await this.activateInternal(this.state.active, activeChanged);
493
+ }
494
+
495
+ this.refreshDrag();
496
+ }
497
+
498
+ private get disabledState(): TabsDisabledState {
499
+ return resolveDisabledState(this.state.disabled, this.state.data);
500
+ }
501
+
502
+ private isDisabledName(name: string): boolean {
503
+ return this.disabledState.names.includes(name);
430
504
  }
431
505
 
432
506
  private syncCurrent(index: number): void {
@@ -480,7 +554,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
480
554
  content: RenderableContent<TabsPanelContext>
481
555
  ): void {
482
556
  const panel = this.dom.panels[index];
483
- const item = this.props.tabs[index];
557
+ const item = this.state.data[index];
484
558
  if (!panel || !item) return;
485
559
 
486
560
  panel.textContent = '';
@@ -495,7 +569,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
495
569
  }
496
570
 
497
571
  private async loadPanel(index: number): Promise<void> {
498
- const item = this.props.tabs[index];
572
+ const item = this.state.data[index];
499
573
  const panel = this.dom.panels[index];
500
574
  if (!item || !panel || typeof item.panel !== 'function') {
501
575
  this.runtime.panelLoadId += 1;
@@ -553,14 +627,14 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
553
627
  }
554
628
 
555
629
  get disabledNames(): string[] {
556
- return this.state.disabled.names;
630
+ return this.disabledState.names;
557
631
  }
558
632
 
559
633
  private bindEvents(): void {
560
634
  this.unbindEvents();
561
635
 
562
- if (!this.root) return;
563
- const tabList = q<HTMLElement>('[data-tabs-list]', this.root);
636
+ if (!this.dom.root) return;
637
+ const tabList = q<HTMLElement>('[data-tabs-list]', this.dom.root);
564
638
  if (!tabList) return;
565
639
 
566
640
  this.cleanup.events.on('tabClick', tabList, 'click', (event) => {
@@ -568,7 +642,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
568
642
  const tab = event.target.closest<HTMLElement>('[data-tabs-tab]');
569
643
  if (!tab || !tabList.contains(tab)) return;
570
644
  const name = tab.dataset.tabsTab;
571
- if (name && !this.state.disabled.names.includes(name)) {
645
+ if (name && !this.isDisabledName(name)) {
572
646
  void this.activate(name);
573
647
  }
574
648
  });
@@ -582,6 +656,9 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
582
656
  if (this.runtime.destroyed) {
583
657
  throw new Error(`Tabs.${method}: instance has been destroyed.`);
584
658
  }
659
+ if (!this.runtime.built) {
660
+ throw new Error(`Tabs.${method}: call build() first.`);
661
+ }
585
662
  }
586
663
 
587
664
  private getIndex(val: TabsValue | undefined | null): number {
@@ -601,7 +678,7 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
601
678
  if (
602
679
  index < 0 ||
603
680
  index >= this.dom.tabs.length ||
604
- this.state.disabled.names.includes(name) ||
681
+ this.isDisabledName(name) ||
605
682
  this.state.current.index === index
606
683
  ) {
607
684
  return;
@@ -633,172 +710,49 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
633
710
  */
634
711
  async activate(val: TabsValue): Promise<void> {
635
712
  this.assertActive('activate');
713
+ this.state.active = val;
636
714
  await this.activateInternal(val, true);
637
715
  }
638
716
 
639
717
  /**
640
- * 将组件挂载到构造器指定的容器中。
718
+ * 构建 Tabs DOM。
641
719
  */
642
720
  build(): this {
643
- this.assertActive('build');
644
- insert(this.dom.container, () => this.root);
645
- return this;
646
- }
647
-
648
- render(): this {
649
- return this.build();
650
- }
651
-
652
- /**
653
- * 动态新增标签。
654
- * @param {object} tabConfig 标签配置。
655
- */
656
- async add(tabConfig: TabItem): Promise<void> {
657
- this.assertActive('add');
658
- validateParam('tabConfig', tabConfig, TAB_CONFIG_RULE, 'Tabs.add');
659
-
660
- tabConfig.name = tabConfig.name || randomId();
661
- this.props.tabs = [...cloneTabItems(this.props.tabs), tabConfig];
662
- this.runtime.cache.panels.clear();
663
-
664
- this.rebuildItems();
665
- this.syncActiveNames(this.resolveActiveIndex(this.props.active));
666
- this.bindEvents();
667
- this.refreshDrag();
668
-
669
- const { onAdd } = this.props;
670
- if (onAdd) {
671
- const index = this.props.tabs.length - 1;
672
- await Promise.resolve(
673
- onAdd(index, tabConfig, this.dom.tabs[index], this.dom.panels[index])
674
- );
675
- }
676
- }
677
-
678
- /**
679
- * 根据索引或名称删除标签。
680
- * @param {number|string} val 标签索引或名称。
681
- */
682
- async delete(val: TabsValue): Promise<void> {
683
- this.assertActive('delete');
684
- if (this.props.tabs.length <= 1) return;
685
-
686
- const index = this.getIndex(val);
687
- if (index < 0 || index >= this.props.tabs.length) return;
688
-
689
- const removedName = this.props.tabs[index].name;
690
- const { onRemove } = this.props;
691
-
692
- this.props.tabs = this.props.tabs.filter((_, i) => i !== index);
693
- if (removedName) this.runtime.cache.panels.delete(removedName);
694
-
695
- if (this.state.current.index >= this.props.tabs.length) {
696
- flushSync(() => {
697
- this.syncCurrent(this.props.tabs.length - 1);
698
- });
699
- } else if (this.state.current.index > index) {
700
- flushSync(() => {
701
- this.syncCurrent(this.state.current.index - 1);
702
- });
703
- }
704
-
705
- this.rebuildItems();
706
- this.bindEvents();
707
- await this.loadPanel(this.state.current.index);
708
- this.refreshDrag();
709
-
710
- if (onRemove) await Promise.resolve(onRemove(index, removedName));
711
- }
712
-
713
- /**
714
- * 根据索引或名称禁用标签。
715
- * @param {number|string} val 标签索引或名称。
716
- */
717
- disable(val: TabsValue): void {
718
- this.assertActive('disable');
719
- const name =
720
- typeof val === 'number' ? this.dom.tabs[val]?.dataset.tabsTab : val;
721
- if (name && !this.state.disabled.names.includes(name)) {
722
- flushSync(() => {
723
- this.state.disabled = this.createDisabledState([
724
- ...this.state.disabled.names,
725
- name,
726
- ]);
727
- });
728
- }
729
- }
730
-
731
- /**
732
- * 根据索引或名称启用标签。
733
- * @param {number|string} val 标签索引或名称。
734
- */
735
- enable(val: TabsValue): void {
736
- this.assertActive('enable');
737
- const name =
738
- typeof val === 'number' ? this.dom.tabs[val]?.dataset.tabsTab : val;
739
- if (name) {
740
- flushSync(() => {
741
- this.state.disabled = this.createDisabledState(
742
- this.state.disabled.names.filter((n) => n !== name)
743
- );
744
- });
721
+ if (this.runtime.destroyed) {
722
+ throw new Error('Tabs.build: instance has been destroyed.');
745
723
  }
746
- }
747
-
748
- private resolveActiveIndex(active: TabsValue | null | undefined): number {
749
- if (active == null) return -1;
750
- if (typeof active === 'number') return active;
751
- if (typeof active === 'string') {
752
- return this.dom.tabs.findIndex((tab) => tab.dataset.tabsTab === active);
724
+ if (this.runtime.built) return this;
725
+ try {
726
+ this.runtime.built = true;
727
+ this.onInit(this.props);
728
+ } catch (error) {
729
+ this.destroy();
730
+ throw error;
753
731
  }
754
- return 0;
755
- }
756
-
757
- private syncActiveNames(index: number): void {
758
- flushSync(() => {
759
- this.syncCurrent(index);
760
- });
732
+ this.emit('init', this.props);
733
+ return this;
761
734
  }
762
735
 
763
- /**
764
- * 使用新配置重新初始化状态。
765
- * @param {object} [patch={}] 需要覆盖的配置。
766
- */
767
- async reInit(patch: TabsProps = {}): Promise<void> {
768
- this.assertActive('reInit');
769
- Object.assign(
770
- this.props,
771
- normalizeProps({
772
- ...this.props,
773
- ...patch,
774
- className: {
775
- ...this.props.className,
776
- ...patch.className,
777
- },
778
- })
779
- );
780
-
781
- flushSync(() => {
782
- this.state.disabled = this.parseDisabled(this.props.disabled);
783
- });
784
-
785
- this.rebuildItems();
786
- this.syncActiveNames(this.resolveActiveIndex(this.props.active));
787
- this.bindEvents();
788
- await this.loadPanel(this.state.current.index);
736
+ refresh(): this {
737
+ this.assertActive('refresh');
789
738
  this.refreshDrag();
739
+ return this;
790
740
  }
791
741
 
792
742
  private get dragContainer(): HTMLElement | null {
793
- return this.root ? q<HTMLElement>('[data-tabs-wrap]', this.root) : null;
743
+ return this.dom.root
744
+ ? q<HTMLElement>('[data-tabs-wrap]', this.dom.root)
745
+ : null;
794
746
  }
795
747
 
796
748
  private get dragInner(): HTMLElement | null {
797
- return this.root ? q<HTMLElement>('[data-tabs-list]', this.root) : null;
749
+ return this.dom.root
750
+ ? q<HTMLElement>('[data-tabs-list]', this.dom.root)
751
+ : null;
798
752
  }
799
753
 
800
754
  private initDrag(): void {
801
- const { direction } = this.props;
755
+ const direction = this.state.direction;
802
756
  const container = this.dragContainer;
803
757
  const inner = this.dragInner;
804
758
  if (!container || !inner) return;
@@ -948,17 +902,70 @@ export class Tabs extends Component<ResolvedTabsProps, TabsState, TabsDOM> {
948
902
  this.removeDragEvents();
949
903
  this.bindingsDispose?.();
950
904
  this.bindingsDispose = null;
905
+ this.stateDispose?.();
906
+ this.stateDispose = null;
951
907
  cancelAnimationFrame(this.raf);
952
908
  cancelAnimationFrame(this.resizeRaf);
953
909
  this.cleanup.events.off('resize');
954
910
 
955
- if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
911
+ this.dom.root?.remove();
912
+ this.runtime.built = false;
913
+ }
914
+
915
+ private validateData(data: TabItem[]): void {
916
+ validateParam('data', data, TABS_STATE_SCHEMA.data, 'Tabs.state');
917
+ data.forEach((item, index) => {
918
+ validateParam(String(index), item, TAB_CONFIG_RULE, 'Tabs.state.data');
919
+ });
920
+ }
921
+
922
+ protected override normalizeStatePatch(
923
+ patch: Partial<TabsState>
924
+ ): Partial<TabsState> {
925
+ const nextPatch = { ...patch };
926
+ if (Object.hasOwn(nextPatch, 'data') && Array.isArray(nextPatch.data)) {
927
+ nextPatch.data = cloneTabItems(nextPatch.data);
928
+ }
929
+ if (
930
+ Object.hasOwn(nextPatch, 'disabled') &&
931
+ Array.isArray(nextPatch.disabled)
932
+ ) {
933
+ nextPatch.disabled = cloneDisabled(nextPatch.disabled);
934
+ }
935
+ return nextPatch;
936
+ }
937
+
938
+ protected override validateStatePatch(patch: Partial<TabsState>): void {
939
+ validateParam(
940
+ 'state',
941
+ patch,
942
+ {
943
+ type: 'plainObject',
944
+ },
945
+ 'Tabs.setState'
946
+ );
947
+
948
+ for (const key of Object.keys(patch)) {
949
+ if (!Object.hasOwn(TABS_STATE_SCHEMA, key)) {
950
+ throw new Error(
951
+ `Tabs.setState: "${key}" is not a supported state key.`
952
+ );
953
+ }
954
+ const stateKey = key as keyof typeof TABS_STATE_SCHEMA;
955
+ validateParam(
956
+ key,
957
+ patch[key as keyof TabsState],
958
+ TABS_STATE_SCHEMA[stateKey],
959
+ 'Tabs.setState'
960
+ );
961
+ }
962
+
963
+ if (Array.isArray(patch.data)) this.validateData(patch.data);
956
964
  }
957
965
  }
958
966
 
959
- export function createTabs(
960
- container: DOMReference,
961
- input: TabsProps = {}
962
- ): Tabs {
963
- return new Tabs(container, input);
967
+ export type Tabs = TabsComponent;
968
+
969
+ export function createTabs(input: TabsProps = {}): Tabs {
970
+ return new TabsComponent(input);
964
971
  }