vanilla-jui 1.3.11 → 1.4.0

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 (71) hide show
  1. package/dist/index.d.ts +2927 -0
  2. package/dist/index.js +1 -1
  3. package/dist/index.umd.js +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +4 -3
  6. package/src/components/accordion.ts +522 -0
  7. package/src/components/drop.ts +497 -0
  8. package/src/components/flow.ts +1579 -0
  9. package/src/components/form.ts +953 -0
  10. package/src/components/{icons.js → icons.ts} +24 -11
  11. package/src/components/menu.ts +615 -0
  12. package/src/components/{modal.js → modal.ts} +560 -237
  13. package/src/components/offcanvas.ts +496 -0
  14. package/src/components/{pagination.js → pagination.ts} +251 -76
  15. package/src/components/parabola.ts +335 -0
  16. package/src/components/{sticky.js → sticky.ts} +123 -98
  17. package/src/components/{swiper.js → swiper.ts} +510 -232
  18. package/src/components/tabs.ts +984 -0
  19. package/src/components/theme.ts +437 -0
  20. package/src/components/toast.ts +371 -0
  21. package/src/components/toc.ts +385 -0
  22. package/src/components/tooltip.ts +179 -0
  23. package/src/components/validator.ts +596 -0
  24. package/src/core/{Component.js → Component.ts} +134 -27
  25. package/src/css/animation.css +31 -1
  26. package/src/css/card.css +1 -1
  27. package/src/css/color.css +863 -1013
  28. package/src/css/color_p3.css +822 -975
  29. package/src/css/components.css +1 -0
  30. package/src/css/form.css +1 -1
  31. package/src/css/icon.css +17 -1
  32. package/src/css/index.css +2 -2
  33. package/src/css/menu.css +1 -1
  34. package/src/css/tabs.css +1 -0
  35. package/src/css/tokens.css +1 -1
  36. package/src/index.ts +37 -0
  37. package/src/locales/{index.js → index.ts} +1 -2
  38. package/src/types.d.ts +1 -0
  39. package/src/utilities/browser.ts +131 -0
  40. package/src/utilities/cache.ts +57 -0
  41. package/src/utilities/core.ts +614 -0
  42. package/src/utilities/dom.ts +468 -0
  43. package/src/utilities/events.ts +106 -0
  44. package/src/utilities/http.ts +35 -0
  45. package/src/utilities/storage.ts +46 -0
  46. package/src/components/accordion.js +0 -373
  47. package/src/components/drop.js +0 -437
  48. package/src/components/flow.js +0 -1133
  49. package/src/components/form.js +0 -585
  50. package/src/components/menu.js +0 -422
  51. package/src/components/offcanvas.js +0 -332
  52. package/src/components/parabola.js +0 -295
  53. package/src/components/tabs.js +0 -746
  54. package/src/components/theme.js +0 -353
  55. package/src/components/toast.js +0 -329
  56. package/src/components/toc.js +0 -226
  57. package/src/components/tooltip.js +0 -86
  58. package/src/components/validator.js +0 -586
  59. package/src/index.js +0 -45
  60. package/src/locales/index.min.js +0 -1
  61. package/src/utilities/browser.js +0 -111
  62. package/src/utilities/cache.js +0 -159
  63. package/src/utilities/core.js +0 -502
  64. package/src/utilities/debug.js +0 -103
  65. package/src/utilities/dom.js +0 -313
  66. package/src/utilities/events.js +0 -69
  67. package/src/utilities/http.js +0 -30
  68. package/src/utilities/legacy-dom.js +0 -179
  69. package/src/utilities/storage.js +0 -46
  70. /package/src/{components/components.js → css/themes/index.css} +0 -0
  71. /package/src/icons/{index.js → index.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vanilla-jui",
3
- "version": "1.3.11",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "description": "A lightweight vanilla JavaScript UI plugin library.",
6
6
  "keywords": [
@@ -37,6 +37,7 @@
37
37
  "main": "./dist/index.js",
38
38
  "module": "./dist/index.js",
39
39
  "browser": "dist/index.umd.js",
40
+ "types": "./dist/index.d.ts",
40
41
  "unpkg": "dist/index.umd.js",
41
42
  "exports": {
42
43
  ".": "./dist/index.js",
@@ -56,8 +57,8 @@
56
57
  "prepublishOnly": "vp run build"
57
58
  },
58
59
  "dependencies": {
59
- "vanilla-signal": "1.1.0",
60
- "vanilla-signal-i18n": "1.1.0"
60
+ "vanilla-signal": "latest",
61
+ "vanilla-signal-i18n": "^1.1.1"
61
62
  },
62
63
  "devDependencies": {
63
64
  "@tsdown/css": "^0.22.2",
@@ -0,0 +1,522 @@
1
+ import {
2
+ bindAttr,
3
+ bindClass,
4
+ createDeepStore,
5
+ createRoot,
6
+ flushSync,
7
+ insert,
8
+ jsx,
9
+ } from 'vanilla-signal';
10
+
11
+ import Component, {
12
+ type ComponentDOM,
13
+ type ComponentRuntime,
14
+ } from '../core/Component.ts';
15
+ import {
16
+ type ResolveSchema,
17
+ randomId,
18
+ resolveProps,
19
+ validateParam,
20
+ } from '../utilities/core.ts';
21
+ import {
22
+ type DOMReference,
23
+ type RenderableContent,
24
+ isRenderableContent,
25
+ normalizeContentNodes,
26
+ requireContainer,
27
+ } from '../utilities/dom.ts';
28
+ import { icon } from './icons.ts';
29
+
30
+ export type AccordionActive = number | string | Array<number | string> | null;
31
+
32
+ export interface AccordionClassNames {
33
+ root: string;
34
+ header: string;
35
+ title: string;
36
+ arrow: string;
37
+ panel: string;
38
+ content: string;
39
+ active: string;
40
+ }
41
+
42
+ export type AccordionClassNameConfig = Partial<AccordionClassNames>;
43
+
44
+ export interface AccordionItem extends Record<string, unknown> {
45
+ name?: string;
46
+ title: RenderableContent<AccordionContentContext>;
47
+ content: RenderableContent<AccordionContentContext>;
48
+ }
49
+
50
+ export interface AccordionProps extends Record<string, unknown> {
51
+ id?: string | null;
52
+ active?: AccordionActive;
53
+ collapsible?: boolean;
54
+ multiple?: boolean;
55
+ className?: AccordionClassNameConfig;
56
+ items?: AccordionItem[];
57
+ onChange?:
58
+ | ((
59
+ index: number,
60
+ name: string,
61
+ header: HTMLElement,
62
+ panel: HTMLElement,
63
+ accordion: Accordion
64
+ ) => void | Promise<void>)
65
+ | null;
66
+ }
67
+
68
+ interface ResolvedAccordionProps extends Record<string, unknown> {
69
+ id: string;
70
+ active: AccordionActive;
71
+ collapsible: boolean;
72
+ multiple: boolean;
73
+ className: AccordionClassNames;
74
+ items: AccordionItem[];
75
+ onChange: NonNullable<AccordionProps['onChange']> | null;
76
+ }
77
+
78
+ interface AccordionState extends Record<string, unknown> {
79
+ activeNames: string[];
80
+ current: {
81
+ index: number | null;
82
+ name: string | null;
83
+ };
84
+ }
85
+
86
+ interface AccordionDOM extends ComponentDOM {
87
+ root: HTMLElement | null;
88
+ container: Element;
89
+ headers: HTMLElement[];
90
+ panels: HTMLElement[];
91
+ }
92
+
93
+ interface AccordionRuntime extends ComponentRuntime {}
94
+
95
+ export interface AccordionContentContext {
96
+ accordion: Accordion;
97
+ item: AccordionItem;
98
+ index: number;
99
+ type: 'title' | 'content';
100
+ active: boolean;
101
+ }
102
+
103
+ const DEFAULT_CLASS_NAMES: AccordionClassNames = {
104
+ root: 'j-accordion',
105
+ header: 'accordion-header',
106
+ title: 'header-title',
107
+ arrow: 'header-arrow',
108
+ panel: 'accordion-panel',
109
+ content: 'panel-content',
110
+ active: 'is-active',
111
+ };
112
+
113
+ const ACCORDION_ITEMS_RULE = {
114
+ type: 'array',
115
+ validate: (value: unknown) => Array.isArray(value) && value.length > 0,
116
+ message: 'expects a non-empty array.',
117
+ };
118
+
119
+ const ACCORDION_ACTIVE_RULE = {
120
+ types: ['number', 'string', 'array', 'null'],
121
+ validate: (value: unknown) => {
122
+ if (value == null) return true;
123
+ if (Array.isArray(value)) {
124
+ return value.every(
125
+ (item) => typeof item === 'number' || typeof item === 'string'
126
+ );
127
+ }
128
+ if (typeof value === 'number') return Number.isInteger(value) && value >= 0;
129
+ return typeof value === 'string' && value.trim().length > 0;
130
+ },
131
+ message: 'expects a positive number, string, array or null.',
132
+ };
133
+
134
+ const ACCORDION_PROPS_SCHEMA = {
135
+ id: {
136
+ default: null,
137
+ types: ['string', 'null'],
138
+ normalize: (value: unknown) => {
139
+ if (typeof value === 'string') {
140
+ const id = value.trim();
141
+ return id || randomId();
142
+ }
143
+ if (value == null) return randomId();
144
+ return value;
145
+ },
146
+ },
147
+ active: { default: 0, ...ACCORDION_ACTIVE_RULE },
148
+ collapsible: { default: false, type: 'boolean' },
149
+ multiple: { default: false, type: 'boolean' },
150
+ className: {
151
+ default: DEFAULT_CLASS_NAMES,
152
+ type: 'object',
153
+ normalize: (value: unknown) => ({
154
+ ...DEFAULT_CLASS_NAMES,
155
+ ...(value && typeof value === 'object' ? value : {}),
156
+ }),
157
+ },
158
+ onChange: { default: null, types: ['function', 'null'] },
159
+ items: { default: [], type: 'array' },
160
+ } satisfies ResolveSchema<AccordionProps>;
161
+
162
+ function cloneItems(items: unknown): AccordionItem[] {
163
+ return Array.isArray(items)
164
+ ? items.map((item) => ({ ...(item as AccordionItem) }))
165
+ : [];
166
+ }
167
+
168
+ function normalizeProps(input: AccordionProps): ResolvedAccordionProps {
169
+ const props = resolveProps(input, ACCORDION_PROPS_SCHEMA, 'Accordion');
170
+ return {
171
+ id: props.id as string,
172
+ active: props.active as AccordionActive,
173
+ collapsible: props.collapsible as boolean,
174
+ multiple: props.multiple as boolean,
175
+ className: props.className as AccordionClassNames,
176
+ items: cloneItems(props.items),
177
+ onChange: props.onChange as ResolvedAccordionProps['onChange'],
178
+ };
179
+ }
180
+
181
+ function normalizeItems(items: AccordionItem[]): AccordionItem[] {
182
+ validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion');
183
+
184
+ return items.map((item) => {
185
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
186
+ throw new Error('Accordion: item expects an object.');
187
+ }
188
+ if (item.name != null && typeof item.name !== 'string') {
189
+ throw new Error('Accordion: item name expects a string.');
190
+ }
191
+ if (!isRenderableContent(item.title)) {
192
+ throw new Error(
193
+ 'Accordion: item title expects string, Node, array, function or null.'
194
+ );
195
+ }
196
+ if (!isRenderableContent(item.content)) {
197
+ throw new Error(
198
+ 'Accordion: item content expects string, Node, array, function or null.'
199
+ );
200
+ }
201
+ return { ...item, name: item.name || randomId() };
202
+ });
203
+ }
204
+
205
+ /**
206
+ * 轻量手风琴组件,继承 Component。
207
+ *
208
+ * DOM 创建一次,通过 createEffect 细粒度更新 class/ARIA。
209
+ */
210
+ export class Accordion extends Component<
211
+ ResolvedAccordionProps,
212
+ AccordionState,
213
+ AccordionDOM
214
+ > {
215
+ declare runtime: AccordionRuntime;
216
+ declare state: AccordionState;
217
+ private bindingsDispose: (() => void) | null;
218
+
219
+ /**
220
+ * @param {Element|Node|string|Array} container 挂载容器(元素、选择器或 JSX/h 返回节点)。
221
+ * @param {object} [input={}] 手风琴配置。
222
+ */
223
+ constructor(container: DOMReference, input: AccordionProps = {}) {
224
+ const el = requireContainer(container, 'Accordion');
225
+ const props = normalizeProps(input);
226
+ super(props);
227
+
228
+ this.dom.container = el;
229
+ this.dom.headers = [];
230
+ this.dom.panels = [];
231
+ this.bindingsDispose = null;
232
+
233
+ this.state = createDeepStore({
234
+ activeNames: [],
235
+ current: { index: null, name: null },
236
+ }) as AccordionState;
237
+
238
+ try {
239
+ this.onInit(props);
240
+ } catch (error) {
241
+ this.destroy();
242
+ throw error;
243
+ }
244
+ }
245
+
246
+ protected onInit(props: ResolvedAccordionProps): void {
247
+ this.root = this.buildRoot(props);
248
+ this.buildItems(props);
249
+ this.syncActiveNames(this.resolveActiveNames(props.active));
250
+ this.bindEvents();
251
+ }
252
+
253
+ private buildRoot(props: ResolvedAccordionProps): HTMLElement {
254
+ return jsx('div', {
255
+ className: props.className.root,
256
+ id: props.id,
257
+ 'data-accordion': 'root',
258
+ }) as HTMLElement;
259
+ }
260
+
261
+ private buildItems(props: ResolvedAccordionProps): void {
262
+ const items = normalizeItems(props.items);
263
+ const fragment = document.createDocumentFragment();
264
+
265
+ this.dom.headers = [];
266
+ this.dom.panels = [];
267
+
268
+ items.forEach((item, index) => {
269
+ const name = item.name || randomId();
270
+ const headerId = `${props.id}_header_${index}`;
271
+ const panelId = `${props.id}_panel_${index}`;
272
+
273
+ const header = jsx('div', {
274
+ className: props.className.header,
275
+ 'data-accordion-header': name,
276
+ id: headerId,
277
+ role: 'button',
278
+ tabindex: '0',
279
+ 'aria-controls': panelId,
280
+ children: [
281
+ jsx('span', {
282
+ className: props.className.title,
283
+ 'data-accordion-title': name,
284
+ children: this.contentView(item, index, 'title'),
285
+ }),
286
+ jsx('span', {
287
+ className: props.className.arrow,
288
+ 'data-accordion-arrow': name,
289
+ 'aria-hidden': 'true',
290
+ children: icon('arrow-down'),
291
+ }),
292
+ ],
293
+ }) as HTMLElement;
294
+
295
+ const panel = jsx('div', {
296
+ className: props.className.panel,
297
+ 'data-accordion-panel': name,
298
+ id: panelId,
299
+ role: 'region',
300
+ 'aria-labelledby': headerId,
301
+ children: jsx('div', {
302
+ className: props.className.content,
303
+ 'data-accordion-content': name,
304
+ children: this.contentView(item, index, 'content'),
305
+ }),
306
+ }) as HTMLElement;
307
+
308
+ this.dom.headers.push(header);
309
+ this.dom.panels.push(panel);
310
+ fragment.append(header, panel);
311
+ });
312
+
313
+ this.root?.append(fragment);
314
+
315
+ this.bindingsDispose?.();
316
+ this.bindingsDispose = createRoot((dispose) => {
317
+ this.dom.headers.forEach((header) => {
318
+ const name = header.dataset.accordionHeader || '';
319
+ bindClass(header, this.props.className.active, () =>
320
+ this.state.activeNames.includes(name)
321
+ );
322
+ bindAttr(header, 'aria-expanded', () =>
323
+ this.state.activeNames.includes(name)
324
+ );
325
+ });
326
+ this.dom.panels.forEach((panel, i) => {
327
+ const name = this.dom.headers[i]?.dataset.accordionHeader || '';
328
+ bindClass(panel, this.props.className.active, () =>
329
+ this.state.activeNames.includes(name)
330
+ );
331
+ bindAttr(panel, 'aria-hidden', () =>
332
+ this.state.activeNames.includes(name) ? 'false' : 'true'
333
+ );
334
+ bindAttr(panel, 'hidden', () => !this.state.activeNames.includes(name));
335
+ });
336
+ return dispose;
337
+ });
338
+ }
339
+
340
+ private contentView(
341
+ item: AccordionItem,
342
+ index: number,
343
+ type: 'title' | 'content'
344
+ ): Node[] {
345
+ return normalizeContentNodes(type === 'title' ? item.title : item.content, {
346
+ accordion: this,
347
+ item,
348
+ index,
349
+ type,
350
+ active: false,
351
+ });
352
+ }
353
+
354
+ private resolveActiveNames(active: AccordionActive): string[] {
355
+ if (active == null) return [];
356
+ const values = Array.isArray(active) ? active : [active];
357
+ const names: string[] = [];
358
+ for (const value of values) {
359
+ const index = this.getIndex(value);
360
+ if (index < 0 || index >= this.dom.headers.length) continue;
361
+ names.push(
362
+ this.dom.headers[index].dataset.accordionHeader || String(index)
363
+ );
364
+ if (!this.props.multiple) break;
365
+ }
366
+ return Array.from(new Set(names));
367
+ }
368
+
369
+ private syncActiveNames(names: string[]): void {
370
+ const firstName = names[0] || null;
371
+ const index = firstName ? this.getIndex(firstName) : null;
372
+ flushSync(() => {
373
+ this.state.activeNames = names;
374
+ this.state.current = { index, name: firstName };
375
+ });
376
+ }
377
+
378
+ private bindEvents(): void {
379
+ this.unbindEvents();
380
+ if (!this.root) return;
381
+
382
+ this.cleanup.events.on('click', this.root, 'click', (event) => {
383
+ if (!(event.target instanceof Element)) return;
384
+ const header = event.target.closest<HTMLElement>(
385
+ '[data-accordion-header]'
386
+ );
387
+ if (!header || !this.root?.contains(header)) return;
388
+ void this.active(header.dataset.accordionHeader);
389
+ });
390
+
391
+ this.cleanup.events.on('keydown', this.root, 'keydown', (event) => {
392
+ if (!(event instanceof KeyboardEvent)) return;
393
+ if (event.key !== 'Enter' && event.key !== ' ') return;
394
+ if (!(event.target instanceof Element)) return;
395
+ const header = event.target.closest<HTMLElement>(
396
+ '[data-accordion-header]'
397
+ );
398
+ if (!header || !this.root?.contains(header)) return;
399
+ event.preventDefault();
400
+ void this.active(header.dataset.accordionHeader);
401
+ });
402
+ }
403
+
404
+ private unbindEvents(): void {
405
+ this.cleanup.events.clear();
406
+ }
407
+
408
+ isActive(name: string): boolean {
409
+ return this.state.activeNames.includes(name);
410
+ }
411
+
412
+ private assertActive(method: string): void {
413
+ if (this.runtime.destroyed) {
414
+ throw new Error(`Accordion.${method}: instance has been destroyed.`);
415
+ }
416
+ }
417
+
418
+ private async activateItem(
419
+ val: number | string | undefined,
420
+ fireEvent = true
421
+ ): Promise<void> {
422
+ const index = this.getIndex(val);
423
+ if (index < 0 || index >= this.dom.headers.length) return;
424
+
425
+ const headerEl = this.dom.headers[index];
426
+ const panelEl = this.dom.panels[index];
427
+ const name = headerEl.dataset.accordionHeader || String(index);
428
+ const active = this.isActive(name);
429
+
430
+ if (active && !this.props.multiple && !this.props.collapsible) return;
431
+
432
+ let nextNames: string[];
433
+ if (this.props.multiple) {
434
+ nextNames = active
435
+ ? this.state.activeNames.filter((n) => n !== name)
436
+ : [...this.state.activeNames, name];
437
+ } else if (active) {
438
+ nextNames = this.props.collapsible ? [] : this.state.activeNames;
439
+ } else {
440
+ nextNames = [name];
441
+ }
442
+
443
+ this.syncActiveNames(nextNames);
444
+
445
+ if (fireEvent && this.props.onChange) {
446
+ await Promise.resolve(
447
+ this.props.onChange(index, name, headerEl, panelEl, this)
448
+ );
449
+ }
450
+ }
451
+
452
+ getIndex(val: number | string | undefined | null): number {
453
+ if (typeof val === 'number') return val;
454
+ if (typeof val === 'string') {
455
+ return this.dom.headers.findIndex(
456
+ (header) => header.dataset.accordionHeader === val
457
+ );
458
+ }
459
+ return -1;
460
+ }
461
+
462
+ /**
463
+ * 激活指定面板。
464
+ * @param {number|string} val 面板索引或名称。
465
+ */
466
+ async active(val: number | string | undefined): Promise<void> {
467
+ this.assertActive('active');
468
+ await this.activateItem(val, true);
469
+ }
470
+
471
+ /**
472
+ * 将组件挂载到构造器指定的容器中。
473
+ */
474
+ build(): this {
475
+ this.assertActive('build');
476
+ insert(this.dom.container, () => this.root);
477
+ return this;
478
+ }
479
+
480
+ render(): this {
481
+ return this.build();
482
+ }
483
+
484
+ /**
485
+ * 动态替换全部面板条目。
486
+ * @param {AccordionItem[]} items 新面板配置。
487
+ * @param {number|string|Array<number|string>|null} [active=0] 替换后默认激活项。
488
+ */
489
+ setItems(items: AccordionItem[], active: AccordionActive = 0): this {
490
+ this.assertActive('setItems');
491
+ validateParam('items', items, ACCORDION_ITEMS_RULE, 'Accordion.setItems');
492
+ validateParam(
493
+ 'active',
494
+ active,
495
+ ACCORDION_ACTIVE_RULE,
496
+ 'Accordion.setItems'
497
+ );
498
+
499
+ this.props.items = cloneItems(normalizeItems(items));
500
+ this.props.active = active;
501
+
502
+ if (this.root) this.root.textContent = '';
503
+ this.buildItems(this.props);
504
+ this.syncActiveNames(this.resolveActiveNames(active));
505
+ this.bindEvents();
506
+ return this;
507
+ }
508
+
509
+ protected onDestroy(): void {
510
+ this.unbindEvents();
511
+ this.bindingsDispose?.();
512
+ this.bindingsDispose = null;
513
+ if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
514
+ }
515
+ }
516
+
517
+ export function createAccordion(
518
+ container: DOMReference,
519
+ input: AccordionProps = {}
520
+ ): Accordion {
521
+ return new Accordion(container, input);
522
+ }