vanilla-jui 1.4.5 → 1.4.6

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 +88 -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,34 +1,32 @@
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 { icon } from '../primitives/icons.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
- isRenderableContent,
24
18
  normalizeContentNodes,
25
- requireContainer,
26
19
  } from '../utilities/dom.ts';
27
- import { icon } from './icons.ts';
20
+ import { randomId } from '../utilities/id.ts';
21
+ import {
22
+ type ResolveSchema,
23
+ resolveProps,
24
+ validateParam,
25
+ } from '../utilities/types.ts';
28
26
 
29
- export type AccordionActive = number | string | Array<number | string> | null;
27
+ type AccordionActive = number | string | Array<number | string> | null;
30
28
 
31
- export interface AccordionClassNames {
29
+ interface AccordionClassNames {
32
30
  root: string;
33
31
  header: string;
34
32
  title: string;
@@ -37,28 +35,28 @@ export interface AccordionClassNames {
37
35
  content: string;
38
36
  }
39
37
 
40
- export type AccordionClassNameConfig = Partial<AccordionClassNames>;
38
+ type AccordionClassNameConfig = Partial<AccordionClassNames>;
41
39
 
42
- export interface AccordionItem extends Record<string, unknown> {
40
+ interface AccordionItem extends Record<string, unknown> {
43
41
  name?: string;
44
42
  title: RenderableContent<AccordionContentContext>;
45
43
  content: RenderableContent<AccordionContentContext>;
46
44
  }
47
45
 
48
- export interface AccordionProps extends Record<string, unknown> {
46
+ interface AccordionProps extends Record<string, unknown> {
49
47
  id?: string | null;
50
48
  active?: AccordionActive;
51
49
  collapsible?: boolean;
52
50
  multiple?: boolean;
53
51
  className?: AccordionClassNameConfig;
54
- items?: AccordionItem[];
52
+ items: AccordionItem[];
55
53
  onChange?:
56
54
  | ((
57
55
  index: number,
58
56
  name: string,
59
57
  header: HTMLElement,
60
58
  panel: HTMLElement,
61
- accordion: Accordion
59
+ accordion: AccordionInstance
62
60
  ) => void | Promise<void>)
63
61
  | null;
64
62
  }
@@ -73,31 +71,48 @@ interface ResolvedAccordionProps extends Record<string, unknown> {
73
71
  onChange: NonNullable<AccordionProps['onChange']> | null;
74
72
  }
75
73
 
74
+ interface AccordionCurrent {
75
+ index: number | null;
76
+ name: string | null;
77
+ }
78
+
76
79
  interface AccordionState extends Record<string, unknown> {
80
+ items: AccordionItem[];
77
81
  activeNames: string[];
78
- current: {
79
- index: number | null;
80
- name: string | null;
81
- };
82
+ current: AccordionCurrent;
82
83
  }
83
84
 
84
85
  interface AccordionDOM extends ComponentDOM {
85
86
  root: HTMLElement | null;
86
- container: Element;
87
87
  headers: HTMLElement[];
88
88
  panels: HTMLElement[];
89
89
  }
90
90
 
91
- interface AccordionRuntime extends ComponentRuntime {}
91
+ interface AccordionRuntime extends ComponentRuntime {
92
+ built: boolean;
93
+ }
92
94
 
93
- export interface AccordionContentContext {
94
- accordion: Accordion;
95
+ interface AccordionContentContext {
96
+ accordion: AccordionInstance;
95
97
  item: AccordionItem;
96
98
  index: number;
97
99
  type: 'title' | 'content';
98
100
  active: boolean;
99
101
  }
100
102
 
103
+ type AccordionInstance = Component<
104
+ ResolvedAccordionProps,
105
+ AccordionState,
106
+ AccordionDOM
107
+ > & {
108
+ runtime: AccordionRuntime;
109
+ state: AccordionState;
110
+ build(): AccordionInstance;
111
+ isActive(name: string): boolean;
112
+ getIndex(value: number | string | undefined | null): number;
113
+ activate(value: number | string | undefined): Promise<void>;
114
+ };
115
+
101
116
  const DEFAULT_CLASS_NAMES: AccordionClassNames = {
102
117
  root: 'j-accordion',
103
118
  header: 'accordion-header',
@@ -107,25 +122,29 @@ const DEFAULT_CLASS_NAMES: AccordionClassNames = {
107
122
  content: 'panel-content',
108
123
  };
109
124
 
125
+ const ACCORDION_ITEM_RULE = {
126
+ type: 'plainObject',
127
+ shape: {
128
+ name: ['string', 'null', 'undefined'],
129
+ title: 'renderable',
130
+ content: 'renderable',
131
+ },
132
+ };
133
+
110
134
  const ACCORDION_ITEMS_RULE = {
111
135
  type: 'array',
112
- validate: (value: unknown) => Array.isArray(value) && value.length > 0,
113
- message: 'expects a non-empty array.',
136
+ nonEmpty: true,
137
+ items: ACCORDION_ITEM_RULE,
114
138
  };
115
139
 
116
140
  const ACCORDION_ACTIVE_RULE = {
117
141
  types: ['number', 'string', 'array', 'null'],
118
142
  validate: (value: unknown) => {
119
143
  if (value == null) return true;
120
- if (Array.isArray(value)) {
121
- return value.every(
122
- (item) => typeof item === 'number' || typeof item === 'string'
123
- );
124
- }
125
- if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
126
- return typeof value === 'string' && value.trim().length > 0;
144
+ if (Array.isArray(value)) return value.every(isActiveValue);
145
+ return isActiveValue(value);
127
146
  },
128
- message: 'expects a positive number, string, array or null.',
147
+ message: 'expects a non-negative integer, non-empty string, array or null.',
129
148
  };
130
149
 
131
150
  const ACCORDION_PROPS_SCHEMA = {
@@ -153,58 +172,116 @@ const ACCORDION_PROPS_SCHEMA = {
153
172
  }),
154
173
  },
155
174
  onChange: { default: null, types: ['function', 'null'] },
156
- items: { default: [], type: 'array' },
175
+ items: { default: [], ...ACCORDION_ITEMS_RULE },
157
176
  } satisfies ResolveSchema<AccordionProps>;
158
177
 
159
- function cloneItems(items: unknown): AccordionItem[] {
160
- return Array.isArray(items)
161
- ? items.map((item) => ({ ...(item as AccordionItem) }))
162
- : [];
178
+ function isActiveValue(value: unknown): value is number | string {
179
+ if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
180
+ return typeof value === 'string' && value.trim().length > 0;
163
181
  }
164
182
 
165
- function normalizeProps(input: AccordionProps): ResolvedAccordionProps {
166
- const props = resolveProps(input, ACCORDION_PROPS_SCHEMA, 'Accordion');
183
+ function normalizeProps(props: AccordionProps): ResolvedAccordionProps {
184
+ const resolved = resolveProps(
185
+ props,
186
+ ACCORDION_PROPS_SCHEMA,
187
+ 'Accordion.props'
188
+ );
167
189
  return {
168
- id: props.id as string,
169
- active: props.active as AccordionActive,
170
- collapsible: props.collapsible as boolean,
171
- multiple: props.multiple as boolean,
172
- className: props.className as AccordionClassNames,
173
- items: cloneItems(props.items),
174
- onChange: props.onChange as ResolvedAccordionProps['onChange'],
190
+ id: resolved.id as string,
191
+ active: resolved.active as AccordionActive,
192
+ collapsible: resolved.collapsible as boolean,
193
+ multiple: resolved.multiple as boolean,
194
+ className: resolved.className as AccordionClassNames,
195
+ items: normalizeItems(resolved.items, false),
196
+ onChange: resolved.onChange as ResolvedAccordionProps['onChange'],
175
197
  };
176
198
  }
177
199
 
178
- function normalizeItems(items: AccordionItem[]): AccordionItem[] {
179
- validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion');
200
+ function normalizeItems(items: unknown, validate = true): AccordionItem[] {
201
+ if (validate) {
202
+ validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion');
203
+ }
180
204
 
181
- return items.map((item) => {
182
- if (!item || typeof item !== 'object' || Array.isArray(item)) {
183
- throw new Error('Accordion: item expects an object.');
184
- }
185
- if (item.name != null && typeof item.name !== 'string') {
186
- throw new Error('Accordion: item name expects a string.');
187
- }
188
- if (!isRenderableContent(item.title)) {
189
- throw new Error(
190
- 'Accordion: item title expects string, Node, array, function or null.'
191
- );
192
- }
193
- if (!isRenderableContent(item.content)) {
194
- throw new Error(
195
- 'Accordion: item content expects string, Node, array, function or null.'
196
- );
205
+ const names = new Set<string>();
206
+
207
+ return (items as unknown[]).map((item) => {
208
+ const source = item as AccordionItem;
209
+ const name = typeof source.name === 'string' ? source.name.trim() : '';
210
+ if (name) {
211
+ if (names.has(name)) {
212
+ throw new Error(`Accordion: item name "${name}" must be unique.`);
213
+ }
214
+ names.add(name);
197
215
  }
198
- return { ...item, name: item.name || randomId() };
216
+
217
+ return {
218
+ ...source,
219
+ name: name || randomId(),
220
+ };
199
221
  });
200
222
  }
201
223
 
224
+ function needsItemsSync(
225
+ source: AccordionItem[],
226
+ normalized: AccordionItem[]
227
+ ): boolean {
228
+ return normalized.some((item, index) => source[index]?.name !== item.name);
229
+ }
230
+
231
+ function resolveActiveItemNames(
232
+ active: AccordionActive,
233
+ items: AccordionItem[],
234
+ multiple: boolean
235
+ ): string[] {
236
+ if (active == null) return [];
237
+
238
+ const values = Array.isArray(active) ? active : [active];
239
+ const names: string[] = [];
240
+
241
+ for (const value of values) {
242
+ const index =
243
+ typeof value === 'number'
244
+ ? value
245
+ : items.findIndex((item) => item.name === value);
246
+ const name = items[index]?.name;
247
+
248
+ if (!name) continue;
249
+
250
+ names.push(name);
251
+ if (!multiple) break;
252
+ }
253
+
254
+ return Array.from(new Set(names));
255
+ }
256
+
257
+ function reconcileActiveNames(
258
+ activeNames: string[],
259
+ items: AccordionItem[],
260
+ multiple: boolean
261
+ ): string[] {
262
+ const names = new Set(items.map((item) => item.name).filter(Boolean));
263
+ const nextNames = activeNames.filter((name) => names.has(name));
264
+ return multiple ? nextNames : nextNames.slice(0, 1);
265
+ }
266
+
267
+ function createCurrentState(
268
+ activeNames: string[],
269
+ items: AccordionItem[]
270
+ ): AccordionCurrent {
271
+ const name = activeNames[0] || null;
272
+ if (!name) return { index: null, name: null };
273
+
274
+ const index = items.findIndex((item) => item.name === name);
275
+ return index >= 0 ? { index, name } : { index: null, name: null };
276
+ }
277
+
202
278
  /**
203
279
  * 轻量手风琴组件,继承 Component。
204
280
  *
205
- * DOM 创建一次,通过 createEffect 细粒度更新 class/ARIA。
281
+ * 构造器只验证和保存初始配置;调用 build() 后才创建 DOM 和绑定交互。
282
+ * items 属于响应式 state,变更后自动重建面板结构。
206
283
  */
207
- export class Accordion extends Component<
284
+ class Accordion extends Component<
208
285
  ResolvedAccordionProps,
209
286
  AccordionState,
210
287
  AccordionDOM
@@ -212,38 +289,33 @@ export class Accordion extends Component<
212
289
  declare runtime: AccordionRuntime;
213
290
  declare state: AccordionState;
214
291
  private bindingsDispose: (() => void) | null;
292
+ private itemsDispose: (() => void) | null;
215
293
 
216
- /**
217
- * @param {Element|Node|string|Array} container 挂载容器(元素、选择器或 JSX/h 返回节点)。
218
- * @param {object} [input={}] 手风琴配置。
219
- */
220
- constructor(container: DOMReference, input: AccordionProps = {}) {
221
- const el = requireContainer(container, 'Accordion');
222
- const props = normalizeProps(input);
223
- super(props);
294
+ constructor(props: AccordionProps) {
295
+ const resolvedProps = normalizeProps(props);
296
+ super(resolvedProps);
297
+
298
+ const activeNames = resolveActiveItemNames(
299
+ resolvedProps.active,
300
+ resolvedProps.items,
301
+ resolvedProps.multiple
302
+ );
224
303
 
225
- this.dom.container = el;
226
304
  this.dom.headers = [];
227
305
  this.dom.panels = [];
228
306
  this.bindingsDispose = null;
229
-
307
+ this.itemsDispose = null;
308
+ this.runtime.built = false;
230
309
  this.state = createDeepStore({
231
- activeNames: [],
232
- current: { index: null, name: null },
310
+ items: resolvedProps.items,
311
+ activeNames,
312
+ current: createCurrentState(activeNames, resolvedProps.items),
233
313
  }) as AccordionState;
234
-
235
- try {
236
- this.onInit(props);
237
- } catch (error) {
238
- this.destroy();
239
- throw error;
240
- }
241
314
  }
242
315
 
243
316
  protected onInit(props: ResolvedAccordionProps): void {
244
- this.root = this.buildRoot(props);
245
- this.buildItems(props);
246
- this.syncActiveNames(this.resolveActiveNames(props.active));
317
+ this.dom.root = this.buildRoot(props);
318
+ this.bindItems();
247
319
  this.bindEvents();
248
320
  }
249
321
 
@@ -255,33 +327,64 @@ export class Accordion extends Component<
255
327
  }) as HTMLElement;
256
328
  }
257
329
 
258
- private buildItems(props: ResolvedAccordionProps): void {
259
- const items = normalizeItems(props.items);
260
- const fragment = document.createDocumentFragment();
330
+ private bindItems(): void {
331
+ this.itemsDispose?.();
332
+ this.itemsDispose = createRoot((dispose) => {
333
+ createEffect(() => {
334
+ const sourceItems = this.state.items;
335
+ const items = normalizeItems(sourceItems);
336
+
337
+ untrack(() => {
338
+ if (needsItemsSync(sourceItems, items)) {
339
+ this.state.items = items;
340
+ }
341
+
342
+ const activeNames = reconcileActiveNames(
343
+ this.state.activeNames,
344
+ items,
345
+ this.props.multiple
346
+ );
347
+ this.buildItems(items, activeNames);
348
+ this.syncActiveNames(activeNames, items);
349
+ });
350
+ });
351
+
352
+ return dispose;
353
+ });
354
+ }
261
355
 
356
+ private buildItems(items: AccordionItem[], activeNames: string[]): void {
357
+ if (!this.dom.root) return;
358
+
359
+ const fragment = document.createDocumentFragment();
360
+ this.bindingsDispose?.();
361
+ this.bindingsDispose = null;
362
+ this.dom.root.textContent = '';
262
363
  this.dom.headers = [];
263
364
  this.dom.panels = [];
264
365
 
265
366
  items.forEach((item, index) => {
266
367
  const name = item.name || randomId();
267
- const headerId = `${props.id}_header_${index}`;
268
- const panelId = `${props.id}_panel_${index}`;
368
+ const active = activeNames.includes(name);
369
+ const headerId = `${this.props.id}_header_${index}`;
370
+ const panelId = `${this.props.id}_panel_${index}`;
269
371
 
270
372
  const header = jsx('div', {
271
- className: props.className.header,
272
- 'data-accordion-header': name,
373
+ className: this.props.className.header,
273
374
  id: headerId,
375
+ 'data-accordion-header': name,
274
376
  role: 'button',
275
377
  tabindex: '0',
276
378
  'aria-controls': panelId,
277
379
  children: [
278
380
  jsx('span', {
279
- className: props.className.title,
381
+ className: this.props.className.title,
280
382
  'data-accordion-title': name,
281
- children: this.contentView(item, index, 'title'),
383
+ role: 'heading',
384
+ children: this.contentView(item, index, 'title', active),
282
385
  }),
283
386
  jsx('span', {
284
- className: props.className.arrow,
387
+ className: this.props.className.arrow,
285
388
  'data-accordion-arrow': name,
286
389
  'aria-hidden': 'true',
287
390
  children: icon('arrow-down'),
@@ -290,15 +393,15 @@ export class Accordion extends Component<
290
393
  }) as HTMLElement;
291
394
 
292
395
  const panel = jsx('div', {
293
- className: props.className.panel,
396
+ className: this.props.className.panel,
294
397
  'data-accordion-panel': name,
295
398
  id: panelId,
296
399
  role: 'region',
297
400
  'aria-labelledby': headerId,
298
401
  children: jsx('div', {
299
- className: props.className.content,
402
+ className: this.props.className.content,
300
403
  'data-accordion-content': name,
301
- children: this.contentView(item, index, 'content'),
404
+ children: this.contentView(item, index, 'content', active),
302
405
  }),
303
406
  }) as HTMLElement;
304
407
 
@@ -307,29 +410,27 @@ export class Accordion extends Component<
307
410
  fragment.append(header, panel);
308
411
  });
309
412
 
310
- this.root?.append(fragment);
413
+ this.dom.root.append(fragment);
414
+ this.bindItemState();
415
+ }
311
416
 
312
- this.bindingsDispose?.();
417
+ private bindItemState(): void {
313
418
  this.bindingsDispose = createRoot((dispose) => {
314
419
  this.dom.headers.forEach((header) => {
315
420
  const name = header.dataset.accordionHeader || '';
316
- // bindClass(header, this.props.className.active, () =>
317
- // this.state.activeNames.includes(name)
318
- // );
319
421
  bindAttr(header, 'aria-expanded', () =>
320
422
  this.state.activeNames.includes(name) ? 'true' : 'false'
321
423
  );
322
424
  });
323
- this.dom.panels.forEach((panel, i) => {
324
- const name = this.dom.headers[i]?.dataset.accordionHeader || '';
325
- // bindClass(panel, this.props.className.active, () =>
326
- // this.state.activeNames.includes(name)
327
- // );
425
+
426
+ this.dom.panels.forEach((panel, index) => {
427
+ const name = this.dom.headers[index]?.dataset.accordionHeader || '';
328
428
  bindAttr(panel, 'aria-hidden', () =>
329
429
  this.state.activeNames.includes(name) ? 'false' : 'true'
330
430
  );
331
431
  bindAttr(panel, 'hidden', () => !this.state.activeNames.includes(name));
332
432
  });
433
+
333
434
  return dispose;
334
435
  });
335
436
  }
@@ -337,64 +438,48 @@ export class Accordion extends Component<
337
438
  private contentView(
338
439
  item: AccordionItem,
339
440
  index: number,
340
- type: 'title' | 'content'
441
+ type: 'title' | 'content',
442
+ active: boolean
341
443
  ): Node[] {
342
444
  return normalizeContentNodes(type === 'title' ? item.title : item.content, {
343
445
  accordion: this,
344
446
  item,
345
447
  index,
346
448
  type,
347
- active: false,
449
+ active,
348
450
  });
349
451
  }
350
452
 
351
- private resolveActiveNames(active: AccordionActive): string[] {
352
- if (active == null) return [];
353
- const values = Array.isArray(active) ? active : [active];
354
- const names: string[] = [];
355
- for (const value of values) {
356
- const index = this.getIndex(value);
357
- if (index < 0 || index >= this.dom.headers.length) continue;
358
- names.push(
359
- this.dom.headers[index].dataset.accordionHeader || String(index)
360
- );
361
- if (!this.props.multiple) break;
362
- }
363
- return Array.from(new Set(names));
364
- }
365
-
366
- private syncActiveNames(names: string[]): void {
367
- const firstName = names[0] || null;
368
- const index = firstName ? this.getIndex(firstName) : null;
453
+ private syncActiveNames(names: string[], items = this.state.items): void {
369
454
  flushSync(() => {
370
455
  this.state.activeNames = names;
371
- this.state.current = { index, name: firstName };
456
+ this.state.current = createCurrentState(names, items);
372
457
  });
373
458
  }
374
459
 
375
460
  private bindEvents(): void {
376
461
  this.unbindEvents();
377
- if (!this.root) return;
462
+ if (!this.dom.root) return;
378
463
 
379
- this.cleanup.events.on('click', this.root, 'click', (event) => {
464
+ this.cleanup.events.on('click', this.dom.root, 'click', (event) => {
380
465
  if (!(event.target instanceof Element)) return;
381
466
  const header = event.target.closest<HTMLElement>(
382
467
  '[data-accordion-header]'
383
468
  );
384
- if (!header || !this.root?.contains(header)) return;
385
- void this.active(header.dataset.accordionHeader);
469
+ if (!header || !this.dom.root?.contains(header)) return;
470
+ void this.activate(header.dataset.accordionHeader);
386
471
  });
387
472
 
388
- this.cleanup.events.on('keydown', this.root, 'keydown', (event) => {
473
+ this.cleanup.events.on('keydown', this.dom.root, 'keydown', (event) => {
389
474
  if (!(event instanceof KeyboardEvent)) return;
390
475
  if (event.key !== 'Enter' && event.key !== ' ') return;
391
476
  if (!(event.target instanceof Element)) return;
392
477
  const header = event.target.closest<HTMLElement>(
393
478
  '[data-accordion-header]'
394
479
  );
395
- if (!header || !this.root?.contains(header)) return;
480
+ if (!header || !this.dom.root?.contains(header)) return;
396
481
  event.preventDefault();
397
- void this.active(header.dataset.accordionHeader);
482
+ void this.activate(header.dataset.accordionHeader);
398
483
  });
399
484
  }
400
485
 
@@ -406,114 +491,110 @@ export class Accordion extends Component<
406
491
  return this.state.activeNames.includes(name);
407
492
  }
408
493
 
409
- private assertActive(method: string): void {
410
- if (this.runtime.destroyed) {
411
- throw new Error(`Accordion.${method}: instance has been destroyed.`);
494
+ getIndex(val: number | string | undefined | null): number {
495
+ if (typeof val === 'number') return val;
496
+ if (typeof val === 'string') {
497
+ return this.dom.headers.findIndex(
498
+ (header) => header.dataset.accordionHeader === val
499
+ );
412
500
  }
501
+ return -1;
413
502
  }
414
503
 
415
- private async activateItem(
416
- val: number | string | undefined,
417
- fireEvent = true
418
- ): Promise<void> {
419
- const index = this.getIndex(val);
504
+ async activate(value: number | string | undefined): Promise<void> {
505
+ this.assertActive('activate');
506
+ this.assertBuilt('activate');
507
+
508
+ const index = this.getIndex(value);
420
509
  if (index < 0 || index >= this.dom.headers.length) return;
421
510
 
422
511
  const headerEl = this.dom.headers[index];
423
512
  const panelEl = this.dom.panels[index];
424
513
  const name = headerEl.dataset.accordionHeader || String(index);
425
- const active = this.isActive(name);
514
+ const isActive = this.isActive(name);
426
515
 
427
- if (active && !this.props.multiple && !this.props.collapsible) return;
516
+ if (isActive && !this.props.multiple && !this.props.collapsible) return;
428
517
 
429
- let nextNames: string[];
518
+ let activeNames: string[];
430
519
  if (this.props.multiple) {
431
- nextNames = active
432
- ? this.state.activeNames.filter((n) => n !== name)
433
- : [...this.state.activeNames, name];
434
- } else if (active) {
435
- nextNames = this.props.collapsible ? [] : this.state.activeNames;
520
+ if (isActive) {
521
+ const nextNames = this.state.activeNames.filter(
522
+ (item) => item !== name
523
+ );
524
+ activeNames =
525
+ !this.props.collapsible && nextNames.length === 0
526
+ ? this.state.activeNames
527
+ : nextNames;
528
+ } else {
529
+ activeNames = [...this.state.activeNames, name];
530
+ }
531
+ } else if (isActive) {
532
+ activeNames = this.props.collapsible ? [] : this.state.activeNames;
436
533
  } else {
437
- nextNames = [name];
534
+ activeNames = [name];
438
535
  }
439
536
 
440
- this.syncActiveNames(nextNames);
537
+ this.syncActiveNames(activeNames);
441
538
 
442
- if (fireEvent && this.props.onChange) {
539
+ if (this.props.onChange) {
443
540
  await Promise.resolve(
444
541
  this.props.onChange(index, name, headerEl, panelEl, this)
445
542
  );
446
543
  }
447
544
  }
448
545
 
449
- getIndex(val: number | string | undefined | null): number {
450
- if (typeof val === 'number') return val;
451
- if (typeof val === 'string') {
452
- return this.dom.headers.findIndex(
453
- (header) => header.dataset.accordionHeader === val
454
- );
455
- }
456
- return -1;
457
- }
458
-
459
- /**
460
- * 激活指定面板。
461
- * @param {number|string} val 面板索引或名称。
462
- */
463
- async active(val: number | string | undefined): Promise<void> {
464
- this.assertActive('active');
465
- await this.activateItem(val, true);
466
- }
467
-
468
- /**
469
- * 将组件挂载到构造器指定的容器中。
470
- */
471
546
  build(): this {
472
547
  this.assertActive('build');
473
- insert(this.dom.container, () => this.root);
548
+ if (this.runtime.built) return this;
549
+
550
+ this.runtime.built = true;
551
+ try {
552
+ this.init(this.props);
553
+ } catch (error) {
554
+ this.runtime.built = false;
555
+ this.destroy();
556
+ throw error;
557
+ }
558
+
474
559
  return this;
475
560
  }
476
561
 
477
- render(): this {
478
- return this.build();
562
+ protected normalizeStatePatch(
563
+ patch: Partial<AccordionState>
564
+ ): Partial<AccordionState> {
565
+ return {
566
+ ...patch,
567
+ ...(Object.hasOwn(patch, 'items')
568
+ ? { items: normalizeItems(patch.items) }
569
+ : {}),
570
+ };
479
571
  }
480
572
 
481
- /**
482
- * 动态替换全部面板条目。
483
- * @param {AccordionItem[]} items 新面板配置。
484
- * @param {number|string|Array<number|string>|null} [active=0] 替换后默认激活项。
485
- */
486
- setItems(items: AccordionItem[], active: AccordionActive = 0): this {
487
- this.assertActive('setItems');
488
- validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion.setItems');
489
- validateParam(
490
- 'active',
491
- active,
492
- ACCORDION_ACTIVE_RULE,
493
- 'Accordion.setItems'
494
- );
495
-
496
- this.props.items = cloneItems(normalizeItems(items));
497
- this.props.active = active;
573
+ private assertActive(method: string): void {
574
+ if (this.runtime.destroyed) {
575
+ throw new Error(`Accordion.${method}: instance has been destroyed.`);
576
+ }
577
+ }
498
578
 
499
- if (this.root) this.root.textContent = '';
500
- this.buildItems(this.props);
501
- this.syncActiveNames(this.resolveActiveNames(active));
502
- this.bindEvents();
503
- return this;
579
+ private assertBuilt(method: string): void {
580
+ if (!this.runtime.built) {
581
+ throw new Error(`Accordion.${method}: call build() first.`);
582
+ }
504
583
  }
505
584
 
506
585
  protected onDestroy(): void {
507
586
  this.unbindEvents();
587
+ this.itemsDispose?.();
508
588
  this.bindingsDispose?.();
589
+ this.itemsDispose = null;
509
590
  this.bindingsDispose = null;
510
- if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
591
+ if (this.dom.root?.parentNode) {
592
+ this.dom.root.parentNode.removeChild(this.dom.root);
593
+ }
594
+ this.runtime.built = false;
511
595
  }
512
596
  }
513
597
 
514
- export function createAccordion(
515
- container: DOMReference,
516
- input: AccordionProps = {}
517
- ): Accordion {
518
- return new Accordion(container, input);
598
+ export function createAccordion(props: AccordionProps): AccordionInstance {
599
+ return new Accordion(props);
519
600
  }