vanilla-jui 1.4.8 → 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 (53) 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/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 -3
  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
  53. package/src/utilities/scheduler.ts +0 -156
@@ -1,19 +1,17 @@
1
1
  import {
2
2
  createDeepStore,
3
3
  createEffect,
4
- createRoot,
4
+ createSignal,
5
5
  flushSync,
6
6
  jsx,
7
- onCleanup,
8
- render,
9
7
  untrack,
10
8
  } from 'vanilla-signal';
11
9
  import { t } from 'vanilla-signal-i18n';
12
10
 
13
- import Component, {
14
- type ComponentDOM,
15
- type ComponentRuntime,
16
- } from '../core/Component.ts';
11
+ import {
12
+ type FunctionalComponent,
13
+ defineComponent,
14
+ } from '../core/component.ts';
17
15
  import locales from '../locales/index.ts';
18
16
  import { icon } from '../primitives/icons.ts';
19
17
  import { createLoading } from '../primitives/loading.ts';
@@ -22,29 +20,26 @@ import { joinClasses } from '../utilities/class-name.ts';
22
20
  import {
23
21
  all,
24
22
  q,
25
- normalizeContentNodes,
26
23
  type RenderableContent,
27
24
  } from '../utilities/dom.ts';
28
25
  import { randomId } from '../utilities/id.ts';
26
+ import { createMotionGroup, createTransition } from '../core/motion.ts';
29
27
  import { isPlainObject } from '../utilities/object.ts';
28
+ import { createPresence } from '../core/presence.ts';
30
29
  import {
31
30
  type ResolveSchema,
32
31
  resolveProps,
33
32
  validateParam,
34
33
  } from '../utilities/types.ts';
35
- import {
36
- createForm,
37
- type FormDataRecord,
38
- type FormField,
39
- type FormProps,
40
- } from './form.ts';
41
-
42
- const HIDE_DURATION = 300;
43
34
 
44
35
  type ModalTextInput = Partial<ModalText> & Record<string, unknown>;
45
- type ModalContent = RenderableContent<Modal>;
46
- type FormInstance = ReturnType<typeof createForm>;
47
- export type ModalMode = 'content' | 'form';
36
+ type ModalContentResult = RenderableContent<Modal>;
37
+ type ModalContent =
38
+ | ModalContentResult
39
+ | ((modal: Modal) => ModalContentResult | Promise<ModalContentResult>);
40
+ interface ResolvedModalContent {
41
+ value: ModalContentResult;
42
+ }
48
43
 
