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,20 +1,14 @@
1
- import {
2
- createDeepStore,
3
- createEffect,
4
- createRoot,
5
- jsx,
6
- untrack,
7
- } from 'vanilla-signal';
1
+ import { For, createDeepStore, flushSync, jsx } from 'vanilla-signal';
8
2
  import { t } from 'vanilla-signal-i18n';
9
3
 
10
- import Component, {
11
- type ComponentDOM,
12
- type ComponentRuntime,
13
- } from '../core/Component.ts';
4
+ import {
5
+ type FunctionalComponent,
6
+ defineComponent,
7
+ } from '../core/component.ts';
14
8
  import locales from '../locales/index.ts';
15
9
  import { icon } from '../primitives/icons.ts';
16
10
  import { joinClasses } from '../utilities/class-name.ts';
17
- import { all } from '../utilities/dom.ts';
11
+ import { createEventManager } from '../utilities/events.ts';
18
12
  import { randomId } from '../utilities/id.ts';
19
13
  import {
20
14
  type ResolveSchema,
@@ -67,23 +61,7 @@ interface ResolvedMenuProps extends Record<string, unknown> {
67
61
 
68
62
  interface MenuState extends Record<string, unknown> {
69
63
  data: MenuItem[];
70
- }
71
-
72
- interface MenuDOM extends ComponentDOM {
73
- root: HTMLElement | null;
74
- list: HTMLElement | null;
75
- }
76
-
77
- interface MenuRuntime extends ComponentRuntime {
78
- built: boolean;
79
- }
80
-
81
- interface MenuCleanupExtras {
82
- state?: (() => void) | null;
83
- }
84
-
85
- interface MenuSnapshot {
86
- data: unknown;
64
+ activeKeys: string[];
87
65
  }
88
66
 
89
67
  const DEFAULT_CLASS_NAMES: MenuClassNames = {
@@ -143,6 +121,7 @@ const MENU_PROPS_SCHEMA = {
143
121
 
144
122
  const MENU_STATE_SCHEMA = {
145
123
  data: MENU_DATA_RULE,
124
+ activeKeys: { type: 'array', items: 'string' },
146
125
  };
147
126
 
148
127
  function normalizeProps(input: MenuProps = {}): ResolvedMenuProps {
@@ -212,322 +191,213 @@ function cloneMenuData(data: unknown): MenuItem[] {
212
191
  });
213
192
  }
214
193
 
215
- function findDirectMenuLink(menuItem: HTMLElement): HTMLElement | null {
216
- return (
217
- all<HTMLElement>('[data-menu-link]', menuItem).find(
218
- (link) => link.parentElement === menuItem
219
- ) || null
220
- );
221
- }
222
-
223
- function findDirectSubMenu(menuItem: HTMLElement): HTMLElement | null {
224
- return (
225
- all<HTMLElement>('[data-menu-list="sub"]', menuItem).find(
226
- (list) => list.parentElement === menuItem
227
- ) || null
228
- );
229
- }
230
-
231
- function findDirectBackItem(subMenu: HTMLElement): HTMLElement | null {
232
- return (
233
- all<HTMLElement>('[data-menu-back]', subMenu).find(
234
- (item) => item.parentElement === subMenu
235
- ) || null
236
- );
237
- }
238
-
239
- class MenuComponent extends Component<ResolvedMenuProps, MenuState, MenuDOM> {
240
- declare runtime: MenuRuntime;
241
- declare state: MenuState;
242
- declare cleanup: Component['cleanup'] & MenuCleanupExtras;
243
-
244
- constructor(input: MenuProps = {}) {
245
- const props = normalizeProps(input);
246
- super(props);
247
-
248
- this.dom.list = null;
249
- this.cleanup.state = null;
250
- this.runtime.built = false;
251
-
252
- this.state = createDeepStore({
253
- data: cloneMenuData(props.data),
254
- }) as MenuState;
255
- }
256
-
257
- build(): this {
258
- if (this.runtime.destroyed) {
259
- throw new Error('Menu.build: instance has been destroyed.');
260
- }
261
- if (this.runtime.built) return this;
262
-
263
- this.validateData(this.state.data);
264
- this.dom.root = jsx('nav', {
265
- className: this.props.className.root,
266
- 'data-menu': 'root',
267
- ...(this.props.type !== undefined && {
268
- 'data-menu-type': this.props.type,
269
- }),
270
- children: jsx('ul', {
271
- className: this.props.className.list,
272
- id: this.props.id,
273
- 'data-menu-list': 'root',
274
- }),
275
- }) as HTMLElement;
276
- this.dom.list = this.dom.root.querySelector('[data-menu-list="root"]');
277
- this.runtime.built = true;
278
- this.bindState();
279
- this.bindEvents();
280
- this.emit('init', this.props);
281
- return this;
282
- }
194
+ export type Menu = FunctionalComponent<
195
+ ResolvedMenuProps,
196
+ MenuState,
197
+ HTMLElement
198
+ >;
283
199
 
284
- private bindState(): void {
285
- if (this.cleanup.state) return;
286
-
287
- this.cleanup.state = createRoot((dispose) => {
288
- createEffect(() => {
289
- const snapshot = {
290
- data: this.state.data,
291
- };
292
- untrack(() => this.renderSnapshot(snapshot));
293
- });
294
- return dispose;
200
+ export function createMenu(input: MenuProps = {}): Menu {
201
+ const props = normalizeProps(input);
202
+ const state = createDeepStore({
203
+ data: cloneMenuData(props.data),
204
+ activeKeys: [],
205
+ }) as MenuState;
206
+ const generatedKeys = new WeakMap<object, string>();
207
+ const events = createEventManager();
208
+ let menu: Menu;
209
+
210
+ const itemKey = (item: MenuItem): string => {
211
+ if (item.id != null) return String(item.id);
212
+ if (!generatedKeys.has(item)) generatedKeys.set(item, randomId());
213
+ return generatedKeys.get(item) as string;
214
+ };
215
+ const isActive = (key: string): boolean => state.activeKeys.includes(key);
216
+ const open = (key: string): void => {
217
+ flushSync(() => {
218
+ state.activeKeys =
219
+ props.type === 'bottom'
220
+ ? [key]
221
+ : state.activeKeys.includes(key)
222
+ ? state.activeKeys
223
+ : [...state.activeKeys, key];
295
224
  });
296
- }
297
-
298
- private renderSnapshot(snapshot: MenuSnapshot): void {
299
- if (!this.runtime.built || !this.dom.root || !this.dom.list) return;
300
- this.validateData(snapshot.data);
301
- const data = cloneMenuData(snapshot.data);
302
-
303
- this.dom.list.textContent = '';
304
- this.dom.list.append(...data.map((item) => this.buildItem(item)));
305
- }
306
-
307
- private buildItem(item: MenuItem): HTMLElement {
308
- const { className } = this.props;
309
- const childrenItems = item.children || [];
310
- const hasChildren = childrenItems.length > 0;
311
- const classes = [className.item];
312
-
313
- if (hasChildren) classes.push(className.hasChildren);
314
- classes.push(...normalizeItemClasses(item.classes));
315
-
316
- const url = itemUrl(item.url);
317
- const shouldRenderSpan = !hasChildren && !url;
318
- const title = shouldRenderSpan
319
- ? (jsx('span', {
320
- className: className.link,
321
- 'data-menu-link': '',
322
- children: item.title,
323
- }) as HTMLElement)
324
- : (jsx('a', {
325
- className: className.link,
326
- href: url,
327
- ...(item.target && { target: item.target }),
328
- 'data-menu-link': '',
329
- children: item.title,
330
- }) as HTMLElement);
331
-
332
- const children: Node[] = [title];
333
-
334
- if (hasChildren) {
335
- children.push(
336
- jsx('ul', {
337
- className: className.subMenu,
338
- 'data-menu-list': 'sub',
339
- children: childrenItems.map((child) => this.buildItem(child)),
340
- }) as HTMLElement
341
- );
342
- }
343
-
344
- return jsx('li', {
345
- className: joinClasses(...classes),
346
- id: itemId(item.id),
347
- 'data-menu-item': item.id == null ? '' : String(item.id),
348
- ...(hasChildren && { 'data-menu-has-children': '' }),
349
- children,
350
- }) as HTMLElement;
351
- }
352
-
353
- private bindEvents(): void {
354
- this.unbindEvents();
355
- const root = this.dom.root;
356
- if (!root) return;
357
-
358
- if (this.props.type === 'mobile') {
359
- this.cleanup.events.on('mobile', root, 'click', (event) => {
360
- const target = event.target;
361
- if (!(target instanceof Element)) return;
362
-
363
- const backItem = target.closest<HTMLElement>('[data-menu-back]');
364
- if (backItem && root.contains(backItem)) {
365
- event.preventDefault();
366
- this.handleBack(backItem);
367
- return;
368
- }
369
-
370
- const menuItem = target.closest<HTMLElement>(
371
- '[data-menu-has-children]'
372
- );
373
- if (!menuItem || !root.contains(menuItem)) return;
374
-
375
- const subMenu = findDirectSubMenu(menuItem);
376
- if (subMenu?.contains(target)) return;
377
-
378
- const directLink = findDirectMenuLink(menuItem);
379
- if (
380
- target === menuItem ||
381
- (directLink && (target === directLink || directLink.contains(target)))
382
- ) {
383
- event.preventDefault();
384
- this.handleMenuClick(menuItem);
385
- }
386
- });
387
- }
388
-
389
- if (this.props.type === 'bottom') {
390
- this.cleanup.events.on('bottom', document, 'click', (event) => {
391
- const target = event.target;
392
- if (!(target instanceof Element) || !root.isConnected) return;
393
-
394
- const submenuLink = target.closest(
395
- '[data-menu-list="sub"] [data-menu-link]'
396
- );
397
- if (submenuLink && root.contains(submenuLink)) return;
398
-
399
- const firstLevelMenuItem = target.closest<HTMLElement>(
400
- '[data-menu-list="root"] > [data-menu-has-children]'
401
- );
402
-
403
- if (firstLevelMenuItem && root.contains(firstLevelMenuItem)) {
404
- event.preventDefault();
405
- this.toggleActive(firstLevelMenuItem);
406
- } else {
407
- this.clearActive();
408
- }
409
- });
410
- }
411
- }
412
-
413
- private unbindEvents(): void {
414
- this.cleanup.events.clear();
415
- }
416
-
417
- private handleMenuClick(menuItem: HTMLElement): void {
418
- menuItem.classList.add(this.props.className.active);
419
-
420
- const subMenu = findDirectSubMenu(menuItem);
421
- if (!subMenu) return;
422
-
423
- const existingBack = findDirectBackItem(subMenu);
424
- if (existingBack) return;
225
+ };
226
+ const close = (key: string): void => {
227
+ flushSync(() => {
228
+ state.activeKeys = state.activeKeys.filter((item) => item !== key);
229
+ });
230
+ };
231
+ const toggle = (key: string): void => {
232
+ flushSync(() => {
233
+ state.activeKeys = isActive(key) ? [] : [key];
234
+ });
235
+ };
425
236
 
426
- const { className } = this.props;
427
- const backButton = jsx('li', {
428
- className: className.backItem,
237
+ const backView = (key: string): HTMLElement =>
238
+ jsx('li', {
239
+ className: props.className.backItem,
429
240
  'data-menu-back': '',
430
241
  children: jsx('a', {
431
- className: className.link,
242
+ className: props.className.link,
432
243
  href: '',
433
244
  'data-menu-link': '',
245
+ onClick: (event: Event) => {
246
+ event.preventDefault();
247
+ close(key);
248
+ },
434
249
  children: [
435
250
  jsx('span', {
436
- className: className.backIcon,
251
+ className: props.className.backIcon,
437
252
  children: icon('arrow-left'),
438
253
  }),
439
254
  jsx('span', {
440
- className: className.text,
441
- children: this.props.backText,
255
+ className: props.className.text,
256
+ children: props.backText,
442
257
  }),
443
258
  ],
444
259
  }),
445
260
  }) as HTMLElement;
446
261
 
447
- subMenu.insertBefore(backButton, subMenu.firstChild);
448
- }
449
-
450
- private handleBack(target: Element): void {
451
- const backItem = target.closest<HTMLElement>('[data-menu-back]');
452
- if (!backItem) return;
453
-
454
- const subMenu = backItem.parentElement;
455
- const parentMenuItem = subMenu?.parentElement;
456
-
457
- if (parentMenuItem?.hasAttribute('data-menu-has-children')) {
458
- parentMenuItem.classList.remove(this.props.className.active);
459
- }
460
-
461
- backItem.remove();
462
- }
463
-
464
- private toggleActive(menuItem: HTMLElement): void {
465
- const isActive = menuItem.classList.contains(this.props.className.active);
466
- this.clearActive();
467
- if (!isActive) menuItem.classList.add(this.props.className.active);
468
- }
469
-
470
- private clearActive(): void {
471
- if (!this.dom.root) return;
472
- all<HTMLElement>('[data-menu-item]', this.dom.root).forEach((item) => {
473
- item.classList.remove(this.props.className.active);
474
- });
475
- }
476
-
477
- private validateData(data: unknown): void {
478
- validateParam('data', data, MENU_DATA_RULE, 'Menu');
479
- }
480
-
481
- private assertStatePatchKey(key: string): void {
482
- if (!Object.hasOwn(MENU_STATE_SCHEMA, key)) {
483
- throw new Error(`Menu.setState: "${key}" is not a supported state key.`);
484
- }
485
- }
486
-
487
- protected override normalizeStatePatch(
488
- patch: Partial<MenuState>
489
- ): Partial<MenuState> {
490
- const nextPatch = { ...patch };
491
- if (Object.hasOwn(nextPatch, 'data')) {
492
- nextPatch.data = cloneMenuData(nextPatch.data);
493
- }
494
- return nextPatch;
495
- }
496
-
497
- protected override validateStatePatch(patch: Partial<MenuState>): void {
498
- validateParam(
499
- 'state',
500
- patch,
501
- {
502
- type: 'plainObject',
503
- },
504
- 'Menu.setState'
505
- );
506
-
507
- for (const key of Object.keys(patch)) {
508
- this.assertStatePatchKey(key);
509
- const stateKey = key as keyof typeof MENU_STATE_SCHEMA;
510
- validateParam(
511
- key,
512
- patch[key as keyof MenuState],
513
- MENU_STATE_SCHEMA[stateKey],
514
- 'Menu.setState'
515
- );
516
- }
517
- }
518
-
519
- protected onDestroy(): void {
520
- this.unbindEvents();
521
- this.cleanup.state?.();
522
- this.cleanup.state = null;
523
- this.dom.root?.remove();
524
- this.dom.list = null;
525
- this.runtime.built = false;
526
- }
527
- }
262
+ const listView = (
263
+ items: () => MenuItem[],
264
+ root = false,
265
+ parentKey?: string
266
+ ): HTMLElement =>
267
+ jsx('ul', {
268
+ className: root ? props.className.list : props.className.subMenu,
269
+ ...(root ? { id: props.id } : {}),
270
+ 'data-menu-list': root ? 'root' : 'sub',
271
+ children: [
272
+ For({
273
+ each: () =>
274
+ props.type === 'mobile' && parentKey && isActive(parentKey)
275
+ ? [parentKey]
276
+ : [],
277
+ key: (key: string) => key,
278
+ children: (key: () => string) => backView(key()),
279
+ }),
280
+ For({
281
+ each: items,
282
+ key: (item: MenuItem) => itemKey(item),
283
+ children: (itemAccessor: () => MenuItem) => {
284
+ const key = itemKey(itemAccessor());
285
+ return jsx('li', {
286
+ className: () => {
287
+ const item = itemAccessor();
288
+ return joinClasses(
289
+ props.className.item,
290
+ item.children?.length ? props.className.hasChildren : '',
291
+ ...normalizeItemClasses(item.classes),
292
+ isActive(key) ? props.className.active : ''
293
+ );
294
+ },
295
+ id: () =>
296
+ itemAccessor().id == null
297
+ ? `menu-item-${key}`
298
+ : itemId(itemAccessor().id),
299
+ 'data-menu-item': () =>
300
+ itemAccessor().id == null ? '' : String(itemAccessor().id),
301
+ 'data-menu-has-children': () =>
302
+ itemAccessor().children?.length ? '' : null,
303
+ onClick: (event: Event) => {
304
+ if (
305
+ props.type === 'mobile' &&
306
+ event.target === event.currentTarget &&
307
+ itemAccessor().children?.length
308
+ ) {
309
+ open(key);
310
+ }
311
+ },
312
+ children: [
313
+ () => {
314
+ const item = itemAccessor();
315
+ const hasChildren = !!item.children?.length;
316
+ const url = itemUrl(item.url);
317
+ if (!hasChildren && !url) {
318
+ return jsx('span', {
319
+ className: props.className.link,
320
+ 'data-menu-link': '',
321
+ children: item.title,
322
+ });
323
+ }
324
+ return jsx('a', {
325
+ className: props.className.link,
326
+ href: url,
327
+ target: item.target,
328
+ 'data-menu-link': '',
329
+ onClick: (event: Event) => {
330
+ if (!hasChildren) return;
331
+ if (props.type === 'mobile' || props.type === 'bottom') {
332
+ event.preventDefault();
333
+ if (props.type === 'bottom') toggle(key);
334
+ else open(key);
335
+ }
336
+ },
337
+ children: item.title,
338
+ });
339
+ },
340
+ () => {
341
+ const children = itemAccessor().children;
342
+ if (!children?.length) return null;
343
+ return listView(
344
+ () => itemAccessor().children || [],
345
+ false,
346
+ key
347
+ );
348
+ },
349
+ ],
350
+ });
351
+ },
352
+ }),
353
+ ],
354
+ }) as HTMLElement;
528
355
 
529
- export type Menu = MenuComponent;
356
+ menu = defineComponent({
357
+ name: 'Menu',
358
+ props,
359
+ state,
360
+ normalizeStatePatch(patch) {
361
+ const next = { ...patch };
362
+ if (Object.hasOwn(next, 'data')) next.data = cloneMenuData(next.data);
363
+ return next;
364
+ },
365
+ validateStatePatch(patch) {
366
+ for (const key of Object.keys(patch)) {
367
+ if (!Object.hasOwn(MENU_STATE_SCHEMA, key)) {
368
+ throw new Error(`Menu.setState: "${key}" is not supported.`);
369
+ }
370
+ const stateKey = key as keyof typeof MENU_STATE_SCHEMA;
371
+ validateParam(
372
+ key,
373
+ patch[key as keyof MenuState],
374
+ MENU_STATE_SCHEMA[stateKey],
375
+ 'Menu.setState'
376
+ );
377
+ }
378
+ },
379
+ view: () => {
380
+ validateParam('data', state.data, MENU_DATA_RULE, 'Menu');
381
+ return jsx('nav', {
382
+ className: props.className.root,
383
+ 'data-menu': 'root',
384
+ 'data-menu-type': props.type,
385
+ children: listView(() => state.data, true),
386
+ }) as HTMLElement;
387
+ },
388
+ onBuild(context) {
389
+ if (props.type !== 'bottom') return;
390
+ events.on('outside', document, 'click', (event) => {
391
+ const target = event.target;
392
+ if (!(target instanceof Node) || context.element?.contains(target))
393
+ return;
394
+ flushSync(() => {
395
+ state.activeKeys = [];
396
+ });
397
+ });
398
+ context.own(() => events.clear());
399
+ },
400
+ });
530
401
 
531
- export function createMenu(input: MenuProps = {}): Menu {
532
- return new MenuComponent(input);
402
+ return menu;
533
403
  }