vanilla-jui 1.4.8 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +10 -4
  2. package/dist/index.d.ts +402 -977
  3. package/dist/index.js +1 -1
  4. package/dist/index.umd.js +1 -1
  5. package/dist/style.css +1 -1
  6. package/package.json +3 -3
  7. package/src/components/accordion.ts +467 -349
  8. package/src/components/flow.ts +779 -1133
  9. package/src/components/form.ts +666 -509
  10. package/src/components/menu.ts +199 -326
  11. package/src/components/modal.ts +510 -747
  12. package/src/components/offcanvas.ts +281 -361
  13. package/src/components/pagination.ts +207 -374
  14. package/src/components/swiper.ts +688 -1168
  15. package/src/components/tabs.ts +468 -788
  16. package/src/components/toc.ts +191 -244
  17. package/src/core/component.ts +332 -0
  18. package/src/core/motion.ts +298 -0
  19. package/src/core/presence.ts +233 -0
  20. package/src/core/scheduler.ts +91 -0
  21. package/src/core/view.ts +31 -0
  22. package/src/css/accordion.css +94 -3
  23. package/src/css/color.css +4 -5
  24. package/src/css/color_p3.css +6 -5
  25. package/src/css/content.css +18 -24
  26. package/src/css/drop.css +4 -1
  27. package/src/css/form.css +8 -0
  28. package/src/css/gradient.css +64 -131
  29. package/src/css/layout.css +9 -23
  30. package/src/css/menu.css +10 -5
  31. package/src/css/modal.css +2 -1
  32. package/src/css/offcanvas.css +1 -17
  33. package/src/css/swiper.css +2 -1
  34. package/src/css/tabs.css +8 -17
  35. package/src/css/themes/default/accordion.css +0 -48
  36. package/src/css/themes/default/swiper.css +1 -1
  37. package/src/css/themes/default/tabs.css +4 -6
  38. package/src/css/toast.css +1 -17
  39. package/src/css/tokens.css +18 -35
  40. package/src/css/tooltip.css +2 -1
  41. package/src/index.ts +8 -3
  42. package/src/locales/index.ts +3 -0
  43. package/src/primitives/drop.ts +253 -262
  44. package/src/primitives/loading.ts +22 -4
  45. package/src/primitives/parabola.ts +123 -164
  46. package/src/primitives/sticky.ts +297 -0
  47. package/src/primitives/theme.ts +164 -222
  48. package/src/primitives/toast.ts +309 -284
  49. package/src/primitives/tooltip.ts +65 -66
  50. package/src/utilities/browser.ts +38 -0
  51. package/src/utilities/dom.ts +0 -54
  52. package/src/utilities/refs.ts +61 -0
  53. package/src/utilities/state.ts +73 -0
  54. package/src/utilities/timer.ts +11 -31
  55. package/src/validation/validator.ts +289 -389
  56. package/src/components/sticky.ts +0 -297
  57. package/src/core/Component.ts +0 -377
  58. package/src/utilities/scheduler.ts +0 -156
@@ -1,20 +1,15 @@
1
- import { createDeepStore, jsx } from 'vanilla-signal';
1
+ import { For, createDeepStore, flushSync, jsx } from 'vanilla-signal';
2
2
  import { t } from 'vanilla-signal-i18n';
3
3
 
4
- import Component, {
5
- type ComponentDOM,
6
- type ComponentRuntime,
7
- } from '../core/Component.ts';
4
+ import {
5
+ type FunctionalComponent,
6
+ defineComponent,
7
+ } from '../core/component.ts';
8
8
  import locales from '../locales/index.ts';
9
9
  import { icon } from '../primitives/icons.ts';
10
10
  import { joinClasses } from '../utilities/class-name.ts';
11
- import { all } from '../utilities/dom.ts';
11
+ import { createEventManager } from '../utilities/events.ts';
12
12
  import { randomId } from '../utilities/id.ts';
