vanilla-jui 1.4.8 → 1.5.2

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 (58) hide show
  1. package/README.md +10 -4
  2. package/dist/index.d.ts +402 -977
  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 +3 -3
  7. package/src/components/accordion.ts +467 -349
  8. package/src/components/flow.ts +779 -1133
  9. package/src/components/form.ts +666 -509
  10. package/src/components/menu.ts +199 -326
  11. package/src/components/modal.ts +510 -747
  12. package/src/components/offcanvas.ts +281 -361
  13. package/src/components/pagination.ts +207 -374
  14. package/src/components/swiper.ts +688 -1168
  15. package/src/components/tabs.ts +468 -788
  16. package/src/components/toc.ts +191 -244
  17. package/src/core/component.ts +332 -0
  18. package/src/core/motion.ts +298 -0
  19. package/src/core/presence.ts +233 -0
  20. package/src/core/scheduler.ts +91 -0
  21. package/src/core/view.ts +31 -0
  22. package/src/css/accordion.css +94 -3
  23. package/src/css/color.css +4 -5
  24. package/src/css/color_p3.css +6 -5
  25. package/src/css/content.css +18 -24
  26. package/src/css/drop.css +4 -1
  27. package/src/css/form.css +8 -0
  28. package/src/css/gradient.css +64 -131
  29. package/src/css/layout.css +9 -23
  30. package/src/css/menu.css +10 -5
  31. package/src/css/modal.css +2 -1
  32. package/src/css/offcanvas.css +1 -17
  33. package/src/css/swiper.css +2 -1
  34. package/src/css/tabs.css +8 -17
  35. package/src/css/themes/default/accordion.css +0 -48
  36. package/src/css/themes/default/swiper.css +1 -1
  37. package/src/css/themes/default/tabs.css +4 -6
  38. package/src/css/toast.css +1 -17
  39. package/src/css/tokens.css +18 -35
  40. package/src/css/tooltip.css +2 -1
  41. package/src/index.ts +8 -3
  42. package/src/locales/index.ts +3 -0
  43. package/src/primitives/drop.ts +253 -262
  44. package/src/primitives/loading.ts +22 -4
  45. package/src/primitives/parabola.ts +123 -164
  46. package/src/primitives/sticky.ts +297 -0
  47. package/src/primitives/theme.ts +164 -222
  48. package/src/primitives/toast.ts +309 -284
  49. package/src/primitives/tooltip.ts +65 -66
  50. package/src/utilities/browser.ts +38 -0
  51. package/src/utilities/dom.ts +0 -54
  52. package/src/utilities/refs.ts +61 -0
  53. package/src/utilities/state.ts +73 -0
  54. package/src/utilities/timer.ts +11 -31
  55. package/src/validation/validator.ts +289 -389
  56. package/src/components/sticky.ts +0 -297
  57. package/src/core/Component.ts +0 -377
  58. package/src/utilities/scheduler.ts +0 -156
@@ -1,26 +1,26 @@
1
1
  import {
2
- bindAttr,
2
+ For,
3
3
  createDeepStore,
4
- createRoot,
4
+ createEffect,
5
+ createSignal,
5
6
  flushSync,
6
7
  jsx,
8
+ onCleanup,
7
9
  } from 'vanilla-signal';
8
10
 
9
- import Component, {
10
- type ComponentDOM,
11
- type ComponentRuntime,
12
- } from '../core/Component.ts';
13
- import { icon } from '../primitives/icons.ts';
14
11
  import {
15
- type RenderableContent,
16
- normalizeContentNodes,
17
- } from '../utilities/dom.ts';
12
+ type FunctionalComponent,
13
+ defineComponent,
14
+ } from '../core/component.ts';
15
+ import { createLoading } from '../primitives/loading.ts';
16
+ import { icon } from '../primitives/icons.ts';
17
+ import { type RenderableContent } from '../utilities/dom.ts';
18
18
  import { randomId } from '../utilities/id.ts';
