vanilla-jui 1.4.7 → 1.5.1

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 (52) hide show
  1. package/README.md +10 -4
  2. package/dist/index.d.ts +418 -970
  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 +466 -346
  8. package/src/components/flow.ts +779 -1133
  9. package/src/components/form.ts +666 -509
  10. package/src/components/menu.ts +198 -328
  11. package/src/components/modal.ts +510 -747
  12. package/src/components/offcanvas.ts +273 -367
  13. package/src/components/pagination.ts +201 -375
  14. package/src/components/swiper.ts +689 -1107
  15. package/src/components/tabs.ts +467 -778
  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/content.css +18 -24
  24. package/src/css/drop.css +4 -1
  25. package/src/css/form.css +8 -0
  26. package/src/css/menu.css +10 -5
  27. package/src/css/modal.css +2 -1
  28. package/src/css/offcanvas.css +5 -17
  29. package/src/css/swiper.css +2 -1
  30. package/src/css/tabs.css +8 -17
  31. package/src/css/themes/default/accordion.css +0 -48
  32. package/src/css/themes/default/swiper.css +1 -1
  33. package/src/css/themes/default/tabs.css +4 -6
  34. package/src/css/toast.css +1 -17
  35. package/src/css/tooltip.css +2 -1
  36. package/src/index.ts +8 -2
  37. package/src/locales/index.ts +3 -0
  38. package/src/primitives/drop.ts +253 -262
  39. package/src/primitives/loading.ts +22 -4
  40. package/src/primitives/parabola.ts +123 -164
  41. package/src/primitives/sticky.ts +297 -0
  42. package/src/primitives/theme.ts +164 -222
  43. package/src/primitives/toast.ts +309 -284
  44. package/src/primitives/tooltip.ts +65 -66
  45. package/src/utilities/browser.ts +38 -0
  46. package/src/utilities/dom.ts +0 -54
  47. package/src/utilities/refs.ts +61 -0
  48. package/src/utilities/state.ts +73 -0
  49. package/src/utilities/timer.ts +11 -31
  50. package/src/validation/validator.ts +289 -389
  51. package/src/components/sticky.ts +0 -297
  52. package/src/core/Component.ts +0 -377
@@ -1,23 +1,26 @@
1
1
  import {
2
- bindAttr,
2
+ For,
3
3
  createDeepStore,
4
4
  createEffect,
5
- createRoot,
5
+ createSignal,
6
6
  flushSync,
7
7
  jsx,
8
- untrack,
8
+ onCleanup,
9
9
  } from 'vanilla-signal';
10
10
 
11
- import Component, {
12
- type ComponentDOM,
13
- type ComponentRuntime,
14
- } from '../core/Component.ts';
15
- import { icon } from '../primitives/icons.ts';
16
11
  import {
17
- type RenderableContent,
18
- normalizeContentNodes,
19
- } 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';
20
18
  import { randomId } from '../utilities/id.ts';
19
+ import {
20
+ type CollapseMotionController,
21
+ createCollapseTransition,
22
+ } from '../core/motion.ts';
23
+ import { createKeyedElementRefs } from '../utilities/refs.ts';
21
24
  import {
22
25
  type ResolveSchema,
23
26
  resolveProps,
@@ -25,6 +28,12 @@ import {
25
28
  } from '../utilities/types.ts';
26
29
 
27
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>>);
28
37
 