13
- import {
14
- createStateSync,
15
- stateSnapshot,
16
- trackStoreVersion,
17
- } from '../utilities/scheduler.ts';
18
13
  import {
19
14
  type ResolveSchema,
20
15
  resolveProps,
@@ -66,23 +61,7 @@ interface ResolvedMenuProps extends Record<string, unknown> {
66
61
 
67
62
  interface MenuState extends Record<string, unknown> {
68
63
  data: MenuItem[];
69
- }
70
-
71
- interface MenuDOM extends ComponentDOM {
72
- root: HTMLElement | null;
73
- list: HTMLElement | null;
74
- }
75
-
76
- interface MenuRuntime extends ComponentRuntime {
77
- built: boolean;
78
- }
79
-
80
- interface MenuCleanupExtras {
81
- state?: (() => void) | null;
82
- }
83
-
84
- interface MenuSnapshot {
85
- data: unknown;
64
+ activeKeys: string[];
86
65
  }
87
66
 
88
67
  const DEFAULT_CLASS_NAMES: MenuClassNames = {
@@ -142,6 +121,7 @@ const MENU_PROPS_SCHEMA = {
142
121
 
143
122
  const MENU_STATE_SCHEMA = {
144
123
  data: MENU_DATA_RULE,
124
+ activeKeys: { type: 'array', items: 'string' },
145
125
  };
146
126
 
147
127
  function normalizeProps(input: MenuProps = {}): ResolvedMenuProps {
@@ -211,320 +191,213 @@ function cloneMenuData(data: unknown): MenuItem[] {
211
191
  });
212
192
  }
213
193
 
214
- function findDirectMenuLink(menuItem: HTMLElement): HTMLElement | null {
215
- return (
216
- all<HTMLElement>('[data-menu-link]', menuItem).find(
217
- (link) => link.parentElement === menuItem
218
- ) || null
219
- );
220
- }
221
-
222
- function findDirectSubMenu(menuItem: HTMLElement): HTMLElement | null {
223
- return (
224
- all<HTMLElement>('[data-menu-list="sub"]', menuItem).find(
225
- (list) => list.parentElement === menuItem
226
- ) || null
227
- );
228
- }
229
-
230
- function findDirectBackItem(subMenu: HTMLElement): HTMLElement | null {
231
- return (
232
- all<HTMLElement>('[data-menu-back]', subMenu).find(
233
- (item) => item.parentElement === subMenu
234
- ) || null
235
- );
236
- }
237
-
238
- class MenuComponent extends Component<ResolvedMenuProps, MenuState, MenuDOM> {
239
- declare runtime: MenuRuntime;
240
- declare state: MenuState;
241
- declare cleanup: Component['cleanup'] & MenuCleanupExtras;
242
-
243
- constructor(input: MenuProps = {}) {
244
- const props = normalizeProps(input);
245
- super(props);
246
-
247
- this.dom.list = null;
248
- this.cleanup.state = null;
249
- this.runtime.built = false;
250
-
251
- this.state = createDeepStore({
252
- data: cloneMenuData(props.data),
253
- }) as MenuState;
254
- }
255
-
256
- build(): this {
257
- if (this.runtime.destroyed) {
258
- throw new Error('Menu.build: instance has been destroyed.');
259
- }
260
- if (this.runtime.built) return this;
261
-
262
- this.validateData(this.state.data);
263
- this.dom.root = jsx('nav', {
264
- className: this.props.className.root,
265
- 'data-menu': 'root',
266
- ...(this.props.type !== undefined && {
267
- 'data-menu-type': this.props.type,
268
- }),
269
- children: jsx('ul', {
270
- className: this.props.className.list,
271
- id: this.props.id,
272
- 'data-menu-list': 'root',
273
- }),
274
- }) as HTMLElement;
275
- this.dom.list = this.dom.root.querySelector('[data-menu-list="root"]');
276
- this.runtime.built = true;
277
- this.bindState();
278
- this.bindEvents();
279
- this.emit('init', this.props);
280
- return this;
281
- }
282
-
283
- private bindState(): void {
284
- if (this.cleanup.state) return;
285
-
286
- this.cleanup.state = createStateSync(
287
- () => ({
288
- data: trackStoreVersion(this.state.data),
289
- }),
290
- (snapshot) => this.renderSnapshot(snapshot),
291
- { deferInitial: false, flushInitial: true }
292
- );
293
- }
294
-
295
- private renderSnapshot(snapshot: MenuSnapshot): void {
296
- if (!this.runtime.built || !this.dom.root || !this.dom.list) return;
297
- this.validateData(snapshot.data);
298
- const data = cloneMenuData(stateSnapshot(snapshot.data));
299
-
300
- this.dom.list.textContent = '';
301
- this.dom.list.append(...data.map((item) => this.buildItem(item)));
302
- }
303
-
304
- private buildItem(item: MenuItem): HTMLElement {
305
- const { className } = this.props;
306
- const childrenItems = item.children || [];
307
- const hasChildren = childrenItems.length > 0;
308
- const classes = [className.item];
309
-
310
- if (hasChildren) classes.push(className.hasChildren);
311
- classes.push(...normalizeItemClasses(item.classes));
312
-
313
- const url = itemUrl(item.url);
314
- const shouldRenderSpan = !hasChildren && !url;
315
- const title = shouldRenderSpan
316
- ? (jsx('span', {
317
- className: className.link,
318
- 'data-menu-link': '',
319
- children: item.title,
320
- }) as HTMLElement)
321
- : (jsx('a', {
322
- className: className.link,
323
- href: url,
324
- ...(item.target && { target: item.target }),
325
- 'data-menu-link': '',
326
- children: item.title,
327
- }) as HTMLElement);
328
-
329
- const children: Node[] = [title];
330
-
331
- if (hasChildren) {
332
- children.push(
333
- jsx('ul', {
334
- className: className.subMenu,
335
- 'data-menu-list': 'sub',
336
- children: childrenItems.map((child) => this.buildItem(child)),
337
- }) as HTMLElement
338
- );
339
- }
340
-
341
- return jsx('li', {
342
- className: joinClasses(...classes),
343
- id: itemId(item.id),
344
- 'data-menu-item': item.id == null ? '' : String(item.id),
345
- ...(hasChildren && { 'data-menu-has-children': '' }),
346
- children,
347
- }) as HTMLElement;
348
- }
349
-
350
- private bindEvents(): void {
351
- this.unbindEvents();
352
- const root = this.dom.root;
353
- if (!root) return;
354
-
355
- if (this.props.type === 'mobile') {
356
- this.cleanup.events.on('mobile', root, 'click', (event) => {
357
- const target = event.target;
358
- if (!(target instanceof Element)) return;
359
-
360
- const backItem = target.closest<HTMLElement>('[data-menu-back]');
361
- if (backItem && root.contains(backItem)) {
362
- event.preventDefault();
363
- this.handleBack(backItem);
364
- return;
365
- }
366
-
367
- const menuItem = target.closest<HTMLElement>(
368
- '[data-menu-has-children]'
369
- );
370
- if (!menuItem || !root.contains(menuItem)) return;
371
-
372
- const subMenu = findDirectSubMenu(menuItem);
373
- if (subMenu?.contains(target)) return;
374
-
375
- const directLink = findDirectMenuLink(menuItem);
376
- if (
377
- target === menuItem ||
378
- (directLink && (target === directLink || directLink.contains(target)))
379
- ) {
380
- event.preventDefault();
381
- this.handleMenuClick(menuItem);
382
- }
383
- });
384
- }
385
-
386
- if (this.props.type === 'bottom') {
387
- this.cleanup.events.on('bottom', document, 'click', (event) => {
388
- const target = event.target;
389
- if (!(target instanceof Element) || !root.isConnected) return;
390
-
391
- const submenuLink = target.closest(
392
- '[data-menu-list="sub"] [data-menu-link]'
393
- );
394
- if (submenuLink && root.contains(submenuLink)) return;
395
-
396
- const firstLevelMenuItem = target.closest<HTMLElement>(
397
- '[data-menu-list="root"] > [data-menu-has-children]'
398
- );
194
+ export type Menu = FunctionalComponent<
195
+ ResolvedMenuProps,
196
+ MenuState,
197
+ HTMLElement
198
+ >;
399
199
 
400
- if (firstLevelMenuItem && root.contains(firstLevelMenuItem)) {
401
- event.preventDefault();
402
- this.toggleActive(firstLevelMenuItem);
403
- } else {
404
- this.clearActive();
405
- }
406
- });
407
- }
408
- }
409
-
410
- private unbindEvents(): void {
411
- this.cleanup.events.clear();
412
- }
413
-
414
- private handleMenuClick(menuItem: HTMLElement): void {
415
- menuItem.classList.add(this.props.className.active);
416
-
417
- const subMenu = findDirectSubMenu(menuItem);
418
- if (!subMenu) return;
419
-
420
- const existingBack = findDirectBackItem(subMenu);
421
- if (existingBack) return;
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];
224
+ });
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
+ };
422
236
 
423
- const { className } = this.props;
424
- const backButton = jsx('li', {
425
- className: className.backItem,
237
+ const backView = (key: string): HTMLElement =>
238
+ jsx('li', {
239
+ className: props.className.backItem,
426
240
  'data-menu-back': '',
427
241
  children: jsx('a', {
428
- className: className.link,
242
+ className: props.className.link,
429
243
  href: '',
430
244
  'data-menu-link': '',
245
+ onClick: (event: Event) => {
246
+ event.preventDefault();
247
+ close(key);
248
+ },
431
249
  children: [
432
250
  jsx('span', {
433
- className: className.backIcon,
251
+ className: props.className.backIcon,
434
252
  children: icon('arrow-left'),
435
253
  }),
436
254
  jsx('span', {
437
- className: className.text,
438
- children: this.props.backText,
255
+ className: props.className.text,
256
+ children: props.backText,
439
257
  }),
440
258
  ],
441
259
  }),
442
260
  }) as HTMLElement;
443
261
 
444
- subMenu.insertBefore(backButton, subMenu.firstChild);
445
- }
446
-
447
- private handleBack(target: Element): void {
448
- const backItem = target.closest<HTMLElement>('[data-menu-back]');
449
- if (!backItem) return;
450
-
451
- const subMenu = backItem.parentElement;
452
- const parentMenuItem = subMenu?.parentElement;
453
-
454
- if (parentMenuItem?.hasAttribute('data-menu-has-children')) {
455
- parentMenuItem.classList.remove(this.props.className.active);
456
- }
457
-
458
- backItem.remove();
459
- }
460
-
461
- private toggleActive(menuItem: HTMLElement): void {
462
- const isActive = menuItem.classList.contains(this.props.className.active);
463
- this.clearActive();
464
- if (!isActive) menuItem.classList.add(this.props.className.active);
465
- }
466
-
467
- private clearActive(): void {
468
- if (!this.dom.root) return;
469
- all<HTMLElement>('[data-menu-item]', this.dom.root).forEach((item) => {
470
- item.classList.remove(this.props.className.active);
471
- });
472
- }
473
-
474
- private validateData(data: unknown): void {
475
- validateParam('data', data, MENU_DATA_RULE, 'Menu');
476
- }
477
-
478
- private assertStatePatchKey(key: string): void {
479
- if (!Object.hasOwn(MENU_STATE_SCHEMA, key)) {
480
- throw new Error(`Menu.setState: "${key}" is not a supported state key.`);
481
- }
482
- }
483
-
484
- protected override normalizeStatePatch(
485
- patch: Partial<MenuState>
486
- ): Partial<MenuState> {
487
- const nextPatch = { ...patch };
488
- if (Object.hasOwn(nextPatch, 'data')) {
489
- nextPatch.data = cloneMenuData(nextPatch.data);
490
- }
491
- return nextPatch;
492
- }
493
-
494
- protected override validateStatePatch(patch: Partial<MenuState>): void {
495
- validateParam(
496
- 'state',
497
- patch,
498
- {
499
- type: 'plainObject',
500
- },
501
- 'Menu.setState'
502
- );
503
-
504
- for (const key of Object.keys(patch)) {
505
- this.assertStatePatchKey(key);
506
- const stateKey = key as keyof typeof MENU_STATE_SCHEMA;
507
- validateParam(
508
- key,
509
- patch[key as keyof MenuState],
510
- MENU_STATE_SCHEMA[stateKey],
511
- 'Menu.setState'
512
- );
513
- }
514
- }
515
-
516
- protected onDestroy(): void {
517
- this.unbindEvents();
518
- this.cleanup.state?.();
519
- this.cleanup.state = null;
520
- this.dom.root?.remove();
521
- this.dom.list = null;
522
- this.runtime.built = false;
523
- }
524
- }
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;
525
355
 
526
- 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
+ });
527
401
 
528
- export function createMenu(input: MenuProps = {}): Menu {
529
- return new MenuComponent(input);
402
+ return menu;
530
403
  }