vanilla-jui 1.3.9 → 1.3.11

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 (76) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.umd.js +1 -1
  3. package/dist/style.css +1 -1
  4. package/package.json +3 -2
  5. package/src/components/accordion.js +373 -0
  6. package/src/components/components.js +0 -0
  7. package/src/components/drop.js +437 -0
  8. package/src/components/flow.js +1133 -0
  9. package/src/components/form.js +585 -0
  10. package/src/components/icons.js +83 -0
  11. package/src/components/menu.js +422 -0
  12. package/src/components/modal.js +1174 -0
  13. package/src/components/offcanvas.js +332 -0
  14. package/src/components/pagination.js +536 -0
  15. package/src/components/parabola.js +295 -0
  16. package/src/components/sticky.js +271 -0
  17. package/src/components/swiper.js +1123 -0
  18. package/src/components/tabs.js +746 -0
  19. package/src/components/theme.js +353 -0
  20. package/src/components/toast.js +329 -0
  21. package/src/components/toc.js +226 -0
  22. package/src/components/tooltip.js +86 -0
  23. package/src/components/validator.js +586 -0
  24. package/src/core/Component.js +248 -0
  25. package/src/css/accordion.css +72 -0
  26. package/src/css/animation.css +177 -0
  27. package/src/css/avatar.css +62 -0
  28. package/src/css/badge.css +66 -0
  29. package/src/css/breadcrumb.css +106 -0
  30. package/src/css/button.css +296 -0
  31. package/src/css/card.css +151 -0
  32. package/src/css/color.css +1163 -0
  33. package/src/css/color_p3.css +1172 -0
  34. package/src/css/components.css +26 -0
  35. package/src/css/content.css +716 -0
  36. package/src/css/divider.css +9 -0
  37. package/src/css/drop.css +28 -0
  38. package/src/css/flow.css +86 -0
  39. package/src/css/font.css +23 -0
  40. package/src/css/form.css +919 -0
  41. package/src/css/gradient.css +224 -0
  42. package/src/css/icon.css +63 -0
  43. package/src/css/index.css +12 -0
  44. package/src/css/layout.css +782 -0
  45. package/src/css/loading.css +38 -0
  46. package/src/css/menu.css +564 -0
  47. package/src/css/modal.css +94 -0
  48. package/src/css/offcanvas.css +104 -0
  49. package/src/css/pagination.css +25 -0
  50. package/src/css/popup.css +69 -0
  51. package/src/css/skeleton.css +76 -0
  52. package/src/css/status.css +56 -0
  53. package/src/css/swiper.css +160 -0
  54. package/src/css/table.css +52 -0
  55. package/src/css/tabs.css +95 -0
  56. package/src/css/tag.css +97 -0
  57. package/src/css/theme-palette.css +59 -0
  58. package/src/css/tip.css +84 -0
  59. package/src/css/toast.css +154 -0
  60. package/src/css/toc.css +38 -0
  61. package/src/css/tokens.css +1295 -0
  62. package/src/css/toolbar.css +115 -0
  63. package/src/css/tooltip.css +19 -0
  64. package/src/icons/index.js +30 -0
  65. package/src/index.js +45 -0
  66. package/src/locales/index.js +45 -0
  67. package/src/locales/index.min.js +1 -0
  68. package/src/utilities/browser.js +111 -0
  69. package/src/utilities/cache.js +159 -0
  70. package/src/utilities/core.js +502 -0
  71. package/src/utilities/debug.js +103 -0
  72. package/src/utilities/dom.js +313 -0
  73. package/src/utilities/events.js +69 -0
  74. package/src/utilities/http.js +30 -0
  75. package/src/utilities/legacy-dom.js +179 -0
  76. package/src/utilities/storage.js +46 -0