49
44
  export interface ModalClassNames {
50
45
  layout: string;
@@ -68,8 +63,9 @@ export interface ModalText {
68
63
  }
69
64
 
70
65
  export interface ModalProps extends Record<string, unknown> {
71
- mode?: ModalMode | null;
72
66
  content?: ModalContent;
67
+ cache?: boolean;
68
+ ttl?: number;
73
69
  position?: string;
74
70
  showCancel?: boolean;
75
71
  showClose?: boolean;
@@ -80,11 +76,7 @@ export interface ModalProps extends Record<string, unknown> {
80
76
  onHide?: ((modal: Modal) => void | Promise<void>) | null;
81
77
  onHidden?: ((modal: Modal) => void | Promise<void>) | null;
82
78
  onConfirm?: ((modal: Modal) => void | Promise<void>) | null;
83
- onSubmit?:
84
- | ((data: FormDataRecord, modal: Modal) => void | Promise<void>)
85
- | null;
86
79
  onCancel?: ((modal: Modal) => void | Promise<void>) | null;
87
- fields?: readonly FormField[] | null;
88
80
  header?: boolean;
89
81
  footer?: boolean;
90
82
  id?: string | null;
@@ -94,8 +86,9 @@ export interface ModalProps extends Record<string, unknown> {
94
86
  }
95
87
 
96
88
  interface ResolvedModalProps extends Record<string, unknown> {
97
- mode: ModalMode;
98
89
  content: ModalContent;
90
+ cache: boolean;
91
+ ttl: number;
99
92
  position: string;
100
93
  showCancel: boolean;
101
94
  showClose: boolean;
@@ -106,9 +99,7 @@ interface ResolvedModalProps extends Record<string, unknown> {
106
99
  onHide: NonNullable<ModalProps['onHide']> | null;
107
100
  onHidden: NonNullable<ModalProps['onHidden']> | null;
108
101
  onConfirm: NonNullable<ModalProps['onConfirm']> | null;
109
- onSubmit: NonNullable<ModalProps['onSubmit']> | null;
110
102
  onCancel: NonNullable<ModalProps['onCancel']> | null;
111
- fields: FormField[] | null;
112
103
  header: boolean;
113
104
  footer: boolean;
114
105
  id: string;
@@ -118,42 +109,19 @@ interface ResolvedModalProps extends Record<string, unknown> {
118
109
  }
119
110
 
120
111
  interface ModalState extends Record<string, unknown> {
121
- mode: ModalMode;
122
112
  content: ModalContent;
123
- fields: FormField[] | null;
124
113
  loading: boolean;
125
114
  processing: boolean;
126
115
  visible: boolean;
127
- data: FormDataRecord | null;
128
- extraData: FormDataRecord | null;
129
- }
130
-
131
- interface ModalDOM extends ComponentDOM {
132
- root: HTMLElement | null;
133
- modal: HTMLElement | null;
134
- header: HTMLElement | null;
135
- body: HTMLElement | null;
136
- footer: HTMLElement | null;
137
- form: FormInstance | null;
138
- formContainer: HTMLElement | null;
139
- }
140
-
141
- interface ModalRuntime extends ComponentRuntime {
142
- scrollLocked: boolean;
143
- visibleApplied: boolean;
144
116
  }
145
117
 
146
118
  interface ModalCache {
147
- initial: ResolvedModalProps | null;
148
- fieldIds: Map<string, string> | null;
119
+ initial: ResolvedModalProps;
149
120
  previousActiveElement: HTMLElement | null;
150
- formId: string;
151
- }
152
-
153
- interface ModalCleanupExtras {
154
- visibility?: (() => void) | null;
155
- view?: (() => void) | null;
156
- hideTimer?: ReturnType<typeof setTimeout> | null;
121
+ content: ModalContentResult;
122
+ contentSource: ModalContent | null;
123
+ hasContent: boolean;
124
+ updatedAt: number;
157
125
  }
158
126
 
159
127
  type ModalStatePatch = Partial<ModalState>;
@@ -171,25 +139,9 @@ const DEFAULT_CLASS_NAMES: ModalClassNames = {
171
139
  confirmBtn: 'is-primary',
172
140
  };
173
141
 
174
- function cloneFields(
175
- fields: readonly FormField[] | null | undefined
176
- ): FormField[] {
177
- if (!Array.isArray(fields)) return [];
178
- return fields.map((field) => ({
179
- ...field,
180
- options: Array.isArray(field.options)
181
- ? field.options.map(
182
- (option: NonNullable<FormField['options']>[number]) =>
183
- option && typeof option === 'object' ? { ...option } : option
184
- )
185
- : field.options,
186
- }));
187
- }
188
-
189
142
  function cloneProps(props: ResolvedModalProps): ResolvedModalProps {
190
143
  return {
191
144
  ...props,
192
- fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
193
145
  text: { ...props.text },
194
146
  className: { ...props.className },
195
147
  };
@@ -197,23 +149,19 @@ function cloneProps(props: ResolvedModalProps): ResolvedModalProps {
197
149
 
198
150
  function createModalState(props: ResolvedModalProps): ModalState {
199
151
  return {
200
- mode: props.mode,
201
152
  content: props.content,
202
- fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
203
153
  loading: false,
204
154
  processing: false,
205
155
  visible: false,
206
- data: null,
207
- extraData: null,
208
156
  };
209
157
  }
210
158
 
211
- function mergeExtraData(
212
- data: FormDataRecord,
213
- extraData: FormDataRecord | null
214
- ): FormDataRecord {
215
- if (!extraData || typeof extraData !== 'object') return data;
216
- return Object.assign(data, extraData);
159
+ function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
160
+ return !!value && typeof (value as PromiseLike<T>).then === 'function';
161
+ }
162
+
163
+ function normalizeTtl(ttl: number): number {
164
+ return Number.isFinite(ttl) ? Math.max(0, ttl) : 0;
217
165
  }
218
166
 
219
167
  const MODAL_CONTENT_RULE = {
@@ -235,18 +183,10 @@ const MODAL_TEXT_RULE = {
235
183
  },
236
184
  };
237
185
 
238
- const MODAL_FIELDS_RULE = { types: ['array', 'null'] };
239
186
  const MODAL_PROPS_SCHEMA = {
240
- mode: {
241
- default: null,
242
- types: ['string', 'null'],
243
- enum: ['content', 'form', null],
244
- normalize: (value: unknown, context) => {
245
- if (value != null) return value;
246
- return Array.isArray(context.options.fields) ? 'form' : 'content';
247
- },
248
- },
249
187
  content: { default: '', ...MODAL_CONTENT_RULE },
188
+ cache: { default: false, type: 'boolean' },
189
+ ttl: { default: 0, type: 'number' },
250
190
  position: { default: 'center', type: 'string' },
251
191
  showCancel: { default: true, type: 'boolean' },
252
192
  showClose: { default: true, type: 'boolean' },
@@ -257,9 +197,7 @@ const MODAL_PROPS_SCHEMA = {
257
197
  onHide: { default: null, types: ['function', 'null'] },
258
198
  onHidden: { default: null, types: ['function', 'null'] },
259
199
  onConfirm: { default: null, types: ['function', 'null'] },
260
- onSubmit: { default: null, types: ['function', 'null'] },
261
200
  onCancel: { default: null, types: ['function', 'null'] },
262
- fields: { default: null, ...MODAL_FIELDS_RULE },
263
201
  header: { default: true, type: 'boolean' },
264
202
  footer: { default: true, type: 'boolean' },
265
203
  id: {
@@ -287,14 +225,10 @@ const MODAL_PROPS_SCHEMA = {
287
225
  } satisfies ResolveSchema<ModalProps>;
288
226
 
289
227
  const MODAL_STATE_SCHEMA = {
290
- mode: { type: 'string', enum: ['content', 'form'] },
291
228
  content: MODAL_PROPS_SCHEMA.content,
292
- fields: MODAL_PROPS_SCHEMA.fields,
293
229
  loading: { default: false, type: 'boolean' },
294
230
  processing: { default: false, type: 'boolean' },
295
231
  visible: { default: false, type: 'boolean' },
296
- data: { default: null, types: ['object', 'null'] },
297
- extraData: { default: null, types: ['object', 'null'] },
298
232
  };
299
233
 
300
234
  const MODAL_UPDATE_RULE = {
@@ -304,8 +238,9 @@ const MODAL_UPDATE_RULE = {
304
238
  function normalizeProps(input: ModalProps): ResolvedModalProps {
305
239
  const props = resolveProps(input, MODAL_PROPS_SCHEMA, 'Modal');
306
240
  return {
307
- mode: props.mode as ModalMode,
308
241
  content: props.content as ModalContent,
242
+ cache: props.cache as boolean,
243
+ ttl: normalizeTtl(props.ttl as number),
309
244
  position: props.position as string,
310
245
  showCancel: props.showCancel as boolean,
311
246
  showClose: props.showClose as boolean,
@@ -316,11 +251,7 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
316
251
  onHide: props.onHide as ResolvedModalProps['onHide'],
317
252
  onHidden: props.onHidden as ResolvedModalProps['onHidden'],
318
253
  onConfirm: props.onConfirm as ResolvedModalProps['onConfirm'],
319
- onSubmit: props.onSubmit as ResolvedModalProps['onSubmit'],
320
254
  onCancel: props.onCancel as ResolvedModalProps['onCancel'],
321
- fields: Array.isArray(props.fields)
322
- ? cloneFields(props.fields as FormField[])
323
- : null,
324
255
  header: props.header as boolean,
325
256
  footer: props.footer as boolean,
326
257
  id: props.id as string,
@@ -333,512 +264,288 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
333
264
  let modalScrollLockCount = 0;
334
265
  let modalBodyOverflow = '';
335
266
 
336
- class ModalComponent extends Component<
267
+ interface ModalActions {
268
+ show(): Modal;
269
+ hide(): Modal;
270
+ reset(): Modal;
271
+ }
272
+
273
+ export type Modal = FunctionalComponent<
337
274
  ResolvedModalProps,
338
275
  ModalState,
339
- ModalDOM,
340
- ModalCache
341
- > {
342
- declare runtime: ModalRuntime;
343
- declare state: ModalState;
344
- declare cleanup: Component['cleanup'] & ModalCleanupExtras;
345
-
346
- constructor(input: ModalProps = {}) {
347
- const props = normalizeProps(input);
348
- super(props);
349
-
350
- this.dom.modal = null;
351
- this.dom.header = null;
352
- this.dom.body = null;
353
- this.dom.footer = null;
354
- this.dom.form = null;
355
- this.dom.formContainer = null;
356
-
357
- this.cleanup.visibility = null;
358
- this.cleanup.view = null;
359
- this.cleanup.hideTimer = null;
360
-
361
- this.runtime.scrollLocked = false;
362
- this.runtime.visibleApplied = false;
363
-
364
- this.cache = {
365
- initial: cloneProps(this.props),
366
- fieldIds: new Map(),
367
- previousActiveElement: null,
368
- formId: `${this.props.id}_form`,
369
- };
370
-
371
- this.state = createDeepStore(createModalState(this.props)) as ModalState;
276
+ HTMLElement,
277
+ ModalActions
278
+ >;
372
279
 
373
- this.init(props);
374
- }
375
-
376
- protected override onInit(): void {
377
- this.bindReactiveVisibility();
378
- }
379
-
380
- build(): this {
381
- this.assertActive('build');
382
- if (this.dom.root instanceof HTMLElement) return this;
383
-
384
- const { id, className } = this.props;
385
- const dialogChildren = [
386
- jsx('div', {
387
- className: className.header,
388
- style: { display: this.props.header ? '' : 'none' },
389
- ref: (element: HTMLElement) => {
390
- this.dom.header = element;
391
- },
392
- }),
393
- jsx('div', {
394
- className: className.body,
395
- ref: (element: HTMLElement) => {
396
- this.dom.body = element;
397
- },
398
- }),
399
- jsx('div', {
400
- className: className.footer,
401
- style: { display: this.props.footer ? '' : 'none' },
402
- ref: (element: HTMLElement) => {
403
- this.dom.footer = element;
404
- },
405
- }),
406
- ];
407
-
408
- const modal = jsx('div', {
409
- className: () =>
410
- joinClasses(
411
- this.props.className.modal,
412
- this.props.fullscreen && 'is-fullscreen'
413
- ),
414
- id,
415
- role: 'document',
416
- 'data-modal-dialog': id,
417
- ref: (element: HTMLElement) => {
418
- this.dom.modal = element;
419
- },
420
- children: dialogChildren,
421
- }) as HTMLElement;
422
-
423
- const root = createPopup({
424
- className: className.layout,
425
- position: this.props.position,
426
- component: 'modal',
427
- labelledby: this.props.header ? `${id}_title` : '',
428
- content: modal,
429
- });
430
- if (!this.props.header) {
431
- root.setAttribute('aria-label', this.props.text.title || 'Modal');
280
+ export function createModal(input: ModalProps = {}): Modal {
281
+ const props = normalizeProps(input);
282
+ const state = createDeepStore(createModalState(props)) as ModalState;
283
+ const cache: ModalCache = {
284
+ initial: cloneProps(props),
285
+ previousActiveElement: null,
286
+ content: null,
287
+ contentSource: null,
288
+ hasContent: false,
289
+ updatedAt: 0,
290
+ };
291
+ const events = new Map<string, () => void>();
292
+ const [motionVisible, setMotionVisible] = createSignal(false);
293
+ const [resolvedContent, setResolvedContent] =
294
+ createSignal<ResolvedModalContent>({ value: null });
295
+ let modal!: Modal;
296
+ let dialog: HTMLElement | null = null;
297
+ let loadingElement: HTMLDivElement | null = null;
298
+ let scrollLocked = false;
299
+ let contentLoadId = 0;
300
+
301
+ const isBusy = (): boolean => state.processing;
302
+ const isContentCacheValid = (source: ModalContent): boolean => {
303
+ if (!props.cache || !cache.hasContent || cache.contentSource !== source) {
304
+ return false;
432
305
  }
433
-
434
- this.dom.root = root;
435
- this.mountView();
436
- return this;
437
- }
438
-
439
- mountView(): void {
440
- if (this.cleanup.view || !this.dom.body) return;
441
-
442
- this.cleanup.view = createRoot((dispose) => {
443
- if (this.dom.header) {
444
- const headerDispose = render(() => this.headerView(), this.dom.header);
445
- onCleanup(headerDispose);
446
- }
447
-
448
- if (this.dom.body) {
449
- const bodyDispose = render(() => this.bodyView(), this.dom.body);
450
- onCleanup(bodyDispose);
451
- }
452
-
453
- if (this.dom.footer) {
454
- const footerDispose = render(() => this.footerView(), this.dom.footer);
455
- onCleanup(footerDispose);
456
- }
457
-
458
- this.bindReactiveLoading();
459
- return dispose;
460
- });
461
- }
462
-
463
- headerView(): () => (Node | null)[] | null {
464
- return () => {
465
- if (!this.props.header) return null;
466
-
467
- return [
468
- jsx('div', {
469
- className: this.props.className.title,
470
- id: `${this.props.id}_title`,
471
- 'aria-label': this.props.text.title,
472
- children: this.props.text.title,
473
- }) as Node,
474
- this.props.showClose
475
- ? (jsx('button', {
476
- type: 'button',
477
- className: joinClasses(
478
- this.props.className.button,
479
- this.props.className.closeBtn
480
- ),
481
- 'data-action': 'close',
482
- 'aria-label': t('Close', locales),
483
- children: icon('close'),
484
- }) as Node)
485
- : null,
486
- ];
487
- };
488
- }
489
-
490
- footerView(): () => (Node | null)[] | null {
491
- return () => {
492
- if (!this.props.footer) return null;
493
-
494
- return [
495
- this.props.showCancel
496
- ? (jsx('button', {
497
- type: 'button',
498
- className: joinClasses(
499
- this.props.className.button,
500
- this.props.className.cancelBtn
501
- ),
502
- 'data-action': 'cancel',
503
- disabled: () => this.isBusy(),
504
- children: this.props.text.cancel,
505
- }) as Node)
506
- : null,
507
- jsx('button', {
508
- type: () => (this.state.mode === 'form' ? 'submit' : 'button'),
509
- className: joinClasses(
510
- this.props.className.button,
511
- this.props.className.confirmBtn
512
- ),
513
- 'data-action': () =>
514
- this.state.mode === 'form' ? 'submit' : 'confirm',
515
- disabled: () => this.isBusy(),
516
- children: this.props.text.confirm,
517
- }) as Node,
518
- ];
519
- };
520
- }
521
-
522
- bodyView(): RenderableContent<Modal> {
523
- if (this.state.mode === 'form') return this.formView();
524
- this.destroyForm();
525
- return this.contentView(this.state.content);
526
- }
527
-
528
- formView(): HTMLElement {
529
- return jsx('div', {
530
- ref: (element: HTMLElement) => {
531
- this.mountForm(element);
306
+ const ttl = normalizeTtl(props.ttl);
307
+ return !ttl || Date.now() - cache.updatedAt <= ttl;
308
+ };
309
+ const setContentCache = (
310
+ source: ModalContent,
311
+ content: ModalContentResult
312
+ ): void => {
313
+ if (!props.cache) return;
314
+ cache.content = content;
315
+ cache.contentSource = source;
316
+ cache.hasContent = true;
317
+ cache.updatedAt = Date.now();
318
+ };
319
+ const clearContentCache = (): void => {
320
+ cache.content = null;
321
+ cache.contentSource = null;
322
+ cache.hasContent = false;
323
+ cache.updatedAt = 0;
324
+ };
325
+ const clearEvents = (): void => {
326
+ for (const dispose of events.values()) dispose();
327
+ events.clear();
328
+ };
329
+ const listen = (
330
+ key: string,
331
+ target: EventTarget,
332
+ type: string,
333
+ listener: EventListener
334
+ ): void => {
335
+ events.get(key)?.();
336
+ target.addEventListener(type, listener);
337
+ events.set(key, () => target.removeEventListener(type, listener));
338
+ };
339
+ const lockScroll = (): void => {
340
+ if (scrollLocked) return;
341
+ if (modalScrollLockCount === 0) {
342
+ modalBodyOverflow = document.body.style.overflow;
343
+ document.body.style.overflow = 'hidden';
344
+ }
345
+ modalScrollLockCount += 1;
346
+ scrollLocked = true;
347
+ };
348
+ const unlockScroll = (): void => {
349
+ if (!scrollLocked) return;
350
+ modalScrollLockCount = Math.max(0, modalScrollLockCount - 1);
351
+ if (modalScrollLockCount === 0) {
352
+ document.body.style.overflow = modalBodyOverflow;
353
+ modalBodyOverflow = '';
354
+ }
355
+ scrollLocked = false;
356
+ };
357
+ const restoreFocus = (): void => {
358
+ const target = cache.previousActiveElement;
359
+ cache.previousActiveElement = null;
360
+ if (target && document.contains(target)) target.focus();
361
+ };
362
+ const motion = createMotionGroup(
363
+ createTransition(() => modal.element, {
364
+ keyframes: [{ opacity: 0 }, { opacity: 1 }],
365
+ options: {
366
+ duration: 220,
367
+ easing: 'ease',
532
368
  },
533
- }) as HTMLElement;
534
- }
535
-
536
- mountForm(container: HTMLElement | null): void {
537
- if (!container) return;
538
- this.dom.formContainer = container;
539
-
540
- const props = this.createFormProps();
541
- if (this.dom.form) {
542
- this.dom.form.setFields(props.fields || []);
543
- if (
544
- this.dom.form.dom.root &&
545
- !container.contains(this.dom.form.dom.root)
546
- ) {
547
- container.appendChild(this.dom.form.dom.root);
369
+ }),
370
+ createTransition(() => dialog, {
371
+ keyframes: [
372
+ { opacity: 0, transform: 'scale(0.96)' },
373
+ { opacity: 1, transform: 'scale(1)' },
374
+ ],
375
+ options: {
376
+ duration: 220,
377
+ easing: 'ease',
378
+ },
379
+ })
380
+ );
381
+ const presence = createPresence({
382
+ elements: () => [modal.element, dialog],
383
+ mount: () => {
384
+ modal.mount(document.body);
385
+ lockScroll();
386
+ },
387
+ activate: () => setMotionVisible(true),
388
+ deactivate: () => setMotionVisible(false),
389
+ motion,
390
+ unmount: () => {
391
+ modal.unmount();
392
+ unlockScroll();
393
+ restoreFocus();
394
+ },
395
+ });
396
+ const syncLoadingElement = (): void => {
397
+ const busy = state.loading || state.processing;
398
+ if (busy) {
399
+ if (!loadingElement) loadingElement = createLoading();
400
+ if (dialog && loadingElement.parentElement !== dialog) {
401
+ dialog.append(loadingElement);
548
402
  }
549
403
  return;
550
404
  }
551
-
552
- this.dom.form = createForm(props).build();
553
- if (this.dom.form.dom.root) container.appendChild(this.dom.form.dom.root);
554
- }
555
-
556
- createFormProps(): FormProps {
557
- return {
558
- id: this.cache.formId,
559
- fields: this.state.fields || [],
560
- buttons: false,
561
- onSubmit: (data) => this.handleFormSubmit(data),
562
- };
563
- }
564
-
565
- destroyForm(): void {
566
- this.dom.form?.destroy();
567
- this.dom.form = null;
568
- this.dom.formContainer = null;
569
- }
570
-
571
- contentView(content: ModalContent): Node[] {
572
- return normalizeContentNodes(content, this);
573
- }
574
-
575
- bindReactiveLoading(): void {
576
- let loading: HTMLElement | null = null;
577
-
578
- createEffect(() => {
579
- if (this.state.loading && !loading) {
580
- loading = createLoading();
581
- this.dom.modal?.appendChild(loading);
582
- } else if (!this.state.loading && loading) {
583
- loading.remove();
584
- loading = null;
585
- }
586
- });
587
-
588
- onCleanup(() => {
589
- loading?.remove();
590
- loading = null;
405
+ loadingElement?.remove();
406
+ loadingElement = null;
407
+ };
408
+ const setProcessing = (value: boolean): void => {
409
+ if (state.processing === value) return;
410
+ flushSync(() => {
411
+ state.processing = value;
591
412
  });
592
- }
593
-
594
- isBusy(): boolean {
595
- return !!(this.state.loading || this.state.processing);
596
- }
597
-
598
- bindReactiveVisibility(): void {
599
- this.cleanup.visibility = createRoot((dispose) => {
600
- createEffect(() => {
601
- const visible = !!this.state.visible;
602
- untrack(() => this.applyVisibility(visible));
413
+ };
414
+ const finishProcessing = (): void => {
415
+ if (state.visible && presence.phase !== 'leaving') {
416
+ setProcessing(false);
417
+ }
418
+ };
419
+ const loadContent = (source: ModalContent = state.content): void => {
420
+ if (isContentCacheValid(source)) {
421
+ contentLoadId += 1;
422
+ flushSync(() => {
423
+ setResolvedContent({ value: cache.content });
424
+ state.loading = false;
603
425
  });
604
- return dispose;
605
- });
606
- }
607
-
608
- applyVisibility(visible: boolean): void {
609
- if (visible === this.runtime.visibleApplied) return;
610
- if (visible) {
611
- this.showFromState();
612
426
  return;
613
427
  }
614
- this.hideFromState();
615
- }
616
-
617
- showFromState(): void {
618
- if (this.runtime.destroyed) {
619
- throw new Error('Modal: The current instance has been destroyed.');
620
- }
621
428
 
622
- if (!this.dom.root) {
623
- throw new Error('Modal.show: build() must be called before show().');
429
+ const loadId = ++contentLoadId;
430
+ if (typeof source !== 'function') {
431
+ clearContentCache();
432
+ flushSync(() => {
433
+ setResolvedContent({ value: source });
434
+ state.loading = false;
435
+ });
436
+ return;
624
437
  }
625
438
 
626
- this.cancelHideTimer();
627
- this.resetAnimationStyles();
628
-
629
- const { onShow, onShown } = this.props;
630
- void onShow?.(this);
631
-
632
- this.cache.previousActiveElement =
633
- document.activeElement instanceof HTMLElement
634
- ? document.activeElement
635
- : null;
636
- if (this.dom.root && !this.dom.root.parentNode)
637
- document.body.appendChild(this.dom.root);
638
- this.lockScroll();
639
- this.runtime.visibleApplied = true;
640
-
641
- this.bindEvents(this.dom.root);
642
- this.focusFirst();
643
-
644
- void onShown?.(this);
645
- }
646
-
647
- hideFromState(): void {
648
- if (!this.runtime.visibleApplied || !this.dom.root) return;
649
-
650
- const { onHide, onHidden } = this.props;
651
- void onHide?.(this);
652
-
653
- this.runtime.visibleApplied = false;
654
- this.clearEvents();
655
-
656
- flushSync(() => {
657
- this.state.loading = false;
658
- this.state.extraData = null;
659
- this.state.data = null;
660
- });
661
-
662
- if (this.dom.modal) {
663
- this.dom.modal.style.transition = `opacity ${HIDE_DURATION}ms ease-out, transform ${HIDE_DURATION}ms ease-out`;
664
- this.dom.modal.style.opacity = '0';
665
- this.dom.modal.style.transform = 'scale(0.3)';
439
+ let result: ModalContentResult | Promise<ModalContentResult>;
440
+ try {
441
+ result = source(modal);
442
+ } catch (error) {
443
+ console.error('Modal.content error:', error);
444
+ flushSync(() => {
445
+ setResolvedContent({ value: null });
446
+ state.loading = false;
447
+ });
448
+ return;
666
449
  }
667
-
668
- this.cancelHideTimer();
669
- this.cleanup.hideTimer = setTimeout(
670
- () => this.finishHide(onHidden),
671
- HIDE_DURATION
672
- );
673
- }
674
-
675
- bindEvents(root: Element | null): void {
676
- this.clearEvents();
677
- this.bindOverlayCloseEvent(root);
678
- this.bindDocumentKeyEvent();
679
- this.bindInsideEvent();
680
- }
681
-
682
- bindOverlayCloseEvent(root: Element | null): void {
683
- if (!root) return;
684
- this.cleanup.events.on('bg', root, 'click', (event) => {
685
- if (this.props.bgClose && event.target === root) {
686
- this.hide();
687
- }
688
- });
689
- }
690
-
691
- bindDocumentKeyEvent(): void {
692
- this.cleanup.events.on('keydown', document, 'keydown', (event) => {
693
- if (!this.state.visible || !(event instanceof KeyboardEvent)) return;
694
-
695
- if (event.key === 'Escape' && this.props.escClose) {
696
- event.preventDefault();
697
- this.hide();
698
- return;
699
- }
700
-
701
- if (event.key === 'Tab') this.trapFocus(event);
702
- });
703
- }
704
-
705
- bindInsideEvent(): void {
706
- if (!this.dom.modal) return;
707
-
708
- this.cleanup.events.on('inside', this.dom.modal, 'click', (event) => {
709
- const target = event.target instanceof Element ? event.target : null;
710
- const actionEl = target?.closest<HTMLElement>('[data-action]');
711
- if (!actionEl || !this.dom.modal?.contains(actionEl)) return;
712
-
713
- const action = actionEl.dataset.action;
714
- if (action === 'cancel' || action === 'close') {
715
- void this.handleCancel();
716
- return;
717
- }
718
-
719
- if (action === 'submit') {
720
- this.requestSubmit();
721
- return;
722
- }
723
-
724
- if (action === 'confirm') {
725
- if (this.state.mode === 'form') {
726
- this.requestSubmit();
727
- return;
728
- }
729
- void this.handleConfirm();
730
- }
731
- });
732
- }
733
-
734
- clearEvents(): void {
735
- this.cleanup.events.clear();
736
- }
737
-
738
- requestSubmit(): void {
739
- if (this.state.mode !== 'form' || !this.dom.form) {
740
- void this.handleConfirm();
450
+ if (!isPromiseLike<ModalContentResult>(result)) {
451
+ setContentCache(source, result);
452
+ flushSync(() => {
453
+ setResolvedContent({ value: result });
454
+ state.loading = false;
455
+ });
741
456
  return;
742
457
  }
743
458
 
744
- this.dom.form.requestSubmit();
745
- }
746
-
747
- async handleFormSubmit(formData: FormDataRecord): Promise<void> {
748
- if (this.isBusy()) return;
749
-
750
- const data = mergeExtraData(formData, this.state.extraData);
751
-
752
459
  flushSync(() => {
753
- this.state.data = data;
460
+ state.loading = true;
461
+ setResolvedContent({ value: null });
754
462
  });
755
-
756
- await this.handleSubmit(data);
757
- }
758
-
759
- async handleConfirm(): Promise<void> {
760
- if (this.isBusy()) return;
761
-
762
- flushSync(() => {
763
- this.state.processing = true;
764
- });
765
-
463
+ void Promise.resolve(result)
464
+ .then((content) => {
465
+ if (modal.runtime.destroyed || loadId !== contentLoadId) return;
466
+ setContentCache(source, content);
467
+ flushSync(() => {
468
+ setResolvedContent({ value: content });
469
+ });
470
+ })
471
+ .catch((error) => {
472
+ if (modal.runtime.destroyed || loadId !== contentLoadId) return;
473
+ console.error('Modal.content error:', error);
474
+ flushSync(() => {
475
+ setResolvedContent({ value: null });
476
+ });
477
+ })
478
+ .finally(() => {
479
+ if (!modal.runtime.destroyed && loadId === contentLoadId) {
480
+ flushSync(() => {
481
+ state.loading = false;
482
+ });
483
+ }
484
+ });
485
+ };
486
+ const handleConfirm = (): void => {
487
+ if (isBusy()) return;
488
+ let result: void | Promise<void> | undefined;
766
489
  try {
767
- await Promise.resolve(this.props.onConfirm?.(this));
490
+ result = props.onConfirm?.(modal);
768
491
  } catch (error) {
769
492
  console.error('Modal.onConfirm error:', error);
770
- } finally {
771
- if (this.state) {
772
- flushSync(() => {
773
- this.state.processing = false;
774
- });
775
- }
493
+ return;
776
494
  }
777
- }
778
-
779
- async handleCancel(): Promise<void> {
780
- if (this.isBusy()) return;
781
-
782
- flushSync(() => {
783
- this.state.processing = true;
784
- });
785
-
495
+ if (!isPromiseLike<void>(result)) return;
496
+ setProcessing(true);
497
+ void Promise.resolve(result)
498
+ .catch((error) => {
499
+ console.error('Modal.onConfirm error:', error);
500
+ })
501
+ .finally(() => {
502
+ if (!modal.runtime.destroyed) finishProcessing();
503
+ });
504
+ };
505
+ const handleCancel = (): void => {
506
+ if (isBusy()) return;
507
+ let result: void | Promise<void> | undefined;
786
508
  try {
787
- await Promise.resolve(this.props.onCancel?.(this));
788
- this.hide();
509
+ result = props.onCancel?.(modal);
789
510
  } catch (error) {
790
511
  console.error('Modal.onCancel error:', error);
791
- } finally {
792
- if (this.state) {
793
- flushSync(() => {
794
- this.state.processing = false;
795
- });
796
- }
512
+ return;
797
513
  }
798
- }
799
-
800
- async handleSubmit(data: FormDataRecord): Promise<void> {
801
- if (!this.props.onSubmit) return;
802
-
803
- flushSync(() => {
804
- this.state.processing = true;
805
- });
806
-
807
- try {
808
- await Promise.resolve(this.props.onSubmit(data, this));
809
- this.state.extraData = null;
810
- } catch (error) {
811
- console.error('Modal.onSubmit error:', error);
812
- } finally {
813
- if (this.state) {
814
- flushSync(() => {
815
- this.state.processing = false;
816
- });
817
- }
514
+ if (!isPromiseLike<void>(result)) {
515
+ modal.hide();
516
+ return;
818
517
  }
819
- }
820
-
821
- trapFocus(event: KeyboardEvent): void {
822
- if (!this.dom.modal) return;
823
- const focusable = Array.from(
824
- all<HTMLElement>(
825
- 'a[href], button:not([disabled]):not([data-action=close]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
826
- this.dom.modal
827
- )
518
+ setProcessing(true);
519
+ void Promise.resolve(result)
520
+ .then(() => {
521
+ if (modal.runtime.destroyed) return;
522
+ modal.hide();
523
+ })
524
+ .catch((error) => {
525
+ console.error('Modal.onCancel error:', error);
526
+ })
527
+ .finally(() => {
528
+ if (!modal.runtime.destroyed) {
529
+ finishProcessing();
530
+ }
531
+ });
532
+ };
533
+ const trapFocus = (event: KeyboardEvent): void => {
534
+ if (!dialog) return;
535
+ const focusable = all<HTMLElement>(
536
+ 'a[href], button:not([disabled]):not([data-action=close]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
537
+ dialog
828
538
  ).filter(
829
539
  (element) =>
830
540
  element.offsetParent !== null || element === document.activeElement
831
541
  );
832
-
833
- if (focusable.length === 0) {
542
+ if (!focusable.length) {
834
543
  event.preventDefault();
835
- this.dom.modal.focus();
544
+ dialog.focus();
836
545
  return;
837
546
  }
838
-
839
547
  const first = focusable[0];
840
548
  const last = focusable[focusable.length - 1];
841
-
842
549
  if (event.shiftKey && document.activeElement === first) {
843
550
  event.preventDefault();
844
551
  last?.focus();
@@ -846,192 +553,248 @@ class ModalComponent extends Component<
846
553
  event.preventDefault();
847
554
  first?.focus();
848
555
  }
849
- }
850
-
851
- focusFirst(): void {
852
- if (!this.dom.modal) return;
853
- const focusRoot = this.dom.form?.root || this.dom.modal;
854
- const firstFocusable = q<HTMLElement>(
556
+ };
557
+ const focusFirst = (): void => {
558
+ if (!dialog) return;
559
+ const first = q<HTMLElement>(
855
560
  'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([data-action=close]), [tabindex]:not([tabindex="-1"])',
856
- focusRoot
561
+ dialog
857
562
  );
858
- if (firstFocusable) firstFocusable.focus();
563
+ if (first) first.focus();
859
564
  else {
860
- this.dom.modal.setAttribute('tabindex', '-1');
861
- this.dom.modal.focus();
862
- }
863
- }
864
-
865
- lockScroll(): void {
866
- if (this.runtime.scrollLocked) return;
867
- if (modalScrollLockCount === 0) {
868
- modalBodyOverflow = document.body.style.overflow;
869
- document.body.style.overflow = 'hidden';
870
- }
871
- modalScrollLockCount += 1;
872
- this.runtime.scrollLocked = true;
873
- }
874
-
875
- unlockScroll(): void {
876
- if (!this.runtime.scrollLocked) return;
877
- modalScrollLockCount = Math.max(0, modalScrollLockCount - 1);
878
- if (modalScrollLockCount === 0) {
879
- document.body.style.overflow = modalBodyOverflow;
880
- modalBodyOverflow = '';
881
- }
882
- this.runtime.scrollLocked = false;
883
- }
884
-
885
- cancelHideTimer(): void {
886
- if (!this.cleanup.hideTimer) return;
887
- clearTimeout(this.cleanup.hideTimer);
888
- this.cleanup.hideTimer = null;
889
- }
890
-
891
- resetAnimationStyles(): void {
892
- if (!this.dom.modal) return;
893
- this.dom.modal.style.removeProperty('transition');
894
- this.dom.modal.style.removeProperty('opacity');
895
- this.dom.modal.style.removeProperty('transform');
896
- }
897
-
898
- finishHide(onHidden: ResolvedModalProps['onHidden']): void {
899
- this.cleanup.hideTimer = null;
900
- this.dom.root?.remove();
901
- this.resetAnimationStyles();
902
- this.unlockScroll();
903
- this.restoreFocus();
904
- void onHidden?.(this);
905
- }
906
-
907
- restoreFocus(): void {
908
- const target = this.cache.previousActiveElement;
909
- this.cache.previousActiveElement = null;
910
- if (
911
- target &&
912
- typeof target.focus === 'function' &&
913
- document.contains(target)
914
- ) {
915
- target.focus();
916
- }
917
- }
918
-
919
- assertActive(method: string): void {
920
- if (this.runtime.destroyed) {
921
- throw new Error(
922
- `Modal.${method}: The current instance has been destroyed.`
923
- );
924
- }
925
- }
926
-
927
- protected override normalizeStatePatch(
928
- patch: ModalStatePatch
929
- ): ModalStatePatch {
930
- const nextPatch: ModalStatePatch = { ...patch };
931
- if (Object.hasOwn(nextPatch, 'fields')) {
932
- nextPatch.fields = Array.isArray(nextPatch.fields)
933
- ? cloneFields(nextPatch.fields)
934
- : null;
565
+ dialog.setAttribute('tabindex', '-1');
566
+ dialog.focus();
935
567
  }
936
- return nextPatch;
937
- }
938
-
939
- protected override validateStatePatch(patch: ModalStatePatch): void {
940
- validateParam('state', patch, MODAL_UPDATE_RULE, 'Modal.setState');
941
-
942
- for (const key of Object.keys(patch)) {
943
- if (!Object.hasOwn(MODAL_STATE_SCHEMA, key)) {
944
- throw new Error(
945
- `Modal.setState: "${key}" is not a supported state key.`
946
- );
947
- }
948
- validateParam(
949
- key,
950
- patch[key as keyof ModalStatePatch],
951
- MODAL_STATE_SCHEMA[key as keyof typeof MODAL_STATE_SCHEMA],
952
- 'Modal.setState'
953
- );
954
- }
955
- }
956
-
957
- protected override afterSetState(patch: ModalStatePatch): void {
958
- if (Object.hasOwn(patch, 'fields')) this.cache.fieldIds?.clear();
959
- if (this.state.mode === 'form' && this.dom.form)
960
- this.dom.form.setFields(this.createFormProps().fields || []);
961
- }
962
-
963
- show(): this {
964
- this.assertActive('show');
965
- if (!this.dom.root) {
568
+ };
569
+ const bindVisibleEvents = (): void => {
570
+ const root = modal.element;
571
+ if (!root || !dialog) return;
572
+ clearEvents();
573
+ listen('root', root, 'click', (event) => {
574
+ if (props.bgClose && !isBusy() && event.target === root) modal.hide();
575
+ });
576
+ listen('dialog', dialog, 'click', (event) => {
577
+ const target = event.target instanceof Element ? event.target : null;
578
+ const action =
579
+ target?.closest<HTMLElement>('[data-action]')?.dataset.action;
580
+ if (action === 'cancel' || action === 'close') handleCancel();
581
+ else if (action === 'confirm') handleConfirm();
582
+ });
583
+ listen('keyboard', document, 'keydown', (event) => {
584
+ if (!(event instanceof KeyboardEvent) || !state.visible) return;
585
+ if (event.key === 'Escape' && props.escClose && !isBusy()) {
586
+ event.preventDefault();
587
+ modal.hide();
588
+ } else if (event.key === 'Tab') trapFocus(event);
589
+ });
590
+ };
591
+ const showFromState = (): void => {
592
+ if (presence.phase === 'visible' || presence.phase === 'entering') return;
593
+ if (!modal.element) {
966
594
  throw new Error('Modal.show: build() must be called before show().');
967
595
  }
968
- this.setState({
969
- visible: true,
970
- });
971
- return this;
972
- }
973
-
974
- hide(): this {
975
- this.assertActive('hide');
976
- this.setState({
977
- visible: false,
596
+ void props.onShow?.(modal);
597
+ cache.previousActiveElement =
598
+ document.activeElement instanceof HTMLElement
599
+ ? document.activeElement
600
+ : null;
601
+ const entering = presence.enter();
602
+ bindVisibleEvents();
603
+ focusFirst();
604
+ void entering.then((completed) => {
605
+ if (completed && !modal.runtime.destroyed) void props.onShown?.(modal);
978
606
  });
979
- return this;
980
- }
981
-
982
- reset(): this {
983
- this.cache.fieldIds?.clear();
984
- if (!this.cache.initial) return this;
985
-
986
- this.setState({
987
- mode: this.cache.initial.mode,
988
- content: this.cache.initial.content,
989
- fields: Array.isArray(this.cache.initial.fields)
990
- ? cloneFields(this.cache.initial.fields)
991
- : null,
992
- loading: false,
993
- processing: false,
994
- data: null,
995
- extraData: null,
607
+ };
608
+ const hideFromState = (): void => {
609
+ if (presence.phase === 'hidden' || presence.phase === 'leaving') return;
610
+ void props.onHide?.(modal);
611
+ clearEvents();
612
+ contentLoadId += 1;
613
+ void presence.leave().then((completed) => {
614
+ if (completed && !modal.runtime.destroyed) {
615
+ flushSync(() => {
616
+ state.loading = false;
617
+ state.processing = false;
618
+ if (!props.cache) setResolvedContent({ value: null });
619
+ });
620
+ void props.onHidden?.(modal);
621
+ }
996
622
  });
997
- return this;
998
- }
999
-
1000
- protected override onDestroy(): void {
1001
- const wasVisible = !!this.runtime.visibleApplied;
1002
- const { onHide, onHidden } = this.props;
1003
-
1004
- if (wasVisible) void onHide?.(this);
1005
-
1006
- this.cancelHideTimer();
1007
- this.clearEvents();
1008
- this.destroyForm();
1009
- this.unlockScroll();
1010
- this.cleanup.visibility?.();
1011
- this.cleanup.visibility = null;
1012
- this.cleanup.view?.();
1013
- this.cleanup.view = null;
1014
-
1015
- this.dom.root?.remove();
1016
- if (wasVisible) void onHidden?.(this);
1017
-
1018
- this.cache = {
1019
- initial: null,
1020
- fieldIds: null,
1021
- previousActiveElement: null,
1022
- formId: '',
1023
- };
1024
- }
1025
-
1026
- override destroy(): this {
1027
- if (this.runtime.destroyed) return this;
1028
- super.destroy();
1029
- return this;
1030
- }
1031
- }
623
+ };
1032
624
 
1033
- export type Modal = ModalComponent;
625
+ modal = defineComponent({
626
+ name: 'Modal',
627
+ props,
628
+ state,
629
+ actions: {
630
+ show() {
631
+ if (!modal.runtime.built) {
632
+ throw new Error('Modal.show: build() must be called before show().');
633
+ }
634
+ modal.setState('visible', true);
635
+ return modal;
636
+ },
637
+ hide() {
638
+ flushSync(() => {
639
+ state.visible = false;
640
+ });
641
+ return modal;
642
+ },
643
+ reset() {
644
+ modal.setState({
645
+ content: cache.initial.content,
646
+ loading: false,
647
+ processing: false,
648
+ });
649
+ clearContentCache();
650
+ setResolvedContent({ value: null });
651
+ return modal;
652
+ },
653
+ },
654
+ normalizeStatePatch(patch: ModalStatePatch) {
655
+ return {
656
+ ...patch,
657
+ };
658
+ },
659
+ validateStatePatch(patch: ModalStatePatch) {
660
+ validateParam('state', patch, MODAL_UPDATE_RULE, 'Modal.setState');
661
+ for (const key of Object.keys(patch)) {
662
+ if (!Object.hasOwn(MODAL_STATE_SCHEMA, key)) {
663
+ throw new Error(
664
+ `Modal.setState: "${key}" is not a supported state key.`
665
+ );
666
+ }
667
+ validateParam(
668
+ key,
669
+ patch[key as keyof ModalStatePatch],
670
+ MODAL_STATE_SCHEMA[key as keyof typeof MODAL_STATE_SCHEMA],
671
+ 'Modal.setState'
672
+ );
673
+ }
674
+ },
675
+ view: () => {
676
+ dialog = jsx('div', {
677
+ className: joinClasses(
678
+ props.className.modal,
679
+ props.fullscreen ? 'is-fullscreen' : ''
680
+ ),
681
+ id: props.id,
682
+ role: 'document',
683
+ 'data-modal-dialog': props.id,
684
+ 'data-mount': () => (motionVisible() ? 'true' : 'false'),
685
+ children: [
686
+ jsx('div', {
687
+ className: props.className.header,
688
+ hidden: () => !props.header || state.loading,
689
+ children: () =>
690
+ props.header && !state.loading
691
+ ? [
692
+ jsx('div', {
693
+ className: props.className.title,
694
+ id: `${props.id}_title`,
695
+ 'aria-label': props.text.title,
696
+ children: props.text.title,
697
+ }),
698
+ props.showClose
699
+ ? jsx('button', {
700
+ type: 'button',
701
+ className: joinClasses(
702
+ props.className.button,
703
+ props.className.closeBtn
704
+ ),
705
+ 'data-action': 'close',
706
+ 'aria-label': t('Close', locales),
707
+ disabled: isBusy,
708
+ children: icon('close'),
709
+ })
710
+ : null,
711
+ ]
712
+ : null,
713
+ }),
714
+ jsx('div', {
715
+ className: props.className.body,
716
+ 'data-modal-body': '',
717
+ children: () => {
718
+ return resolvedContent().value;
719
+ },
720
+ }),
721
+ jsx('div', {
722
+ className: props.className.footer,
723
+ hidden: () => !props.footer || state.loading,
724
+ children: () =>
725
+ props.footer && !state.loading
726
+ ? [
727
+ props.showCancel
728
+ ? jsx('button', {
729
+ type: 'button',
730
+ className: joinClasses(
731
+ props.className.button,
732
+ props.className.cancelBtn
733
+ ),
734
+ 'data-action': 'cancel',
735
+ disabled: isBusy,
736
+ children: props.text.cancel,
737
+ })
738
+ : null,
739
+ jsx('button', {
740
+ type: 'button',
741
+ className: joinClasses(
742
+ props.className.button,
743
+ props.className.confirmBtn
744
+ ),
745
+ 'data-action': 'confirm',
746
+ disabled: isBusy,
747
+ children: props.text.confirm,
748
+ }),
749
+ ]
750
+ : null,
751
+ }),
752
+ ],
753
+ }) as HTMLElement;
754
+ const root = createPopup({
755
+ className: props.className.layout,
756
+ position: props.position,
757
+ component: 'modal',
758
+ labelledby: props.header ? `${props.id}_title` : '',
759
+ content: dialog,
760
+ });
761
+ root.setAttribute('data-mount', 'false');
762
+ if (!props.header)
763
+ root.setAttribute('aria-label', props.text.title || 'Modal');
764
+ createEffect(() => {
765
+ root.setAttribute('data-mount', motionVisible() ? 'true' : 'false');
766
+ });
767
+ createEffect(syncLoadingElement);
768
+ createEffect(() => {
769
+ const visible = state.visible;
770
+ untrack(() => (visible ? showFromState() : hideFromState()));
771
+ });
772
+ createEffect(() => {
773
+ const visible = state.visible;
774
+ const content = state.content;
775
+ untrack(() => {
776
+ if (visible) loadContent(content);
777
+ else {
778
+ contentLoadId += 1;
779
+ }
780
+ });
781
+ void content;
782
+ });
783
+ return root;
784
+ },
785
+ onDestroy() {
786
+ const wasVisible = presence.phase !== 'hidden';
787
+ if (wasVisible) void props.onHide?.(modal);
788
+ presence.cancel();
789
+ clearEvents();
790
+ unlockScroll();
791
+ restoreFocus();
792
+ loadingElement?.remove();
793
+ loadingElement = null;
794
+ dialog = null;
795
+ if (wasVisible) void props.onHidden?.(modal);
796
+ },
797
+ }) as Modal;
1034
798
 
1035
- export function createModal(input: ModalProps = {}): Modal {
1036
- return new ModalComponent(input);
799
+ return modal;
1037
800
  }