29
38
  interface AccordionClassNames {
30
39
  root: string;
@@ -40,7 +49,9 @@ type AccordionClassNameConfig = Partial<AccordionClassNames>;
40
49
  interface AccordionItem extends Record<string, unknown> {
41
50
  name?: string;
42
51
  title: RenderableContent<AccordionContentContext>;
43
- content: RenderableContent<AccordionContentContext>;
52
+ content: AccordionContent;
53
+ cache?: boolean;
54
+ ttl?: number;
44
55
  }
45
56
 
46
57
  interface AccordionProps extends Record<string, unknown> {
@@ -48,8 +59,9 @@ interface AccordionProps extends Record<string, unknown> {
48
59
  active?: AccordionActive;
49
60
  collapsible?: boolean;
50
61
  multiple?: boolean;
62
+ direction?: AccordionDirection;
51
63
  className?: AccordionClassNameConfig;
52
- items: AccordionItem[];
64
+ data: AccordionItem[];
53
65
  onChange?:
54
66
  | ((
55
67
  index: number,
@@ -66,8 +78,9 @@ interface ResolvedAccordionProps extends Record<string, unknown> {
66
78
  active: AccordionActive;
67
79
  collapsible: boolean;
68
80
  multiple: boolean;
81
+ direction: AccordionDirection;
69
82
  className: AccordionClassNames;
70
- items: AccordionItem[];
83
+ data: AccordionItem[];
71
84
  onChange: NonNullable<AccordionProps['onChange']> | null;
72
85
  }
73
86
 
@@ -77,19 +90,9 @@ interface AccordionCurrent {
77
90
  }
78
91
 
79
92
  interface AccordionState extends Record<string, unknown> {
80
- items: AccordionItem[];
93
+ data: AccordionItem[];
81
94
  activeNames: string[];
82
- current: AccordionCurrent;
83
- }
84
-
85
- interface AccordionDOM extends ComponentDOM {
86
- root: HTMLElement | null;
87
- headers: HTMLElement[];
88
- panels: HTMLElement[];
89
- }
90
-
91
- interface AccordionRuntime extends ComponentRuntime {
92
- built: boolean;
95
+ loading: boolean;
93
96
  }
94
97
 
95
98
  interface AccordionContentContext {
@@ -100,24 +103,31 @@ interface AccordionContentContext {
100
103
  active: boolean;
101
104
  }
102
105
 
103
- type AccordionInstance = Component<
106
+ interface AccordionContentCacheEntry {
107
+ content: RenderableContent<AccordionContentContext>;
108
+ updatedAt: number;
109
+ }
110
+
111
+ type AccordionInstance = FunctionalComponent<
104
112
  ResolvedAccordionProps,
105
113
  AccordionState,
106
- AccordionDOM
114
+ HTMLElement,
115
+ AccordionActions
107
116
  > & {
108
- runtime: AccordionRuntime;
109
- state: AccordionState;
110
- build(): AccordionInstance;
117
+ readonly current: AccordionCurrent;
118
+ };
119
+
120
+ interface AccordionActions {
111
121
  isActive(name: string): boolean;
112
122
  getIndex(value: number | string | undefined | null): number;
113
123
  activate(value: number | string | undefined): Promise<void>;
114
- };
124
+ }
115
125
 
116
126
  const DEFAULT_CLASS_NAMES: AccordionClassNames = {
117
127
  root: 'j-accordion',
118
128
  header: 'accordion-header',
119
129
  title: 'header-title',
120
- arrow: 'header-arrow',
130
+ arrow: 'header-arrow j-button is-icon is-ghost is-sm',
121
131
  panel: 'accordion-panel',
122
132
  content: 'panel-content',
123
133
  };
@@ -128,10 +138,12 @@ const ACCORDION_ITEM_RULE = {
128
138
  name: ['string', 'null', 'undefined'],
129
139
  title: 'renderable',
130
140
  content: 'renderable',
141
+ cache: ['boolean', 'undefined'],
142
+ ttl: ['number', 'undefined'],
131
143
  },
132
144
  };
133
145
 
134
- const ACCORDION_ITEMS_RULE = {
146
+ const ACCORDION_DATA_RULE = {
135
147
  type: 'array',
136
148
  nonEmpty: true,
137
149
  items: ACCORDION_ITEM_RULE,
@@ -163,6 +175,11 @@ const ACCORDION_PROPS_SCHEMA = {
163
175
  active: { default: 0, ...ACCORDION_ACTIVE_RULE },
164
176
  collapsible: { default: false, type: 'boolean' },
165
177
  multiple: { default: false, type: 'boolean' },
178
+ direction: {
179
+ default: 'vertical',
180
+ type: 'string',
181
+ enum: ['vertical', 'horizontal'],
182
+ },
166
183
  className: {
167
184
  default: DEFAULT_CLASS_NAMES,
168
185
  type: 'object',
@@ -172,7 +189,7 @@ const ACCORDION_PROPS_SCHEMA = {
172
189
  }),
173
190
  },
174
191
  onChange: { default: null, types: ['function', 'null'] },
175
- items: { default: [], ...ACCORDION_ITEMS_RULE },
192
+ data: { default: [], ...ACCORDION_DATA_RULE },
176
193
  } satisfies ResolveSchema<AccordionProps>;
177
194
 
178
195
  function isActiveValue(value: unknown): value is number | string {
@@ -191,46 +208,49 @@ function normalizeProps(props: AccordionProps): ResolvedAccordionProps {
191
208
  active: resolved.active as AccordionActive,
192
209
  collapsible: resolved.collapsible as boolean,
193
210
  multiple: resolved.multiple as boolean,
211
+ direction: resolved.direction as AccordionDirection,
194
212
  className: resolved.className as AccordionClassNames,
195
- items: normalizeItems(resolved.items, false),
213
+ data: normalizeData(resolved.data, false),
196
214
  onChange: resolved.onChange as ResolvedAccordionProps['onChange'],
197
215
  };
198
216
  }
199
217
 
200
- 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[] {
201
234
  if (validate) {
202
- validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion');
235
+ validateParam('data', data, ACCORDION_DATA_RULE, 'Accordion');
203
236
  }
204
237
 
205
238
  const names = new Set<string>();
206
239
 
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);
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.`);
215
245
  }
216
-
217
- return {
218
- ...source,
219
- name: name || randomId(),
220
- };
246
+ names.add(name);
247
+ return next;
221
248
  });
222
249
  }
223
250
 
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
251
  function resolveActiveItemNames(
232
252
  active: AccordionActive,
233
- items: AccordionItem[],
253
+ data: AccordionItem[],
234
254
  multiple: boolean
235
255
  ): string[] {
236
256
  if (active == null) return [];
@@ -242,8 +262,8 @@ function resolveActiveItemNames(
242
262
  const index =
243
263
  typeof value === 'number'
244
264
  ? value
245
- : items.findIndex((item) => item.name === value);
246
- const name = items[index]?.name;
265
+ : data.findIndex((item) => item.name === value);
266
+ const name = data[index]?.name;
247
267
 
248
268
  if (!name) continue;
249
269
 
@@ -256,345 +276,445 @@ function resolveActiveItemNames(
256
276
 
257
277
  function reconcileActiveNames(
258
278
  activeNames: string[],
259
- items: AccordionItem[],
279
+ data: AccordionItem[],
260
280
  multiple: boolean
261
281
  ): string[] {
262
- const names = new Set(items.map((item) => item.name).filter(Boolean));
282
+ const names = new Set(data.map((item) => item.name).filter(Boolean));
263
283
  const nextNames = activeNames.filter((name) => names.has(name));
264
284
  return multiple ? nextNames : nextNames.slice(0, 1);
265
285
  }
266
286
 
267
287
  function createCurrentState(
268
288
  activeNames: string[],
269
- items: AccordionItem[]
289
+ data: AccordionItem[]
270
290
  ): AccordionCurrent {
271
291
  const name = activeNames[0] || null;
272
292
  if (!name) return { index: null, name: null };
273
293
 
274
- const index = items.findIndex((item) => item.name === name);
294
+ const index = data.findIndex((item) => item.name === name);
275
295
  return index >= 0 ? { index, name } : { index: null, name: null };
276
296
  }
277
297
 
278
- /**
279
- * 轻量手风琴组件,继承 Component。
280
- *
281
- * 构造器只验证和保存初始配置;调用 build() 后才创建 DOM 和绑定交互。
282
- * items 属于响应式 state,变更后自动重建面板结构。
283
- */
284
- class Accordion extends Component<
285
- ResolvedAccordionProps,
286
- AccordionState,
287
- AccordionDOM
288
- > {
289
- declare runtime: AccordionRuntime;
290
- declare state: AccordionState;
291
- private bindingsDispose: (() => void) | null;
292
- private itemsDispose: (() => void) | null;
293
-
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
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
302
341
  );
342
+ };
303
343
 
304
- this.dom.headers = [];
305
- this.dom.panels = [];
306
- this.bindingsDispose = null;
307
- this.itemsDispose = null;
308
- this.runtime.built = false;
309
- this.state = createDeepStore({
310
- items: resolvedProps.items,
311
- activeNames,
312
- current: createCurrentState(activeNames, resolvedProps.items),
313
- }) as AccordionState;
314
- }
344
+ const isActive = (name: string): boolean => state.activeNames.includes(name);
315
345
 
316
- protected onInit(props: ResolvedAccordionProps): void {
317
- this.dom.root = this.buildRoot(props);
318
- this.bindItems();
319
- this.bindEvents();
320
- }
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
+ };
321
355
 
322
- private buildRoot(props: ResolvedAccordionProps): HTMLElement {
323
- return jsx('div', {
324
- className: props.className.root,
325
- id: props.id,
326
- 'data-accordion': 'root',
327
- }) as HTMLElement;
328
- }
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
+ };
329
365
 
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);
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
+ };
336
371
 
337
- untrack(() => {
338
- if (needsItemsSync(sourceItems, items)) {
339
- this.state.items = items;
340
- }
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
- 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
- });
377
+ for (const name of names) {
378
+ const wasActive = motionActiveNames.has(name);
379
+ const active = nextActiveNames.has(name);
380
+ if (wasActive === active) continue;
381
+
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());
386
+ }
387
+
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' },
350
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
+ };
351
422
 
352
- return dispose;
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;
353
461
  });
354
- }
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
+ };
355
501
 
356
- private buildItems(items: AccordionItem[], activeNames: string[]): void {
357
- if (!this.dom.root) return;
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
+ });
358
581
 
359
- const fragment = document.createDocumentFragment();
360
- this.bindingsDispose?.();
361
- this.bindingsDispose = null;
362
- this.dom.root.textContent = '';
363
- this.dom.headers = [];
364
- 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
+ };
365
593
 
366
- items.forEach((item, index) => {
367
- const name = item.name || randomId();
368
- const active = activeNames.includes(name);
369
- const headerId = `${this.props.id}_header_${index}`;
370
- 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
+ };
371
607
 
372
- const header = jsx('div', {
373
- className: this.props.className.header,
608
+ return [
609
+ jsx('div', {
610
+ className: resolvedProps.className.header,
374
611
  id: headerId,
375
612
  'data-accordion-header': name,
376
613
  role: 'button',
377
614
  tabindex: '0',
378
615
  'aria-controls': panelId,
616
+ 'aria-expanded': () => (isActive(name) ? 'true' : 'false'),
617
+ ref: headers.bind(name),
618
+ onClick: () => void activate(name),
619
+ onKeyDown: handleKeyDown,
379
620
  children: [
380
621
  jsx('span', {
381
- className: this.props.className.title,
622
+ className: resolvedProps.className.title,
382
623
  'data-accordion-title': name,
383
624
  role: 'heading',
384
- children: this.contentView(item, index, 'title', active),
625
+ children: () => {
626
+ const item = itemAccessor();
627
+ return titleView(item.title, item, indexAccessor());
628
+ },
385
629
  }),
386
630
  jsx('span', {
387
- className: this.props.className.arrow,
631
+ className: resolvedProps.className.arrow,
388
632
  'data-accordion-arrow': name,
389
633
  'aria-hidden': 'true',
390
634
  children: icon('arrow-down'),
391
635
  }),
392
636
  ],
393
- }) as HTMLElement;
394
-
395
- const panel = jsx('div', {
396
- className: this.props.className.panel,
637
+ }),
638
+ jsx('div', {
639
+ className: resolvedProps.className.panel,
397
640
  'data-accordion-panel': name,
398
641
  id: panelId,
399
642
  role: 'region',
400
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),
401
649
  children: jsx('div', {
402
- className: this.props.className.content,
650
+ className: resolvedProps.className.content,
403
651
  'data-accordion-content': name,
404
- children: this.contentView(item, index, 'content', active),
652
+ children: content.render,
405
653
  }),
406
- }) as HTMLElement;
407
-
408
- this.dom.headers.push(header);
409
- this.dom.panels.push(panel);
410
- fragment.append(header, panel);
411
- });
412
-
413
- this.dom.root.append(fragment);
414
- this.bindItemState();
415
- }
416
-
417
- private bindItemState(): void {
418
- this.bindingsDispose = createRoot((dispose) => {
419
- this.dom.headers.forEach((header) => {
420
- const name = header.dataset.accordionHeader || '';
421
- bindAttr(header, 'aria-expanded', () =>
422
- this.state.activeNames.includes(name) ? 'true' : 'false'
423
- );
424
- });
654
+ }),
655
+ ];
656
+ };
425
657
 
426
- this.dom.panels.forEach((panel, index) => {
427
- const name = this.dom.headers[index]?.dataset.accordionHeader || '';
428
- bindAttr(panel, 'aria-hidden', () =>
429
- this.state.activeNames.includes(name) ? 'false' : 'true'
430
- );
431
- 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]);
432
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;
433
713
 
434
- return dispose;
435
- });
436
- }
437
-
438
- private contentView(
439
- item: AccordionItem,
440
- index: number,
441
- type: 'title' | 'content',
442
- active: boolean
443
- ): Node[] {
444
- return normalizeContentNodes(type === 'title' ? item.title : item.content, {
445
- accordion: this,
446
- item,
447
- index,
448
- type,
449
- active,
450
- });
451
- }
452
-
453
- private syncActiveNames(names: string[], items = this.state.items): void {
454
- flushSync(() => {
455
- this.state.activeNames = names;
456
- this.state.current = createCurrentState(names, items);
457
- });
458
- }
459
-
460
- private bindEvents(): void {
461
- this.unbindEvents();
462
- if (!this.dom.root) return;
463
-
464
- this.cleanup.events.on('click', this.dom.root, 'click', (event) => {
465
- if (!(event.target instanceof Element)) return;
466
- const header = event.target.closest<HTMLElement>(
467
- '[data-accordion-header]'
468
- );
469
- if (!header || !this.dom.root?.contains(header)) return;
470
- void this.activate(header.dataset.accordionHeader);
471
- });
472
-
473
- this.cleanup.events.on('keydown', this.dom.root, 'keydown', (event) => {
474
- if (!(event instanceof KeyboardEvent)) return;
475
- if (event.key !== 'Enter' && event.key !== ' ') return;
476
- if (!(event.target instanceof Element)) return;
477
- const header = event.target.closest<HTMLElement>(
478
- '[data-accordion-header]'
479
- );
480
- if (!header || !this.dom.root?.contains(header)) return;
481
- event.preventDefault();
482
- void this.activate(header.dataset.accordionHeader);
483
- });
484
- }
485
-
486
- private unbindEvents(): void {
487
- this.cleanup.events.clear();
488
- }
489
-
490
- isActive(name: string): boolean {
491
- return this.state.activeNames.includes(name);
492
- }
493
-
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
- );
500
- }
501
- return -1;
502
- }
503
-
504
- async activate(value: number | string | undefined): Promise<void> {
505
- this.assertActive('activate');
506
- this.assertBuilt('activate');
507
-
508
- const index = this.getIndex(value);
509
- if (index < 0 || index >= this.dom.headers.length) return;
510
-
511
- const headerEl = this.dom.headers[index];
512
- const panelEl = this.dom.panels[index];
513
- const name = headerEl.dataset.accordionHeader || String(index);
514
- const isActive = this.isActive(name);
515
-
516
- if (isActive && !this.props.multiple && !this.props.collapsible) return;
517
-
518
- let activeNames: string[];
519
- if (this.props.multiple) {
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;
533
- } else {
534
- activeNames = [name];
535
- }
536
-
537
- this.syncActiveNames(activeNames);
538
-
539
- if (this.props.onChange) {
540
- await Promise.resolve(
541
- this.props.onChange(index, name, headerEl, panelEl, this)
542
- );
543
- }
544
- }
545
-
546
- build(): this {
547
- this.assertActive('build');
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
-
559
- return this;
560
- }
561
-
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
- };
571
- }
572
-
573
- private assertActive(method: string): void {
574
- if (this.runtime.destroyed) {
575
- throw new Error(`Accordion.${method}: instance has been destroyed.`);
576
- }
577
- }
578
-
579
- private assertBuilt(method: string): void {
580
- if (!this.runtime.built) {
581
- throw new Error(`Accordion.${method}: call build() first.`);
582
- }
583
- }
584
-
585
- protected onDestroy(): void {
586
- this.unbindEvents();
587
- this.itemsDispose?.();
588
- this.bindingsDispose?.();
589
- this.itemsDispose = null;
590
- this.bindingsDispose = null;
591
- if (this.dom.root?.parentNode) {
592
- this.dom.root.parentNode.removeChild(this.dom.root);
593
- }
594
- this.runtime.built = false;
595
- }
596
- }
714
+ Object.defineProperty(accordion, 'current', {
715
+ enumerable: true,
716
+ get: current,
717
+ });
597
718
 
598
- export function createAccordion(props: AccordionProps): AccordionInstance {
599
- return new Accordion(props);
719
+ return accordion;
600
720
  }