@@ -0,0 +1,1174 @@
1
+ import {
2
+ createDeepStore,
3
+ createEffect,
4
+ createRoot,
5
+ flushSync,
6
+ jsx,
7
+ onCleanup,
8
+ render,
9
+ untrack,
10
+ } from 'vanilla-signal';
11
+
12
+ import Component from '../core/Component.js';
13
+ import {
14
+ hasOwn,
15
+ isPlainObject,
16
+ randomId,
17
+ resolveProps,
18
+ validateParam,
19
+ } from '../utilities/core.js';
20
+ import {
21
+ all,
22
+ q,
23
+ canUseDOM,
24
+ canRenderDOM,
25
+ createLoading,
26
+ isNode,
27
+ requireRenderDOM,
28
+ } from '../utilities/dom.js';
29
+ import { Form } from './form.js';
30
+ import { icon } from './icons.js';
31
+
32
+ const HIDE_DURATION = 300;
33
+
34
+ function cloneFields(fields) {
35
+ if (!Array.isArray(fields)) return [];
36
+ return fields.map((field) => ({
37
+ ...field,
38
+ options: Array.isArray(field.options)
39
+ ? field.options.map((option) =>
40
+ option && typeof option === 'object' ? { ...option } : option
41
+ )
42
+ : field.options,
43
+ }));
44
+ }
45
+
46
+ function cloneProps(props) {
47
+ return {
48
+ ...props,
49
+ fields: Array.isArray(props.fields)
50
+ ? cloneFields(props.fields)
51
+ : props.fields,
52
+ };
53
+ }
54
+
55
+ function isModalContent(content) {
56
+ return (
57
+ content == null ||
58
+ typeof content === 'string' ||
59
+ typeof content === 'function' ||
60
+ Array.isArray(content) ||
61
+ isNode(content)
62
+ );
63
+ }
64
+
65
+ function isUpdateProps(value) {
66
+ return isPlainObject(value);
67
+ }
68
+
69
+ function isFlowLike(value) {
70
+ return (
71
+ value == null ||
72
+ (typeof value === 'object' &&
73
+ typeof value.next === 'function' &&
74
+ typeof value.back === 'function' &&
75
+ typeof value.snapshot === 'function')
76
+ );
77
+ }
78
+
79
+ function hydrateFields(fields, data) {
80
+ if (!Array.isArray(fields) || !isPlainObject(data)) return fields;
81
+
82
+ return fields.map((field) => {
83
+ if (!field?.name || !hasOwn(data, field.name)) return field;
84
+ const value = data[field.name];
85
+
86
+ if (field.type === 'checkbox' || field.type === 'radio') {
87
+ const optionValue = field.value ?? 'on';
88
+ const checked = Array.isArray(value)
89
+ ? value.map(String).includes(String(optionValue))
90
+ : value === true || String(value) === String(optionValue);
91
+ return { ...field, checked };
92
+ }
93
+
94
+ return { ...field, value };
95
+ });
96
+ }
97
+
98
+ function createTextState(props) {
99
+ const text = isPlainObject(props?.text) ? props.text : {};
100
+ return {
101
+ title: typeof text.title === 'string' ? text.title : 'Tip',
102
+ confirm: typeof text.confirm === 'string' ? text.confirm : 'Confirm',
103
+ cancel: typeof text.cancel === 'string' ? text.cancel : 'Cancel',
104
+ };
105
+ }
106
+
107
+ function createModalState(props) {
108
+ return {
109
+ ...props,
110
+ fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
111
+ text: createTextState(props),
112
+ loading: false,
113
+ submitting: false,
114
+ visible: false,
115
+ data: null,
116
+ extraData: null,
117
+ };
118
+ }
119
+
120
+ function mergeExtraData(data, extraData) {
121
+ if (!extraData || typeof extraData !== 'object') return data;
122
+ return Object.assign(data, extraData);
123
+ }
124
+
125
+ function clonePlainObject(value) {
126
+ return isPlainObject(value) ? { ...value } : {};
127
+ }
128
+
129
+ const MODAL_CONTENT_RULE = {
130
+ validate: isModalContent,
131
+ message: 'expects string, Node, array, function or null.',
132
+ };
133
+
134
+ const MODAL_TEXT_RULE = {
135
+ default: {},
136
+ validate: (value) => isPlainObject(value),
137
+ message: 'expects an object with text fields.',
138
+ normalize: (value) => {
139
+ const text = isPlainObject(value) ? value : {};
140
+ return {
141
+ ...text,
142
+ title: typeof text.title === 'string' ? text.title : 'Tip',
143
+ confirm: typeof text.confirm === 'string' ? text.confirm : 'Confirm',
144
+ cancel: typeof text.cancel === 'string' ? text.cancel : 'Cancel',
145
+ };
146
+ },
147
+ };
148
+
149
+ const MODAL_FIELDS_RULE = { types: ['array', 'null'] };
150
+ const MODAL_PROPS_SCHEMA = {
151
+ content: { default: '', ...MODAL_CONTENT_RULE },
152
+ position: { default: 'center', type: 'string' },
153
+ showCancel: { default: true, type: 'boolean' },
154
+ showClose: { default: true, type: 'boolean' },
155
+ fullscreen: { default: false, type: 'boolean' },
156
+ flow: {
157
+ default: null,
158
+ validate: isFlowLike,
159
+ message: 'expects a Flow instance or null.',
160
+ },
161
+ text: MODAL_TEXT_RULE,
162
+ onShow: { default: null, types: ['function', 'null'] },
163
+ onShown: { default: null, types: ['function', 'null'] },
164
+ onHide: { default: null, types: ['function', 'null'] },
165
+ onHidden: { default: null, types: ['function', 'null'] },
166
+ onConfirm: { default: null, types: ['function', 'null'] },
167
+ onSubmit: { default: null, types: ['function', 'null'] },
168
+ onCancel: { default: null, types: ['function', 'null'] },
169
+ fields: { default: null, ...MODAL_FIELDS_RULE },
170
+ header: { default: true, type: 'boolean' },
171
+ footer: { default: true, type: 'boolean' },
172
+ style: { default: null, types: ['string', 'null', 'object'] },
173
+ id: {
174
+ default: null,
175
+ types: ['string', 'null'],
176
+ normalize: (value) => {
177
+ if (typeof value === 'string')
178
+ return value.trim() ? value.trim() : randomId();
179
+ if (value == null) return randomId();
180
+ return value;
181
+ },
182
+ },
183
+ escClose: { default: false, type: 'boolean' },
184
+ bgClose: { default: false, type: 'boolean' },
185
+ lazy: { default: false, type: 'boolean' },
186
+ };
187
+
188
+ const MODAL_STATE_SCHEMA = {
189
+ ...MODAL_PROPS_SCHEMA,
190
+ loading: { default: false, type: 'boolean' },
191
+ submitting: { default: false, type: 'boolean' },
192
+ visible: { default: false, type: 'boolean' },
193
+ data: { default: null, types: ['object', 'null'] },
194
+ extraData: { default: null, types: ['object', 'null'] },
195
+ };
196
+
197
+ const MODAL_UPDATE_BLOCKED_KEYS = new Set(['id', 'lazy']);
198
+
199
+ const MODAL_EXTRA_FIELDS_RULE = {
200
+ validate: (value) =>
201
+ !!value && typeof value === 'object' && !Array.isArray(value),
202
+ message: 'expects an object.',
203
+ };
204
+
205
+ const MODAL_UPDATE_RULE = {
206
+ validate: isUpdateProps,
207
+ message: 'expects a props object.',
208
+ };
209
+
210
+ let modalScrollLockCount = 0;
211
+ let modalBodyOverflow = '';
212
+
213
+ export class Modal extends Component {
214
+ constructor(input = {}) {
215
+ const props = resolveProps(input, MODAL_PROPS_SCHEMA, 'Modal');
216
+ super(props);
217
+
218
+ this.dom.modal = null;
219
+ this.dom.header = null;
220
+ this.dom.body = null;
221
+ this.dom.footer = null;
222
+ this.dom.form = null;
223
+ this.dom.formContainer = null;
224
+
225
+ this.cleanup.visibility = null;
226
+ this.cleanup.view = null;
227
+ this.cleanup.hideTimer = null;
228
+
229
+ this.runtime.scrollLocked = false;
230
+ this.runtime.visibleApplied = false;
231
+
232
+ this.cache = {
233
+ initial: cloneProps(this.props),
234
+ fieldIds: new Map(),
235
+ baseStyle: '',
236
+ previousActiveElement: null,
237
+ formId: `${this.props.id}_form`,
238
+ };
239
+
240
+ this.state = createDeepStore(createModalState(this.props));
241
+
242
+ this.init(props);
243
+ }
244
+
245
+ onInit() {
246
+ this.bindReactiveVisibility();
247
+ if (!this.state.lazy && canRenderDOM()) this.buildRoot();
248
+ }
249
+
250
+ buildRoot() {
251
+ if (this.root) return this.root;
252
+
253
+ const { id } = this.state;
254
+ const dialogChildren = [
255
+ jsx('div', {
256
+ className: 'modal-header',
257
+ style: () => ({ display: this.state.header ? '' : 'none' }),
258
+ ref: (element) => {
259
+ this.dom.header = element;
260
+ },
261
+ }),
262
+ jsx('div', {
263
+ className: 'modal-body',
264
+ ref: (element) => {
265
+ this.dom.body = element;
266
+ },
267
+ }),
268
+ jsx('div', {
269
+ className: 'modal-footer',
270
+ style: () => ({ display: this.state.footer ? '' : 'none' }),
271
+ ref: (element) => {
272
+ this.dom.footer = element;
273
+ },
274
+ }),
275
+ ];
276
+
277
+ const modal = jsx('div', {
278
+ className: () =>
279
+ this.state.fullscreen ? 'j-modal is-fullscreen' : 'j-modal',
280
+ id,
281
+ role: 'document',
282
+ ref: (element) => {
283
+ this.dom.modal = element;
284
+ this.applyStyle(element, this.state.style);
285
+ },
286
+ children: dialogChildren,
287
+ });
288
+
289
+ const root = jsx('div', {
290
+ className: () => `j-popup-layout is-${this.state.position}`,
291
+ role: 'dialog',
292
+ 'aria-modal': 'true',
293
+ 'aria-labelledby': () => (this.state.header ? `${id}_title` : null),
294
+ 'aria-label': () =>
295
+ this.state.header ? null : this.state.text?.title || 'Modal',
296
+ children: modal,
297
+ });
298
+
299
+ this.root = root;
300
+ this.mountView();
301
+ return root;
302
+ }
303
+
304
+ mountView() {
305
+ if (this.cleanup.view || !this.dom.body) return;
306
+
307
+ this.cleanup.view = createRoot((dispose) => {
308
+ if (this.dom.header) {
309
+ const headerDispose = render(() => this.headerView(), this.dom.header);
310
+ onCleanup(headerDispose);
311
+ }
312
+
313
+ render(() => this.bodyView(), this.dom.body);
314
+
315
+ if (this.dom.footer) {
316
+ const footerDispose = render(() => this.footerView(), this.dom.footer);
317
+ onCleanup(footerDispose);
318
+ }
319
+
320
+ this.bindReactiveLoading();
321
+ this.bindReactiveStyle();
322
+ return dispose;
323
+ });
324
+ }
325
+
326
+ headerView() {
327
+ return () => {
328
+ if (!this.state.header) return null;
329
+
330
+ return [
331
+ jsx('div', {
332
+ className: 'modal-title',
333
+ id: `${this.state.id}_title`,
334
+ children: () => this.state.text?.title,
335
+ }),
336
+ this.state.showClose
337
+ ? jsx('button', {
338
+ type: 'button',
339
+ className: 'modal-close j-button is-icon is-ghost is-sm',
340
+ 'data-action': 'close',
341
+ 'aria-label': 'close',
342
+ children: icon('close'),
343
+ })
344
+ : null,
345
+ ];
346
+ };
347
+ }
348
+
349
+ footerView() {
350
+ return () => {
351
+ if (!this.state.footer) return null;
352
+
353
+ return [
354
+ this.state.showCancel
355
+ ? jsx('button', {
356
+ type: 'button',
357
+ className: 'j-button is-ghost modal-cancel',
358
+ 'data-action': 'close',
359
+ 'aria-label': 'close',
360
+ disabled: () => this.isBusy(),
361
+ children: () => this.state.text?.cancel,
362
+ })
363
+ : null,
364
+ jsx('button', {
365
+ type: () => (this.isFormMode() ? 'submit' : 'button'),
366
+ form: () => (this.isFormMode() ? this.cache.formId : null),
367
+ className: 'j-button is-primary modal-confirm',
368
+ 'data-action': this.isFormMode() ? 'submit' : 'confirm',
369
+ disabled: () => this.isBusy(),
370
+ children: () => this.state.text?.confirm,
371
+ }),
372
+ ];
373
+ };
374
+ }
375
+
376
+ bodyView() {
377
+ if (this.isFormMode()) return this.formView();
378
+ this.destroyForm();
379
+ return this.contentView(this.state.content);
380
+ }
381
+
382
+ formView() {
383
+ return jsx('div', {
384
+ className: 'modal-form-container',
385
+ ref: (element) => {
386
+ this.mountForm(element);
387
+ },
388
+ });
389
+ }
390
+
391
+ mountForm(container) {
392
+ if (!container) return;
393
+ this.dom.formContainer = container;
394
+
395
+ const props = this.createFormProps();
396
+ if (this.dom.form) {
397
+ this.dom.form.update(props);
398
+ return;
399
+ }
400
+
401
+ this.dom.form = new Form(props, container).build();
402
+ }
403
+
404
+ createFormProps() {
405
+ return {
406
+ id: this.cache.formId,
407
+ fields: this.state.fields || [],
408
+ buttons: false,
409
+ onSubmit: (data) => this.handleFormSubmit(data),
410
+ };
411
+ }
412
+
413
+ destroyForm() {
414
+ this.dom.form?.destroy();
415
+ this.dom.form = null;
416
+ this.dom.formContainer = null;
417
+ }
418
+
419
+ contentView(content) {
420
+ if (typeof content === 'function') return content(this);
421
+ if (Array.isArray(content) || isNode(content)) return content;
422
+ if (content == null) return '';
423
+ if (typeof content !== 'string') return '';
424
+
425
+ // return Array.from(html(content).childNodes);
426
+ const template = document.createElement('template');
427
+ template.innerHTML = content;
428
+ return Array.from(template.content.childNodes);
429
+ }
430
+
431
+ bindReactiveLoading() {
432
+ let loading = null;
433
+
434
+ createEffect(() => {
435
+ if (this.state.loading && !loading) {
436
+ loading = createLoading();
437
+ this.dom.modal.appendChild(loading);
438
+ } else if (!this.state.loading && loading) {
439
+ loading.remove();
440
+ loading = null;
441
+ }
442
+ });
443
+
444
+ onCleanup(() => {
445
+ loading?.remove();
446
+ loading = null;
447
+ });
448
+ }
449
+
450
+ bindReactiveStyle() {
451
+ createEffect(() => {
452
+ const style = this.state.style;
453
+ if (this.dom.modal) this.applyStyle(this.dom.modal, style);
454
+ });
455
+ }
456
+
457
+ isFormMode() {
458
+ return Array.isArray(this.state.fields);
459
+ }
460
+
461
+ isBusy() {
462
+ return !!(this.state.loading || this.state.submitting);
463
+ }
464
+
465
+ validatePropsPatch(patch, namespace = 'Modal.update') {
466
+ validateParam('props', patch, MODAL_UPDATE_RULE, namespace);
467
+
468
+ for (const key of Object.keys(patch)) {
469
+ if (!hasOwn(MODAL_PROPS_SCHEMA, key)) {
470
+ throw new Error(
471
+ `Validator: ${namespace}.${key} is not a supported modal prop.`
472
+ );
473
+ }
474
+ if (MODAL_UPDATE_BLOCKED_KEYS.has(key)) {
475
+ throw new Error(
476
+ `Modal.update: "${key}" cannot be updated after initialization.`
477
+ );
478
+ }
479
+ validateParam(key, patch[key], MODAL_PROPS_SCHEMA[key], namespace);
480
+ }
481
+ }
482
+
483
+ applyProps(patch, { validate = true, force = false } = {}) {
484
+ if (validate) this.validatePropsPatch(patch);
485
+ if (!patch || Object.keys(patch).length === 0) return this;
486
+
487
+ const hasFields = hasOwn(patch, 'fields');
488
+ const hasContent = hasOwn(patch, 'content');
489
+
490
+ if (hasContent) {
491
+ if (!hasFields && this.isFormMode() && !force) {
492
+ throw new Error(
493
+ 'Modal.update: Cannot update content when fields are defined.'
494
+ );
495
+ }
496
+ }
497
+
498
+ const nextProps = Object.assign({}, this.props, patch);
499
+ if (hasOwn(patch, 'text')) {
500
+ nextProps.text = Object.assign({}, this.props.text || {}, patch.text);
501
+ }
502
+
503
+ this.props = nextProps;
504
+ super.update(patch, { force });
505
+ this.props = nextProps;
506
+
507
+ const statePatch = {};
508
+ if (hasFields) {
509
+ this.cache.fieldIds.clear();
510
+ statePatch.fields = Array.isArray(patch.fields)
511
+ ? cloneFields(patch.fields)
512
+ : null;
513
+ }
514
+
515
+ for (const [key, value] of Object.entries(patch)) {
516
+ if (key === 'fields' || key === 'text') continue;
517
+ statePatch[key] = value;
518
+ }
519
+
520
+ const shouldRefreshText = hasOwn(patch, 'text');
521
+
522
+ if (shouldRefreshText) {
523
+ statePatch.text = createTextState(this.props);
524
+ }
525
+
526
+ flushSync(() => {
527
+ for (const [key, value] of Object.entries(statePatch)) {
528
+ this.state[key] = value;
529
+ }
530
+ });
531
+
532
+ if (hasOwn(patch, 'style') && this.dom.modal) {
533
+ this.applyStyle(this.dom.modal, this.state.style);
534
+ }
535
+
536
+ if (
537
+ this.state.visible &&
538
+ (hasOwn(patch, 'bgClose') || hasOwn(patch, 'escClose'))
539
+ ) {
540
+ this.bindEvents(this.root);
541
+ }
542
+
543
+ if (this.dom.form && hasFields) {
544
+ this.dom.form.update(this.createFormProps());
545
+ }
546
+
547
+ return this;
548
+ }
549
+
550
+ applyStyle(element, style) {
551
+ element.removeAttribute('style');
552
+ if (!style) {
553
+ this.cache.baseStyle = '';
554
+ return;
555
+ }
556
+ if (typeof style === 'string') {
557
+ element.style.cssText = style;
558
+ this.cache.baseStyle = element.getAttribute('style') || '';
559
+ return;
560
+ }
561
+ if (typeof style === 'object') {
562
+ Object.entries(style).forEach(([key, value]) => {
563
+ if (value == null) return;
564
+ const name = key.startsWith('--')
565
+ ? key
566
+ : key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
567
+ element.style.setProperty(name, String(value));
568
+ });
569
+ }
570
+ this.cache.baseStyle = element.getAttribute('style') || '';
571
+ }
572
+
573
+ // normalizeIcon(element) {
574
+ // if (!(element instanceof Element)) return;
575
+ // const iconElement = q('.modal-close', element);
576
+ // if (iconElement) {
577
+ // iconElement.style.transform = `translateX(${iconElement.clientHeight / 3}px)`;
578
+ // }
579
+ // }
580
+
581
+ bindReactiveVisibility() {
582
+ this.cleanup.visibility = createRoot((dispose) => {
583
+ createEffect(() => {
584
+ const visible = !!this.state.visible;
585
+ untrack(() => this.applyVisibility(visible));
586
+ });
587
+ return dispose;
588
+ });
589
+ }
590
+
591
+ applyVisibility(visible) {
592
+ if (visible === this.runtime.visibleApplied) return;
593
+ if (visible) {
594
+ this.showFromState();
595
+ return;
596
+ }
597
+ this.hideFromState();
598
+ }
599
+
600
+ showFromState() {
601
+ if (this.runtime.destroyed) {
602
+ throw new Error('Modal: The current instance has been destroyed.');
603
+ }
604
+
605
+ requireRenderDOM('Modal.show');
606
+
607
+ if (!this.root) this.buildRoot();
608
+
609
+ this.cancelHideTimer();
610
+ this.resetAnimationStyles();
611
+
612
+ const { onShow, onShown } = this.state;
613
+ if (onShow) onShow();
614
+
615
+ this.cache.previousActiveElement = document.activeElement;
616
+ if (!this.root.parentNode) document.body.appendChild(this.root);
617
+ this.lockScroll();
618
+ this.runtime.visibleApplied = true;
619
+
620
+ // this.normalizeIcon(this.dom.modal);
621
+ this.bindEvents(this.root);
622
+ this.focusFirst();
623
+
624
+ if (onShown) onShown();
625
+ }
626
+
627
+ hideFromState() {
628
+ if (!this.runtime.visibleApplied || !this.root) return;
629
+
630
+ const { onHide, onHidden } = this.state;
631
+ if (onHide) onHide();
632
+
633
+ this.runtime.visibleApplied = false;
634
+ this.clearEvents();
635
+
636
+ flushSync(() => {
637
+ this.state.loading = false;
638
+ this.state.extraData = null;
639
+ this.state.data = null;
640
+ });
641
+
642
+ if (this.dom.modal) {
643
+ this.dom.modal.style.transition = `opacity ${HIDE_DURATION}ms ease-out, transform ${HIDE_DURATION}ms ease-out`;
644
+ this.dom.modal.style.opacity = '0';
645
+ this.dom.modal.style.transform = 'scale(0.3)';
646
+ }
647
+
648
+ this.cancelHideTimer();
649
+ this.cleanup.hideTimer = setTimeout(
650
+ () => this.finishHide(onHidden),
651
+ HIDE_DURATION
652
+ );
653
+ }
654
+
655
+ bindEvents(root) {
656
+ this.clearEvents();
657
+ this.bindOverlayCloseEvent(root);
658
+ this.bindDocumentKeyEvent();
659
+ this.bindInsideEvent();
660
+ }
661
+
662
+ bindOverlayCloseEvent(root) {
663
+ if (!root) return;
664
+ this.cleanup.events.on('bg', root, 'click', (event) => {
665
+ if (this.state.bgClose && event.target === root) {
666
+ this.hide();
667
+ }
668
+ });
669
+ }
670
+
671
+ bindDocumentKeyEvent() {
672
+ this.cleanup.events.on('keydown', document, 'keydown', (event) => {
673
+ if (!this.state.visible) return;
674
+
675
+ if (event.key === 'Escape' && this.state.escClose) {
676
+ event.preventDefault();
677
+ this.hide();
678
+ return;
679
+ }
680
+
681
+ if (event.key === 'Tab') this.trapFocus(event);
682
+ });
683
+ }
684
+
685
+ bindInsideEvent() {
686
+ if (!this.dom.modal) return;
687
+
688
+ this.cleanup.events.on('inside', this.dom.modal, 'click', (event) => {
689
+ const target = event.target instanceof Element ? event.target : null;
690
+ const actionEl = target?.closest('[data-action]');
691
+ if (!actionEl || !this.dom.modal.contains(actionEl)) return;
692
+
693
+ const action = actionEl.dataset.action;
694
+ if (
695
+ action === 'cancel' ||
696
+ action === 'close' ||
697
+ actionEl.classList.contains('modal-close')
698
+ ) {
699
+ void this.handleCancel();
700
+ return;
701
+ }
702
+
703
+ if (action === 'submit') {
704
+ this.requestSubmit();
705
+ return;
706
+ }
707
+
708
+ if (action === 'back') {
709
+ void this.handleBack();
710
+ return;
711
+ }
712
+
713
+ if (action === 'next') {
714
+ void this.handleNext();
715
+ return;
716
+ }
717
+
718
+ if (action === 'confirm') {
719
+ if (this.isFormMode()) {
720
+ if (actionEl.classList.contains('modal-confirm')) return;
721
+ this.requestSubmit();
722
+ return;
723
+ }
724
+ void this.handleConfirm();
725
+ }
726
+ });
727
+ }
728
+
729
+ clearEvents() {
730
+ this.cleanup.events.clear();
731
+ }
732
+
733
+ requestSubmit() {
734
+ if (!this.dom.form) {
735
+ void this.handleConfirm();
736
+ return;
737
+ }
738
+
739
+ this.dom.form.requestSubmit();
740
+ }
741
+
742
+ async handleFormSubmit(formData) {
743
+ if (this.isBusy()) return;
744
+
745
+ const data = mergeExtraData(formData, this.state.extraData);
746
+
747
+ flushSync(() => {
748
+ this.state.data = data;
749
+ });
750
+
751
+ await this.handleSubmit(data);
752
+ }
753
+
754
+ async handleNext() {
755
+ if (this.isBusy()) return;
756
+ if (!this.hasFlow()) return;
757
+ await this.moveFlow('next');
758
+ }
759
+
760
+ async handleBack() {
761
+ if (this.isBusy()) return;
762
+ if (!this.hasFlow()) return;
763
+ await this.moveFlow('back');
764
+ }
765
+
766
+ hasFlow() {
767
+ return isFlowLike(this.state.flow) && !!this.state.flow;
768
+ }
769
+
770
+ async moveFlow(direction) {
771
+ const payload = this.createFlowPayload();
772
+ if (payload === false) return;
773
+
774
+ flushSync(() => {
775
+ this.state.submitting = true;
776
+ });
777
+
778
+ try {
779
+ const snapshot = await this.state.flow[direction](payload);
780
+ this.syncFlowView(this.state.flow, snapshot);
781
+ } catch (error) {
782
+ console.error(`Modal.flow.${direction} error:`, error);
783
+ this.syncFlowView(this.state.flow);
784
+ } finally {
785
+ if (this.state) {
786
+ flushSync(() => {
787
+ this.state.submitting = false;
788
+ });
789
+ }
790
+ }
791
+ }
792
+
793
+ createFlowPayload() {
794
+ if (!this.isFormMode()) return null;
795
+
796
+ const form = this.dom.form;
797
+ if (!form) return this.state.data || null;
798
+ if (!form.validate()) return false;
799
+
800
+ const data = mergeExtraData(form.collectData(), this.state.extraData);
801
+ flushSync(() => {
802
+ this.state.data = data;
803
+ });
804
+
805
+ return this.state.data;
806
+ }
807
+
808
+ resolveFlowModalView(flow, snapshot, step) {
809
+ const source = step?.modal ?? step?.view;
810
+ if (typeof source === 'function') {
811
+ const result = source({
812
+ flow,
813
+ snapshot,
814
+ step,
815
+ modal: this,
816
+ data: snapshot?.data ?? null,
817
+ currentData: snapshot?.currentData ?? null,
818
+ });
819
+ return isPlainObject(result) ? result : {};
820
+ }
821
+ return clonePlainObject(source);
822
+ }
823
+
824
+ syncFlowView(flow, snapshot = null) {
825
+ if (!flow || !this.state) return;
826
+ const state = flow.snapshot() || snapshot;
827
+ const step =
828
+ flow.currentStep || state?.currentStep || snapshot?.currentStep;
829
+ const modalView = this.resolveFlowModalView(flow, state, step);
830
+ const nextText = isPlainObject(modalView.text) ? modalView.text : {};
831
+ const shouldInjectStepTitle =
832
+ (!hasOwn(modalView, 'text') || !hasOwn(nextText, 'title')) &&
833
+ step?.title != null;
834
+
835
+ const patch = {
836
+ ...(shouldInjectStepTitle
837
+ ? {
838
+ text: {
839
+ ...nextText,
840
+ title: step.title,
841
+ },
842
+ }
843
+ : {}),
844
+ ...(!hasOwn(modalView, 'content') && !hasOwn(modalView, 'fields')
845
+ ? { content: step?.content ?? null }
846
+ : {}),
847
+ ...modalView,
848
+ };
849
+
850
+ if (Array.isArray(patch.fields)) {
851
+ patch.fields = hydrateFields(patch.fields, state?.currentData);
852
+ }
853
+ if (!hasOwn(patch, 'fields') && hasOwn(patch, 'content')) {
854
+ patch.fields = null;
855
+ }
856
+
857
+ this.update(patch);
858
+ }
859
+
860
+ async handleConfirm() {
861
+ if (this.isBusy()) return;
862
+
863
+ flushSync(() => {
864
+ this.state.submitting = true;
865
+ });
866
+
867
+ try {
868
+ if (this.state.onConfirm) await Promise.resolve(this.state.onConfirm());
869
+ this.hide();
870
+ } catch (error) {
871
+ console.error('Modal.onConfirm error:', error);
872
+ } finally {
873
+ if (this.state) {
874
+ flushSync(() => {
875
+ this.state.submitting = false;
876
+ });
877
+ }
878
+ }
879
+ }
880
+
881
+ async handleCancel() {
882
+ if (this.isBusy()) return;
883
+
884
+ flushSync(() => {
885
+ this.state.submitting = true;
886
+ });
887
+
888
+ try {
889
+ if (this.state.onCancel) await Promise.resolve(this.state.onCancel(this));
890
+ this.hide();
891
+ } catch (error) {
892
+ console.error('Modal.onCancel error:', error);
893
+ } finally {
894
+ if (this.state) {
895
+ flushSync(() => {
896
+ this.state.submitting = false;
897
+ });
898
+ }
899
+ }
900
+ }
901
+
902
+ async handleSubmit(data) {
903
+ if (!this.state.onSubmit) return;
904
+
905
+ flushSync(() => {
906
+ this.state.submitting = true;
907
+ });
908
+
909
+ try {
910
+ await Promise.resolve(this.state.onSubmit(data));
911
+ this.state.extraData = null;
912
+ } catch (error) {
913
+ console.error('Modal.onSubmit error:', error);
914
+ } finally {
915
+ if (this.state) {
916
+ flushSync(() => {
917
+ this.state.submitting = false;
918
+ });
919
+ }
920
+ }
921
+ }
922
+
923
+ trapFocus(event) {
924
+ if (!this.dom.modal) return;
925
+ const focusable = Array.from(
926
+ all(
927
+ 'a[href], button:not([disabled]):not([data-action=close]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
928
+ this.dom.modal
929
+ )
930
+ ).filter(
931
+ (element) =>
932
+ element.offsetParent !== null || element === document.activeElement
933
+ );
934
+
935
+ if (focusable.length === 0) {
936
+ event.preventDefault();
937
+ this.dom.modal.focus();
938
+ return;
939
+ }
940
+
941
+ const first = focusable[0];
942
+ const last = focusable[focusable.length - 1];
943
+
944
+ if (event.shiftKey && document.activeElement === first) {
945
+ event.preventDefault();
946
+ last.focus();
947
+ } else if (!event.shiftKey && document.activeElement === last) {
948
+ event.preventDefault();
949
+ first.focus();
950
+ }
951
+ }
952
+
953
+ focusFirst() {
954
+ if (!this.dom.modal) return;
955
+ const focusRoot = this.dom.form?.root || this.dom.modal;
956
+ const firstFocusable = q(
957
+ 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]):not([data-action=close]), [tabindex]:not([tabindex="-1"])',
958
+ focusRoot
959
+ );
960
+ if (firstFocusable) firstFocusable.focus();
961
+ else {
962
+ this.dom.modal.setAttribute('tabindex', '-1');
963
+ this.dom.modal.focus();
964
+ }
965
+ }
966
+
967
+ lockScroll() {
968
+ if (this.runtime.scrollLocked || !canUseDOM()) return;
969
+ if (modalScrollLockCount === 0) {
970
+ modalBodyOverflow = document.body.style.overflow;
971
+ document.body.style.overflow = 'hidden';
972
+ }
973
+ modalScrollLockCount += 1;
974
+ this.runtime.scrollLocked = true;
975
+ }
976
+
977
+ unlockScroll() {
978
+ if (!this.runtime.scrollLocked || !canUseDOM()) return;
979
+ modalScrollLockCount = Math.max(0, modalScrollLockCount - 1);
980
+ if (modalScrollLockCount === 0) {
981
+ document.body.style.overflow = modalBodyOverflow;
982
+ modalBodyOverflow = '';
983
+ }
984
+ this.runtime.scrollLocked = false;
985
+ }
986
+
987
+ cancelHideTimer() {
988
+ if (!this.cleanup.hideTimer) return;
989
+ clearTimeout(this.cleanup.hideTimer);
990
+ this.cleanup.hideTimer = null;
991
+ }
992
+
993
+ resetAnimationStyles() {
994
+ if (!this.dom.modal) return;
995
+ if (this.cache.baseStyle)
996
+ this.dom.modal.setAttribute('style', this.cache.baseStyle);
997
+ else this.dom.modal.removeAttribute('style');
998
+ }
999
+
1000
+ finishHide(onHidden) {
1001
+ this.cleanup.hideTimer = null;
1002
+ if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
1003
+ this.resetAnimationStyles();
1004
+ this.unlockScroll();
1005
+ this.restoreFocus();
1006
+ if (onHidden) onHidden();
1007
+ }
1008
+
1009
+ restoreFocus() {
1010
+ const target = this.cache.previousActiveElement;
1011
+ this.cache.previousActiveElement = null;
1012
+ if (
1013
+ target &&
1014
+ typeof target.focus === 'function' &&
1015
+ document.contains(target)
1016
+ ) {
1017
+ target.focus();
1018
+ }
1019
+ }
1020
+
1021
+ assertActive(method) {
1022
+ if (this.runtime.destroyed) {
1023
+ throw new Error(
1024
+ `Modal.${method}: The current instance has been destroyed.`
1025
+ );
1026
+ }
1027
+ }
1028
+
1029
+ validateStatePatch(patch) {
1030
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1031
+ throw new Error('Modal.setState: expects a plain object patch.');
1032
+ }
1033
+
1034
+ for (const key of Object.keys(patch)) {
1035
+ if (!hasOwn(MODAL_STATE_SCHEMA, key)) {
1036
+ throw new Error(
1037
+ `Modal.setState: "${key}" is not a supported state key.`
1038
+ );
1039
+ }
1040
+ if (MODAL_UPDATE_BLOCKED_KEYS.has(key)) {
1041
+ throw new Error(
1042
+ `Modal.setState: "${key}" cannot be updated after initialization.`
1043
+ );
1044
+ }
1045
+ validateParam(key, patch[key], MODAL_STATE_SCHEMA[key], 'Modal.setState');
1046
+ }
1047
+ }
1048
+
1049
+ setState(patch = {}) {
1050
+ this.assertActive('setState');
1051
+ this.validateStatePatch(patch);
1052
+
1053
+ flushSync(() => {
1054
+ for (const [key, value] of Object.entries(patch)) {
1055
+ if (key === 'fields') this.cache.fieldIds.clear();
1056
+ this.state[key] =
1057
+ key === 'fields' && Array.isArray(value) ? cloneFields(value) : value;
1058
+ }
1059
+ });
1060
+ return this;
1061
+ }
1062
+
1063
+ show() {
1064
+ this.assertActive('show');
1065
+ flushSync(() => {
1066
+ this.state.visible = true;
1067
+ });
1068
+ return this;
1069
+ }
1070
+
1071
+ hide() {
1072
+ this.assertActive('hide');
1073
+ flushSync(() => {
1074
+ this.state.visible = false;
1075
+ });
1076
+ return this;
1077
+ }
1078
+
1079
+ setFields(data, force = false) {
1080
+ validateParam('data', data, MODAL_FIELDS_RULE, 'Modal.setFields');
1081
+ this.applyProps({ fields: data }, { validate: false, force });
1082
+ return this;
1083
+ }
1084
+
1085
+ addFields(data) {
1086
+ validateParam('data', data, MODAL_EXTRA_FIELDS_RULE, 'Modal.addFields');
1087
+ flushSync(() => {
1088
+ this.state.extraData = data;
1089
+ });
1090
+ return this;
1091
+ }
1092
+
1093
+ setContent(content, force = false) {
1094
+ validateParam('content', content, MODAL_CONTENT_RULE, 'Modal.setContent');
1095
+
1096
+ if (this.isFormMode() && !force) {
1097
+ throw new Error(
1098
+ 'Modal.setContent: Cannot setContent when fields are defined.'
1099
+ );
1100
+ }
1101
+
1102
+ this.applyProps({ content }, { validate: false, force });
1103
+ return this;
1104
+ }
1105
+
1106
+ update(patch = {}, force = false) {
1107
+ return this.applyProps(patch, { validate: true, force });
1108
+ }
1109
+
1110
+ reset() {
1111
+ this.cache.fieldIds.clear();
1112
+
1113
+ const initialProps = cloneProps(this.cache.initial);
1114
+ delete initialProps.id;
1115
+ delete initialProps.lazy;
1116
+ this.props = cloneProps(initialProps);
1117
+ this.applyProps(initialProps, { validate: false });
1118
+ flushSync(() => {
1119
+ this.state.data = null;
1120
+ this.state.extraData = null;
1121
+ });
1122
+ return this;
1123
+ }
1124
+
1125
+ resetContent() {
1126
+ return this.setContent(this.cache.initial?.content ?? '');
1127
+ }
1128
+
1129
+ resetFields() {
1130
+ return this.setFields(
1131
+ Array.isArray(this.cache.initial?.fields)
1132
+ ? cloneFields(this.cache.initial.fields)
1133
+ : null
1134
+ );
1135
+ }
1136
+
1137
+ onDestroy() {
1138
+ const wasVisible = !!this.runtime.visibleApplied;
1139
+ const onHide = this.state?.onHide;
1140
+ const onHidden = this.state?.onHidden;
1141
+
1142
+ if (wasVisible && onHide) onHide();
1143
+
1144
+ this.cancelHideTimer();
1145
+ this.clearEvents();
1146
+ this.destroyForm();
1147
+ this.unlockScroll();
1148
+ this.cleanup.visibility?.();
1149
+ this.cleanup.visibility = null;
1150
+ this.cleanup.view?.();
1151
+ this.cleanup.view = null;
1152
+
1153
+ if (this.root?.parentNode) this.root.parentNode.removeChild(this.root);
1154
+ if (wasVisible && onHidden) onHidden();
1155
+
1156
+ this.cache = {
1157
+ initial: null,
1158
+ fieldIds: null,
1159
+ baseStyle: '',
1160
+ previousActiveElement: null,
1161
+ formId: '',
1162
+ };
1163
+ }
1164
+
1165
+ destroy() {
1166
+ if (this.runtime.destroyed) return;
1167
+ super.destroy();
1168
+ return this;
1169
+ }
1170
+ }
1171
+
1172
+ export function createModal(input) {
1173
+ return new Modal(input);
1174
+ }