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.
- package/README.md +10 -4
- package/dist/index.d.ts +418 -970
- package/dist/index.js +1 -1
- package/dist/index.umd.js +1 -1
- package/dist/style.css +1 -1
- package/package.json +3 -3
- package/src/components/accordion.ts +466 -346
- package/src/components/flow.ts +779 -1133
- package/src/components/form.ts +666 -509
- package/src/components/menu.ts +198 -328
- package/src/components/modal.ts +510 -747
- package/src/components/offcanvas.ts +273 -367
- package/src/components/pagination.ts +201 -375
- package/src/components/swiper.ts +689 -1107
- package/src/components/tabs.ts +467 -778
- package/src/components/toc.ts +191 -244
- package/src/core/component.ts +332 -0
- package/src/core/motion.ts +298 -0
- package/src/core/presence.ts +233 -0
- package/src/core/scheduler.ts +91 -0
- package/src/core/view.ts +31 -0
- package/src/css/accordion.css +94 -3
- package/src/css/content.css +18 -24
- package/src/css/drop.css +4 -1
- package/src/css/form.css +8 -0
- package/src/css/menu.css +10 -5
- package/src/css/modal.css +2 -1
- package/src/css/offcanvas.css +5 -17
- package/src/css/swiper.css +2 -1
- package/src/css/tabs.css +8 -17
- package/src/css/themes/default/accordion.css +0 -48
- package/src/css/themes/default/swiper.css +1 -1
- package/src/css/themes/default/tabs.css +4 -6
- package/src/css/toast.css +1 -17
- package/src/css/tooltip.css +2 -1
- package/src/index.ts +8 -2
- package/src/locales/index.ts +3 -0
- package/src/primitives/drop.ts +253 -262
- package/src/primitives/loading.ts +22 -4
- package/src/primitives/parabola.ts +123 -164
- package/src/primitives/sticky.ts +297 -0
- package/src/primitives/theme.ts +164 -222
- package/src/primitives/toast.ts +309 -284
- package/src/primitives/tooltip.ts +65 -66
- package/src/utilities/browser.ts +38 -0
- package/src/utilities/dom.ts +0 -54
- package/src/utilities/refs.ts +61 -0
- package/src/utilities/state.ts +73 -0
- package/src/utilities/timer.ts +11 -31
- package/src/validation/validator.ts +289 -389
- package/src/components/sticky.ts +0 -297
- package/src/core/Component.ts +0 -377
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
For,
|
|
3
3
|
createDeepStore,
|
|
4
4
|
createEffect,
|
|
5
|
-
|
|
5
|
+
createSignal,
|
|
6
6
|
flushSync,
|
|
7
7
|
jsx,
|
|
8
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
} from '../
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
+
data: AccordionItem[];
|
|
81
94
|
activeNames: string[];
|
|
82
|
-
|
|
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
|
-
|
|
106
|
+
interface AccordionContentCacheEntry {
|
|
107
|
+
content: RenderableContent<AccordionContentContext>;
|
|
108
|
+
updatedAt: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type AccordionInstance = FunctionalComponent<
|
|
104
112
|
ResolvedAccordionProps,
|
|
105
113
|
AccordionState,
|
|
106
|
-
|
|
114
|
+
HTMLElement,
|
|
115
|
+
AccordionActions
|
|
107
116
|
> & {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
data: normalizeData(resolved.data, false),
|
|
196
214
|
onChange: resolved.onChange as ResolvedAccordionProps['onChange'],
|
|
197
215
|
};
|
|
198
216
|
}
|
|
199
217
|
|
|
200
|
-
function
|
|
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('
|
|
235
|
+
validateParam('data', data, ACCORDION_DATA_RULE, 'Accordion');
|
|
203
236
|
}
|
|
204
237
|
|
|
205
238
|
const names = new Set<string>();
|
|
206
239
|
|
|
207
|
-
return (
|
|
208
|
-
const
|
|
209
|
-
const name =
|
|
210
|
-
if (name) {
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
246
|
-
const 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
|
-
|
|
279
|
+
data: AccordionItem[],
|
|
260
280
|
multiple: boolean
|
|
261
281
|
): string[] {
|
|
262
|
-
const names = new Set(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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:
|
|
622
|
+
className: resolvedProps.className.title,
|
|
382
623
|
'data-accordion-title': name,
|
|
383
624
|
role: 'heading',
|
|
384
|
-
children:
|
|
625
|
+
children: () => {
|
|
626
|
+
const item = itemAccessor();
|
|
627
|
+
return titleView(item.title, item, indexAccessor());
|
|
628
|
+
},
|
|
385
629
|
}),
|
|
386
630
|
jsx('span', {
|
|
387
|
-
className:
|
|
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
|
-
})
|
|
394
|
-
|
|
395
|
-
|
|
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:
|
|
650
|
+
className: resolvedProps.className.content,
|
|
403
651
|
'data-accordion-content': name,
|
|
404
|
-
children:
|
|
652
|
+
children: content.render,
|
|
405
653
|
}),
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
return new Accordion(props);
|
|
719
|
+
return accordion;
|
|
600
720
|
}
|