19
19
  import {
20
- createStateSync,
21
- stateSnapshot,
22
- trackStoreVersion,
23
- } from '../utilities/scheduler.ts';
20
+ type CollapseMotionController,
21
+ createCollapseTransition,
22
+ } from '../core/motion.ts';
23
+ import { createKeyedElementRefs } from '../utilities/refs.ts';
24
24
  import {
25
25
  type ResolveSchema,
26
26
  resolveProps,
@@ -28,6 +28,12 @@ import {
28
28
  } from '../utilities/types.ts';
29
29
 
30
30
  type AccordionActive = number | string | Array<number | string> | null;
31
+ type AccordionDirection = 'vertical' | 'horizontal';
32
+ type AccordionContent =
33
+ | RenderableContent<AccordionContentContext>
34
+ | ((
35
+ context: AccordionContentContext
36
+ ) => Promise<RenderableContent<AccordionContentContext>>);
31
37
 
32
38
  interface AccordionClassNames {
33
39
  root: string;
@@ -43,7 +49,9 @@ type AccordionClassNameConfig = Partial<AccordionClassNames>;
43
49
  interface AccordionItem extends Record<string, unknown> {
44
50
  name?: string;
45
51
  title: RenderableContent<AccordionContentContext>;
46
- content: RenderableContent<AccordionContentContext>;
52
+ content: AccordionContent;
53
+ cache?: boolean;
54
+ ttl?: number;
47
55
  }
48
56
 
49
57
  interface AccordionProps extends Record<string, unknown> {
@@ -51,8 +59,9 @@ interface AccordionProps extends Record<string, unknown> {
51
59
  active?: AccordionActive;
52
60
  collapsible?: boolean;
53
61
  multiple?: boolean;
62
+ direction?: AccordionDirection;
54
63
  className?: AccordionClassNameConfig;
55
- items: AccordionItem[];
64
+ data: AccordionItem[];
56
65
  onChange?:
57
66
  | ((
58
67
  index: number,
@@ -69,8 +78,9 @@ interface ResolvedAccordionProps extends Record<string, unknown> {
69
78
  active: AccordionActive;
70
79
  collapsible: boolean;
71
80
  multiple: boolean;
81
+ direction: AccordionDirection;
72
82
  className: AccordionClassNames;
73
- items: AccordionItem[];
83
+ data: AccordionItem[];
74
84
  onChange: NonNullable<AccordionProps['onChange']> | null;
75
85
  }
76
86
 
@@ -80,19 +90,9 @@ interface AccordionCurrent {
80
90
  }
81
91
 
82
92
  interface AccordionState extends Record<string, unknown> {
83
- items: AccordionItem[];
93
+ data: AccordionItem[];
84
94
  activeNames: string[];
85
- current: AccordionCurrent;
86
- }
87
-
88
- interface AccordionDOM extends ComponentDOM {
89
- root: HTMLElement | null;
90
- headers: HTMLElement[];
91
- panels: HTMLElement[];
92
- }
93
-
94
- interface AccordionRuntime extends ComponentRuntime {
95
- built: boolean;
95
+ loading: boolean;
96
96
  }
97
97
 
98
98
  interface AccordionContentContext {
@@ -103,24 +103,31 @@ interface AccordionContentContext {
103
103
  active: boolean;
104
104
  }
105
105
 
106
- type AccordionInstance = Component<
106
+ interface AccordionContentCacheEntry {
107
+ content: RenderableContent<AccordionContentContext>;
108
+ updatedAt: number;
109
+ }
110
+
111
+ type AccordionInstance = FunctionalComponent<
107
112
  ResolvedAccordionProps,
108
113
  AccordionState,
109
- AccordionDOM
114
+ HTMLElement,
115
+ AccordionActions
110
116
  > & {
111
- runtime: AccordionRuntime;
112
- state: AccordionState;
113
- build(): AccordionInstance;
117
+ readonly current: AccordionCurrent;
118
+ };
119
+
120
+ interface AccordionActions {
114
121
  isActive(name: string): boolean;
115
122
  getIndex(value: number | string | undefined | null): number;
116
123
  activate(value: number | string | undefined): Promise<void>;
117
- };
124
+ }
118
125
 
119
126
  const DEFAULT_CLASS_NAMES: AccordionClassNames = {
120
127
  root: 'j-accordion',
121
128
  header: 'accordion-header',
122
129
  title: 'header-title',
123
- arrow: 'header-arrow',
130
+ arrow: 'header-arrow j-button is-icon is-ghost is-sm',
124
131
  panel: 'accordion-panel',
125
132
  content: 'panel-content',
126
133
  };
@@ -131,10 +138,12 @@ const ACCORDION_ITEM_RULE = {
131
138
  name: ['string', 'null', 'undefined'],
132
139
  title: 'renderable',
133
140
  content: 'renderable',
141
+ cache: ['boolean', 'undefined'],
142
+ ttl: ['number', 'undefined'],
134
143
  },
135
144
  };
136
145
 
137
- const ACCORDION_ITEMS_RULE = {
146
+ const ACCORDION_DATA_RULE = {
138
147
  type: 'array',
139
148
  nonEmpty: true,
140
149
  items: ACCORDION_ITEM_RULE,
@@ -166,6 +175,11 @@ const ACCORDION_PROPS_SCHEMA = {
166
175
  active: { default: 0, ...ACCORDION_ACTIVE_RULE },
167
176
  collapsible: { default: false, type: 'boolean' },
168
177
  multiple: { default: false, type: 'boolean' },
178
+ direction: {
179
+ default: 'vertical',
180
+ type: 'string',
181
+ enum: ['vertical', 'horizontal'],
182
+ },
169
183
  className: {
170
184
  default: DEFAULT_CLASS_NAMES,
171
185
  type: 'object',
@@ -175,7 +189,7 @@ const ACCORDION_PROPS_SCHEMA = {
175
189
  }),
176
190
  },
177
191
  onChange: { default: null, types: ['function', 'null'] },
178
- items: { default: [], ...ACCORDION_ITEMS_RULE },
192
+ data: { default: [], ...ACCORDION_DATA_RULE },
179
193
  } satisfies ResolveSchema<AccordionProps>;
180
194
 
181
195
  function isActiveValue(value: unknown): value is number | string {
@@ -194,46 +208,49 @@ function normalizeProps(props: AccordionProps): ResolvedAccordionProps {
194
208
  active: resolved.active as AccordionActive,
195
209
  collapsible: resolved.collapsible as boolean,
196
210
  multiple: resolved.multiple as boolean,
211
+ direction: resolved.direction as AccordionDirection,
197
212
  className: resolved.className as AccordionClassNames,
198
- items: normalizeItems(resolved.items, false),
213
+ data: normalizeData(resolved.data, false),
199
214
  onChange: resolved.onChange as ResolvedAccordionProps['onChange'],
200
215
  };
201
216
  }
202
217
 
203
- function normalizeItems(items: unknown, validate = true): AccordionItem[] {
218
+ function normalizeAccordionItem(
219
+ item: AccordionItem,
220
+ index: number,
221
+ validate = true
222
+ ): AccordionItem {
223
+ if (validate)
224
+ validateParam(String(index), item, ACCORDION_ITEM_RULE, 'Accordion.data');
225
+ const source = item as AccordionItem;
226
+ const name = typeof source.name === 'string' ? source.name.trim() : '';
227
+ return {
228
+ ...source,
229
+ name: name || randomId(),
230
+ };
231
+ }
232
+
233
+ function normalizeData(data: unknown, validate = true): AccordionItem[] {
204
234
  if (validate) {
205
- validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion');
235
+ validateParam('data', data, ACCORDION_DATA_RULE, 'Accordion');
206
236
  }
207
237
 
208
238
  const names = new Set<string>();
209
239
 
210
- return (items as unknown[]).map((item) => {
211
- const source = item as AccordionItem;
212
- const name = typeof source.name === 'string' ? source.name.trim() : '';
213
- if (name) {
214
- if (names.has(name)) {
215
- throw new Error(`Accordion: item name "${name}" must be unique.`);
216
- }
217
- names.add(name);
240
+ return (data as unknown[]).map((item, index) => {
241
+ const next = normalizeAccordionItem(item as AccordionItem, index, false);
242
+ const name = next.name as string;
243
+ if (names.has(name)) {
244
+ throw new Error(`Accordion: item name "${name}" must be unique.`);
218
245
  }
219
-
220
- return {
221
- ...source,
222
- name: name || randomId(),
223
- };
246
+ names.add(name);
247
+ return next;
224
248
  });
225
249
  }
226
250
 
227
- function needsItemsSync(
228
- source: AccordionItem[],
229
- normalized: AccordionItem[]
230
- ): boolean {
231
- return normalized.some((item, index) => source[index]?.name !== item.name);
232
- }
233
-
234
251
  function resolveActiveItemNames(
235
252
  active: AccordionActive,
236
- items: AccordionItem[],
253
+ data: AccordionItem[],
237
254
  multiple: boolean
238
255
  ): string[] {
239
256
  if (active == null) return [];
@@ -245,8 +262,8 @@ function resolveActiveItemNames(
245
262
  const index =
246
263
  typeof value === 'number'
247
264
  ? value
248
- : items.findIndex((item) => item.name === value);
249
- const name = items[index]?.name;
265
+ : data.findIndex((item) => item.name === value);
266
+ const name = data[index]?.name;
250
267
 
251
268
  if (!name) continue;
252
269
 
@@ -259,344 +276,445 @@ function resolveActiveItemNames(
259
276
 
260
277
  function reconcileActiveNames(
261
278
  activeNames: string[],
262
- items: AccordionItem[],
279
+ data: AccordionItem[],
263
280
  multiple: boolean
264
281
  ): string[] {
265
- const names = new Set(items.map((item) => item.name).filter(Boolean));
282
+ const names = new Set(data.map((item) => item.name).filter(Boolean));
266
283
  const nextNames = activeNames.filter((name) => names.has(name));
267
284
  return multiple ? nextNames : nextNames.slice(0, 1);
268
285
  }
269
286
 
270
287
  function createCurrentState(
271
288
  activeNames: string[],
272
- items: AccordionItem[]
289
+ data: AccordionItem[]
273
290
  ): AccordionCurrent {
274
291
  const name = activeNames[0] || null;
275
292
  if (!name) return { index: null, name: null };
276
293
 
277
- const index = items.findIndex((item) => item.name === name);
294
+ const index = data.findIndex((item) => item.name === name);
278
295
  return index >= 0 ? { index, name } : { index: null, name: null };
279
296
  }
280
297
 
281
- /**
282
- * 轻量手风琴组件,继承 Component。
283
- *
284
- * 构造器只验证和保存初始配置;调用 build() 后才创建 DOM 和绑定交互。
285
- * items 属于响应式 state,变更后自动重建面板结构。
286
- */
287
- class Accordion extends Component<
288
- ResolvedAccordionProps,
289
- AccordionState,
290
- AccordionDOM
291
- > {
292
- declare runtime: AccordionRuntime;
293
- declare state: AccordionState;
294
- private bindingsDispose: (() => void) | null;
295
- private itemsDispose: (() => void) | null;
296
-
297
- constructor(props: AccordionProps) {
298
- const resolvedProps = normalizeProps(props);
299
- super(resolvedProps);
300
-
301
- const activeNames = resolveActiveItemNames(
302
- resolvedProps.active,
303
- resolvedProps.items,
304
- resolvedProps.multiple
298
+ function normalizeTtl(ttl: unknown): number {
299
+ return typeof ttl === 'number' && ttl > 0 ? ttl : 0;
300
+ }
301
+
302
+ export function createAccordion(props: AccordionProps): AccordionInstance {
303
+ const resolvedProps = normalizeProps(props);
304
+ const activeNames = resolveActiveItemNames(
305
+ resolvedProps.active,
306
+ resolvedProps.data,
307
+ resolvedProps.multiple
308
+ );
309
+ const state = createDeepStore({
310
+ data: resolvedProps.data,
311
+ activeNames,
312
+ loading: false,
313
+ }) as AccordionState;
314
+ const generatedNames = new WeakMap<object, string>();
315
+ const headers = createKeyedElementRefs<string, HTMLElement>();
316
+ const panels = createKeyedElementRefs<string, HTMLElement>();
317
+ const panelMotions = new Map<string, CollapseMotionController>();
318
+ const contentCache = new Map<string, AccordionContentCacheEntry>();
319
+ const contentLoaders = new Map<string, () => Promise<void>>();
320
+ let motionActiveNames = new Set(activeNames);
321
+ let motionPromise = Promise.resolve();
322
+ let contentLoadId = 0;
323
+ let loadingCount = 0;
324
+ let loading = false;
325
+ let accordion: AccordionInstance;
326
+
327
+ const itemName = (item: AccordionItem): string => {
328
+ const explicit = typeof item.name === 'string' ? item.name.trim() : '';
329
+ if (explicit) return explicit;
330
+ if (!generatedNames.has(item)) generatedNames.set(item, randomId());
331
+ return generatedNames.get(item) as string;
332
+ };
333
+ const current = (): AccordionCurrent => {
334
+ const data = state.data.map((item) => ({
335
+ ...item,
336
+ name: itemName(item),
337
+ }));
338
+ return createCurrentState(
339
+ reconcileActiveNames(state.activeNames, data, resolvedProps.multiple),
340
+ data
305
341
  );
342
+ };
306
343
 
307
- this.dom.headers = [];
308
- this.dom.panels = [];
309
- this.bindingsDispose = null;
310
- this.itemsDispose = null;
311
- this.runtime.built = false;
312
- this.state = createDeepStore({
313
- items: resolvedProps.items,
314
- activeNames,
315
- current: createCurrentState(activeNames, resolvedProps.items),
316
- }) as AccordionState;
317
- }
344
+ const isActive = (name: string): boolean => state.activeNames.includes(name);
318
345
 
319
- protected onInit(props: ResolvedAccordionProps): void {
320
- this.dom.root = this.buildRoot(props);
321
- this.bindItems();
322
- this.bindEvents();
323
- }
346
+ const beginLoading = (): void => {
347
+ loadingCount += 1;
348
+ if (!loading && !accordion.runtime.destroyed) {
349
+ loading = true;
350
+ flushSync(() => {
351
+ state.loading = true;
352
+ });
353
+ }
354
+ };
324
355
 
325
- private buildRoot(props: ResolvedAccordionProps): HTMLElement {
326
- return jsx('div', {
327
- className: props.className.root,
328
- id: props.id,
329
- 'data-accordion': 'root',
330
- }) as HTMLElement;
331
- }
356
+ const endLoading = (): void => {
357
+ loadingCount = Math.max(0, loadingCount - 1);
358
+ if (loadingCount === 0 && loading && !accordion.runtime.destroyed) {
359
+ loading = false;
360
+ flushSync(() => {
361
+ state.loading = false;
362
+ });
363
+ }
364
+ };
332
365
 
333
- private bindItems(): void {
334
- this.itemsDispose?.();
335
- this.syncItems(this.state.items);
336
- this.itemsDispose = createStateSync(
337
- () => trackStoreVersion(this.state.items),
338
- (items) => this.syncItems(items)
339
- );
340
- }
366
+ const getIndex = (value: number | string | undefined | null): number => {
367
+ if (typeof value === 'number') return value;
368
+ if (typeof value !== 'string') return -1;
369
+ return state.data.findIndex((item) => itemName(item) === value);
370
+ };
371
+
372
+ const transitionPanels = (activeNames: readonly string[]): Promise<void> => {
373
+ const nextActiveNames = new Set(activeNames);
374
+ const names = new Set([...motionActiveNames, ...nextActiveNames]);
375
+ const transitions: Promise<void>[] = [];
341
376
 
342
- private syncItems(sourceItems: AccordionItem[]): void {
343
- const items = normalizeItems(stateSnapshot(sourceItems));
377
+ for (const name of names) {
378
+ const wasActive = motionActiveNames.has(name);
379
+ const active = nextActiveNames.has(name);
380
+ if (wasActive === active) continue;
344
381
 
345
- if (needsItemsSync(sourceItems, items)) {
346
- this.state.items = items;
382
+ const panel = panels.get(name);
383
+ if (panel) panel.inert = !active;
384
+ const motion = panelMotions.get(name);
385
+ if (motion) transitions.push(active ? motion.enter() : motion.leave());
347
386
  }
348
387
 
349
- const activeNames = reconcileActiveNames(
350
- this.state.activeNames,
351
- items,
352
- this.props.multiple
353
- );
354
- this.buildItems(items, activeNames);
355
- this.syncActiveNames(activeNames, items);
356
- }
388
+ motionActiveNames = nextActiveNames;
389
+ if (transitions.length > 0) {
390
+ motionPromise = Promise.all(transitions).then(() => undefined);
391
+ }
392
+ return motionPromise;
393
+ };
394
+
395
+ const bindPanel =
396
+ (name: string) =>
397
+ (panel: HTMLElement): void => {
398
+ panels.bind(name)(panel);
399
+ panelMotions.get(name)?.cancel();
400
+ const motion = createCollapseTransition(() => panels.get(name), {
401
+ axis: resolvedProps.direction,
402
+ options: { duration: 250, easing: 'ease' },
403
+ });
404
+ panelMotions.set(name, motion);
405
+ const active = isActive(name);
406
+ panel.inert = !active;
407
+ motion.setExpanded(active);
408
+ onCleanup(() => {
409
+ if (panelMotions.get(name) !== motion) return;
410
+ motion.cancel();
411
+ panelMotions.delete(name);
412
+ motionActiveNames.delete(name);
413
+ if (state.activeNames.includes(name)) {
414
+ flushSync(() => {
415
+ state.activeNames = state.activeNames.filter(
416
+ (activeName) => activeName !== name
417
+ );
418
+ });
419
+ }
420
+ });
421
+ };
357
422
 
358
- private buildItems(items: AccordionItem[], activeNames: string[]): void {
359
- if (!this.dom.root) return;
423
+ const activate = async (
424
+ value: number | string | undefined
425
+ ): Promise<void> => {
426
+ if (accordion.runtime.destroyed) {
427
+ throw new Error('Accordion.activate: instance destroyed');
428
+ }
429
+ if (!accordion.runtime.built) {
430
+ throw new Error('Accordion.activate: call build() first.');
431
+ }
432
+
433
+ const index = getIndex(value);
434
+ const item = state.data[index];
435
+ if (!item) return;
436
+ const name = itemName(item);
437
+ const active = isActive(name);
438
+ if (active && !resolvedProps.multiple && !resolvedProps.collapsible) return;
439
+
440
+ let next: string[];
441
+ if (resolvedProps.multiple) {
442
+ if (active) {
443
+ const remaining = state.activeNames.filter(
444
+ (itemName) => itemName !== name
445
+ );
446
+ next =
447
+ !resolvedProps.collapsible && remaining.length === 0
448
+ ? [...state.activeNames]
449
+ : remaining;
450
+ } else {
451
+ next = [...state.activeNames, name];
452
+ }
453
+ } else if (active) {
454
+ next = resolvedProps.collapsible ? [] : [...state.activeNames];
455
+ } else {
456
+ next = [name];
457
+ }
458
+
459
+ flushSync(() => {
460
+ state.activeNames = next;
461
+ });
462
+ const transitioned = transitionPanels(next);
463
+ const loaded = next.includes(name)
464
+ ? contentLoaders.get(name)?.() || Promise.resolve()
465
+ : Promise.resolve();
466
+
467
+ const header = headers.get(name);
468
+ const panel = panels.get(name);
469
+ const changed =
470
+ resolvedProps.onChange && header && panel
471
+ ? loaded.then(() =>
472
+ resolvedProps.onChange?.(index, name, header, panel, accordion)
473
+ )
474
+ : Promise.resolve();
475
+ await Promise.all([transitioned, changed]);
476
+ };
477
+
478
+ const contentContext = (
479
+ item: AccordionItem,
480
+ index: number,
481
+ type: 'title' | 'content'
482
+ ): AccordionContentContext => {
483
+ const name = itemName(item);
484
+ return {
485
+ accordion,
486
+ item,
487
+ index,
488
+ type,
489
+ active: isActive(name),
490
+ };
491
+ };
492
+
493
+ const titleView = (
494
+ content: RenderableContent<AccordionContentContext>,
495
+ item: AccordionItem,
496
+ index: number
497
+ ): RenderableContent<AccordionContentContext> => {
498
+ if (typeof content !== 'function') return content;
499
+ return content(contentContext(item, index, 'title'));
500
+ };
501
+
502
+ const createContentRenderer = (
503
+ itemAccessor: () => AccordionItem,
504
+ indexAccessor: () => number,
505
+ name: string
506
+ ) => {
507
+ const initial = itemAccessor().content;
508
+ const [content, setContent] = createSignal<
509
+ RenderableContent<AccordionContentContext>
510
+ >(typeof initial === 'function' ? null : initial);
511
+ const [loading, setLoading] = createSignal(false);
512
+ let source: AccordionContent = initial;
513
+ let localLoadId = 0;
514
+ let pending: Promise<void> | null = null;
515
+
516
+ const context = (): AccordionContentContext =>
517
+ contentContext(itemAccessor(), indexAccessor(), 'content');
518
+
519
+ const load = async (): Promise<void> => {
520
+ const current = itemAccessor();
521
+ const source = current.content;
522
+ if (typeof source !== 'function') {
523
+ setContent(source);
524
+ return;
525
+ }
526
+ const cached = current.cache ? contentCache.get(name) : null;
527
+ const ttlValue = normalizeTtl(current.ttl);
528
+ if (cached && (!ttlValue || Date.now() - cached.updatedAt <= ttlValue)) {
529
+ setContent(cached.content);
530
+ return;
531
+ }
532
+ if (pending) return pending;
533
+ const request = ++localLoadId;
534
+ contentLoadId += 1;
535
+ const activeLoad = contentLoadId;
536
+ setLoading(true);
537
+ beginLoading();
538
+ const task = Promise.resolve()
539
+ .then(() =>
540
+ (source as (context: AccordionContentContext) => unknown)(context())
541
+ )
542
+ .then((result) => {
543
+ if (request !== localLoadId || accordion.runtime.destroyed) return;
544
+ if (itemAccessor().cache) {
545
+ contentCache.set(name, {
546
+ content: result as RenderableContent<AccordionContentContext>,
547
+ updatedAt: Date.now(),
548
+ });
549
+ }
550
+ setContent(result as RenderableContent<AccordionContentContext>);
551
+ })
552
+ .finally(() => {
553
+ if (request === localLoadId && !accordion.runtime.destroyed) {
554
+ setLoading(false);
555
+ }
556
+ if (activeLoad <= contentLoadId) endLoading();
557
+ if (pending === task) pending = null;
558
+ });
559
+ pending = task;
560
+ return task;
561
+ };
562
+ contentLoaders.set(name, load);
563
+ onCleanup(() => {
564
+ localLoadId += 1;
565
+ contentCache.delete(name);
566
+ contentLoaders.delete(name);
567
+ pending = null;
568
+ });
569
+
570
+ createEffect(() => {
571
+ const nextContent = itemAccessor().content;
572
+ if (nextContent !== source) {
573
+ source = nextContent;
574
+ localLoadId += 1;
575
+ pending = null;
576
+ contentCache.delete(name);
577
+ setContent(typeof nextContent === 'function' ? null : nextContent);
578
+ }
579
+ if (isActive(name)) void load();
580
+ });
360
581
 
361
- const fragment = document.createDocumentFragment();
362
- this.bindingsDispose?.();
363
- this.bindingsDispose = null;
364
- this.dom.root.textContent = '';
365
- this.dom.headers = [];
366
- this.dom.panels = [];
582
+ return {
583
+ loading,
584
+ isAsync: () => typeof itemAccessor().content === 'function',
585
+ render: (): RenderableContent<AccordionContentContext> => {
586
+ if (loading()) return createLoading('flex-start', 'flex-start');
587
+ const value = content();
588
+ if (typeof value === 'function') return value(context());
589
+ return value;
590
+ },
591
+ };
592
+ };
367
593
 
368
- items.forEach((item, index) => {
369
- const name = item.name || randomId();
370
- const active = activeNames.includes(name);
371
- const headerId = `${this.props.id}_header_${index}`;
372
- const panelId = `${this.props.id}_panel_${index}`;
594
+ const itemView = (
595
+ itemAccessor: () => AccordionItem,
596
+ indexAccessor: () => number
597
+ ): Node[] => {
598
+ const name = itemName(itemAccessor());
599
+ const content = createContentRenderer(itemAccessor, indexAccessor, name);
600
+ const headerId = `${resolvedProps.id}_header_${name}`;
601
+ const panelId = `${resolvedProps.id}_panel_${name}`;
602
+ const handleKeyDown = (event: KeyboardEvent): void => {
603
+ if (event.key !== 'Enter' && event.key !== ' ') return;
604
+ event.preventDefault();
605
+ void activate(name);
606
+ };
373
607
 
374
- const header = jsx('div', {
375
- className: this.props.className.header,
608
+ return [
609
+ jsx('div', {
610
+ className: resolvedProps.className.header,
376
611
  id: headerId,
377
612
  'data-accordion-header': name,
378
613
  role: 'button',
379
614
  tabindex: '0',
380
615
  'aria-controls': panelId,
616
+ 'aria-expanded': () => (isActive(name) ? 'true' : 'false'),
617
+ ref: headers.bind(name),
618
+ onClick: () => void activate(name),
619
+ onKeyDown: handleKeyDown,
381
620
  children: [
382
621
  jsx('span', {
383
- className: this.props.className.title,
622
+ className: resolvedProps.className.title,
384
623
  'data-accordion-title': name,
385
624
  role: 'heading',
386
- children: this.contentView(item, index, 'title', active),
625
+ children: () => {
626
+ const item = itemAccessor();
627
+ return titleView(item.title, item, indexAccessor());
628
+ },
387
629
  }),
388
630
  jsx('span', {
389
- className: this.props.className.arrow,
631
+ className: resolvedProps.className.arrow,
390
632
  'data-accordion-arrow': name,
391
633
  'aria-hidden': 'true',
392
634
  children: icon('arrow-down'),
393
635
  }),
394
636
  ],
395
- }) as HTMLElement;
396
-
397
- const panel = jsx('div', {
398
- className: this.props.className.panel,
637
+ }),
638
+ jsx('div', {
639
+ className: resolvedProps.className.panel,
399
640
  'data-accordion-panel': name,
400
641
  id: panelId,
401
642
  role: 'region',
402
643
  'aria-labelledby': headerId,
644
+ 'aria-hidden': () => (isActive(name) ? 'false' : 'true'),
645
+ 'aria-live': () => (content.isAsync() ? 'polite' : null),
646
+ 'aria-busy': () => String(content.loading()),
647
+ 'data-state': () => (isActive(name) ? 'open' : 'closed'),
648
+ ref: bindPanel(name),
403
649
  children: jsx('div', {
404
- className: this.props.className.content,
650
+ className: resolvedProps.className.content,
405
651
  'data-accordion-content': name,
406
- children: this.contentView(item, index, 'content', active),
652
+ children: content.render,
407
653
  }),
408
- }) as HTMLElement;
409
-
410
- this.dom.headers.push(header);
411
- this.dom.panels.push(panel);
412
- fragment.append(header, panel);
413
- });
414
-
415
- this.dom.root.append(fragment);
416
- this.bindItemState();
417
- }
418
-
419
- private bindItemState(): void {
420
- this.bindingsDispose = createRoot((dispose) => {
421
- this.dom.headers.forEach((header) => {
422
- const name = header.dataset.accordionHeader || '';
423
- bindAttr(header, 'aria-expanded', () =>
424
- this.state.activeNames.includes(name) ? 'true' : 'false'
425
- );
426
- });
654
+ }),
655
+ ];
656
+ };
427
657
 
428
- this.dom.panels.forEach((panel, index) => {
429
- const name = this.dom.headers[index]?.dataset.accordionHeader || '';
430
- bindAttr(panel, 'aria-hidden', () =>
431
- this.state.activeNames.includes(name) ? 'false' : 'true'
432
- );
433
- bindAttr(panel, 'hidden', () => !this.state.activeNames.includes(name));
658
+ accordion = defineComponent<
659
+ ResolvedAccordionProps,
660
+ AccordionState,
661
+ HTMLElement,
662
+ AccordionActions
663
+ >({
664
+ name: 'Accordion',
665
+ props: resolvedProps,
666
+ state,
667
+ actions: {
668
+ isActive,
669
+ getIndex,
670
+ activate,
671
+ },
672
+ normalizeStatePatch(patch) {
673
+ return {
674
+ ...patch,
675
+ ...(Object.hasOwn(patch, 'data')
676
+ ? { data: normalizeData(patch.data) }
677
+ : {}),
678
+ };
679
+ },
680
+ view: () => {
681
+ const root = jsx('div', {
682
+ className: resolvedProps.className.root,
683
+ id: resolvedProps.id,
684
+ 'data-accordion': 'root',
685
+ 'data-direction': resolvedProps.direction,
686
+ children: For({
687
+ each: () => state.data,
688
+ key: (item: AccordionItem) => itemName(item),
689
+ children: (
690
+ itemAccessor: () => AccordionItem,
691
+ indexAccessor: () => number
692
+ ) => itemView(itemAccessor, indexAccessor),
693
+ }),
694
+ }) as HTMLElement;
695
+ motionActiveNames = new Set(state.activeNames);
696
+ createEffect(() => {
697
+ void transitionPanels([...state.activeNames]);
434
698
  });
699
+ return root;
700
+ },
701
+ onDestroy() {
702
+ contentLoadId += 1;
703
+ loadingCount = 0;
704
+ loading = false;
705
+ contentCache.clear();
706
+ contentLoaders.clear();
707
+ for (const motion of panelMotions.values()) motion.cancel();
708
+ panelMotions.clear();
709
+ headers.clear();
710
+ panels.clear();
711
+ },
712
+ }) as AccordionInstance;
435
713
 
436
- return dispose;
437
- });
438
- }
439
-
440
- private contentView(
441
- item: AccordionItem,
442
- index: number,
443
- type: 'title' | 'content',
444
- active: boolean
445
- ): Node[] {
446
- return normalizeContentNodes(type === 'title' ? item.title : item.content, {
447
- accordion: this,
448
- item,
449
- index,
450
- type,
451
- active,
452
- });
453
- }
454
-
455
- private syncActiveNames(names: string[], items = this.state.items): void {
456
- flushSync(() => {
457
- this.state.activeNames = names;
458
- this.state.current = createCurrentState(names, items);
459
- });
460
- }
461
-
462
- private bindEvents(): void {
463
- this.unbindEvents();
464
- if (!this.dom.root) return;
465
-
466
- this.cleanup.events.on('click', this.dom.root, 'click', (event) => {
467
- if (!(event.target instanceof Element)) return;
468
- const header = event.target.closest<HTMLElement>(
469
- '[data-accordion-header]'
470
- );
471
- if (!header || !this.dom.root?.contains(header)) return;
472
- void this.activate(header.dataset.accordionHeader);
473
- });
474
-
475
- this.cleanup.events.on('keydown', this.dom.root, 'keydown', (event) => {
476
- if (!(event instanceof KeyboardEvent)) return;
477
- if (event.key !== 'Enter' && event.key !== ' ') return;
478
- if (!(event.target instanceof Element)) return;
479
- const header = event.target.closest<HTMLElement>(
480
- '[data-accordion-header]'
481
- );
482
- if (!header || !this.dom.root?.contains(header)) return;
483
- event.preventDefault();
484
- void this.activate(header.dataset.accordionHeader);
485
- });
486
- }
487
-
488
- private unbindEvents(): void {
489
- this.cleanup.events.clear();
490
- }
491
-
492
- isActive(name: string): boolean {
493
- return this.state.activeNames.includes(name);
494
- }
495
-
496
- getIndex(val: number | string | undefined | null): number {
497
- if (typeof val === 'number') return val;
498
- if (typeof val === 'string') {
499
- return this.dom.headers.findIndex(
500
- (header) => header.dataset.accordionHeader === val
501
- );
502
- }
503
- return -1;
504
- }
505
-
506
- async activate(value: number | string | undefined): Promise<void> {
507
- this.assertActive('activate');
508
- this.assertBuilt('activate');
509
-
510
- const index = this.getIndex(value);
511
- if (index < 0 || index >= this.dom.headers.length) return;
512
-
513
- const headerEl = this.dom.headers[index];
514
- const panelEl = this.dom.panels[index];
515
- const name = headerEl.dataset.accordionHeader || String(index);
516
- const isActive = this.isActive(name);
517
-
518
- if (isActive && !this.props.multiple && !this.props.collapsible) return;
519
-
520
- let activeNames: string[];
521
- if (this.props.multiple) {
522
- if (isActive) {
523
- const nextNames = this.state.activeNames.filter(
524
- (item) => item !== name
525
- );
526
- activeNames =
527
- !this.props.collapsible && nextNames.length === 0
528
- ? this.state.activeNames
529
- : nextNames;
530
- } else {
531
- activeNames = [...this.state.activeNames, name];
532
- }
533
- } else if (isActive) {
534
- activeNames = this.props.collapsible ? [] : this.state.activeNames;
535
- } else {
536
- activeNames = [name];
537
- }
538
-
539
- this.syncActiveNames(activeNames);
540
-
541
- if (this.props.onChange) {
542
- await Promise.resolve(
543
- this.props.onChange(index, name, headerEl, panelEl, this)
544
- );
545
- }
546
- }
547
-
548
- build(): this {
549
- this.assertActive('build');
550
- if (this.runtime.built) return this;
551
-
552
- this.runtime.built = true;
553
- try {
554
- this.init(this.props);
555
- } catch (error) {
556
- this.runtime.built = false;
557
- this.destroy();
558
- throw error;
559
- }
560
-
561
- return this;
562
- }
563
-
564
- protected normalizeStatePatch(
565
- patch: Partial<AccordionState>
566
- ): Partial<AccordionState> {
567
- return {
568
- ...patch,
569
- ...(Object.hasOwn(patch, 'items')
570
- ? { items: normalizeItems(patch.items) }
571
- : {}),
572
- };
573
- }
574
-
575
- private assertActive(method: string): void {
576
- if (this.runtime.destroyed) {
577
- throw new Error(`Accordion.${method}: instance has been destroyed.`);
578
- }
579
- }
580
-
581
- private assertBuilt(method: string): void {
582
- if (!this.runtime.built) {
583
- throw new Error(`Accordion.${method}: call build() first.`);
584
- }
585
- }
586
-
587
- protected onDestroy(): void {
588
- this.unbindEvents();
589
- this.itemsDispose?.();
590
- this.bindingsDispose?.();
591
- this.itemsDispose = null;
592
- this.bindingsDispose = null;
593
- if (this.dom.root?.parentNode) {
594
- this.dom.root.parentNode.removeChild(this.dom.root);
595
- }
596
- this.runtime.built = false;
597
- }
598
- }
714
+ Object.defineProperty(accordion, 'current', {
715
+ enumerable: true,
716
+ get: current,
717
+ });
599
718
 
600
- export function createAccordion(props: AccordionProps): AccordionInstance {
601
- return new Accordion(props);
719
+ return accordion;
602
720
  }