vanilla-jui 1.4.5 → 1.4.6

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 (103) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +1273 -1896
  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 +21 -2
  7. package/src/components/accordion.ts +297 -216
  8. package/src/components/flow.ts +222 -258
  9. package/src/components/form.ts +44 -102
  10. package/src/components/menu.ts +268 -356
  11. package/src/components/modal.ts +181 -641
  12. package/src/components/offcanvas.ts +225 -106
  13. package/src/components/pagination.ts +296 -326
  14. package/src/components/sticky.ts +55 -54
  15. package/src/components/swiper.ts +237 -247
  16. package/src/components/tabs.ts +278 -271
  17. package/src/components/toc.ts +42 -46
  18. package/src/core/Component.ts +69 -47
  19. package/src/css/accordion.css +2 -2
  20. package/src/css/animation.css +1 -5
  21. package/src/css/avatar.css +1 -1
  22. package/src/css/badge.css +1 -1
  23. package/src/css/breadcrumb.css +9 -10
  24. package/src/css/button.css +1 -1
  25. package/src/css/card.css +2 -2
  26. package/src/css/color.css +974 -1002
  27. package/src/css/color_p3.css +974 -1004
  28. package/src/css/components.css +1 -1
  29. package/src/css/content.css +72 -63
  30. package/src/css/drop.css +1 -1
  31. package/src/css/editor.css +14 -14
  32. package/src/css/flow.css +4 -14
  33. package/src/css/font.css +5 -4
  34. package/src/css/form.css +76 -52
  35. package/src/css/gradient.css +299 -222
  36. package/src/css/icon.css +57 -57
  37. package/src/css/index.css +1 -1
  38. package/src/css/layout.css +13 -10
  39. package/src/css/loading.css +60 -12
  40. package/src/css/menu.css +262 -188
  41. package/src/css/modal.css +1 -1
  42. package/src/css/offcanvas.css +9 -13
  43. package/src/css/pagination.css +5 -1
  44. package/src/css/popup.css +1 -1
  45. package/src/css/skeleton.css +3 -2
  46. package/src/css/status.css +2 -3
  47. package/src/css/swiper.css +1 -1
  48. package/src/css/table.css +1 -1
  49. package/src/css/tabs.css +12 -11
  50. package/src/css/tag.css +1 -1
  51. package/src/css/theme-palette.css +19 -5
  52. package/src/css/themes/default/accordion.css +61 -61
  53. package/src/css/themes/default/avatar.css +54 -54
  54. package/src/css/themes/default/badge.css +62 -62
  55. package/src/css/themes/default/breadcrumb.css +23 -23
  56. package/src/css/themes/default/button.css +203 -203
  57. package/src/css/themes/default/content.css +53 -53
  58. package/src/css/themes/default/drop.css +10 -10
  59. package/src/css/themes/default/editor.css +9 -9
  60. package/src/css/themes/default/index.css +1 -1
  61. package/src/css/themes/default/loading.css +26 -20
  62. package/src/css/themes/default/menu.css +75 -11
  63. package/src/css/themes/default/modal.css +32 -36
  64. package/src/css/themes/default/pagination.css +7 -7
  65. package/src/css/themes/default/popup.css +6 -6
  66. package/src/css/themes/default/swiper.css +32 -32
  67. package/src/css/themes/default/table.css +16 -16
  68. package/src/css/themes/default/tabs.css +30 -30
  69. package/src/css/themes/default/tag.css +71 -71
  70. package/src/css/themes/default/theme-palette.css +11 -11
  71. package/src/css/themes/default/tip.css +42 -42
  72. package/src/css/themes/default/toast.css +60 -60
  73. package/src/css/themes/default/toolbar.css +23 -29
  74. package/src/css/themes/default/tooltip.css +43 -13
  75. package/src/css/themes/index.css +1 -1
  76. package/src/css/tip.css +2 -2
  77. package/src/css/toast.css +3 -3
  78. package/src/css/tokens.css +12 -17
  79. package/src/css/toolbar.css +9 -10
  80. package/src/css/tooltip.css +3 -2
  81. package/src/index.ts +22 -19
  82. package/src/{components → primitives}/drop.ts +58 -49
  83. package/src/{components → primitives}/icons.ts +1 -1
  84. package/src/primitives/loading.ts +23 -0
  85. package/src/primitives/parabola.ts +388 -0
  86. package/src/primitives/popup.ts +61 -0
  87. package/src/{components → primitives}/theme.ts +88 -57
  88. package/src/{components → primitives}/toast.ts +18 -25
  89. package/src/{components → primitives}/tooltip.ts +100 -25
  90. package/src/utilities/browser.ts +23 -49
  91. package/src/utilities/class-name.ts +5 -0
  92. package/src/utilities/dom.ts +14 -32
  93. package/src/utilities/function.ts +4 -0
  94. package/src/utilities/http.ts +7 -7
  95. package/src/utilities/id.ts +57 -0
  96. package/src/utilities/object.ts +5 -0
  97. package/src/utilities/timer.ts +38 -0
  98. package/src/utilities/types.ts +420 -0
  99. package/src/{components → validation}/validator.ts +64 -53
  100. package/src/components/parabola.ts +0 -335
  101. package/src/utilities/cache.ts +0 -57
  102. package/src/utilities/core.ts +0 -614
  103. package/src/utilities/storage.ts +0 -46
@@ -8,63 +8,55 @@ import {
8
8
  render,
9
9
  untrack,
10
10
  } from 'vanilla-signal';
11
+ import { t } from 'vanilla-signal-i18n';
11
12
 
12
13
  import Component, {
13
14
  type ComponentDOM,
14
15
  type ComponentRuntime,
15
- type ComponentUpdateOptions,
16
16
  } from '../core/Component.ts';
17
- import {
18
- type ResolveSchema,
19
- hasOwn,
20
- isPlainObject,
21
- randomId,
22
- resolveProps,
23
- validateParam,
24
- } from '../utilities/core.ts';
17
+ import locales from '../locales/index.ts';
18
+ import { icon } from '../primitives/icons.ts';
19
+ import { createLoading } from '../primitives/loading.ts';
20
+ import { createPopup } from '../primitives/popup.ts';
21
+ import { joinClasses } from '../utilities/class-name.ts';
25
22
  import {
26
23
  all,
27
24
  q,
28
- createLoading,
29
- isRenderableContent,
30
25
  normalizeContentNodes,
31
26
  type RenderableContent,
32
27
  } from '../utilities/dom.ts';
28
+ import { randomId } from '../utilities/id.ts';
29
+ import { isPlainObject } from '../utilities/object.ts';
30
+ import {
31
+ type ResolveSchema,
32
+ resolveProps,
33
+ validateParam,
34
+ } from '../utilities/types.ts';
33
35
  import {
34
- Form,
36
+ createForm,
35
37
  type FormDataRecord,
36
38
  type FormField,
37
39
  type FormProps,
38
40
  } from './form.ts';
39
- import { icon } from './icons.ts';
40
41
 
41
42
  const HIDE_DURATION = 300;
42
43
 
43
- type ModalStyle =
44
- | string
45
- | Record<string, string | number | null | undefined>
46
- | null;
47
44
  type ModalTextInput = Partial<ModalText> & Record<string, unknown>;
48
45
  type ModalContent = RenderableContent<Modal>;
49
- type FlowDirection = 'next' | 'back';
46
+ type FormInstance = ReturnType<typeof createForm>;
47
+ export type ModalMode = 'content' | 'form';
50
48
 
51
49
  export interface ModalClassNames {
52
50
  layout: string;
53
51
  modal: string;
54
- fullscreen: string;
55
52
  header: string;
56
53
  body: string;
57
54
  footer: string;
58
55
  title: string;
59
- close: string;
60
- cancel: string;
61
- confirm: string;
56
+ closeBtn: string;
57
+ cancelBtn: string;
58
+ confirmBtn: string;
62
59
  button: string;
63
- buttonIcon: string;
64
- buttonGhost: string;
65
- buttonPrimary: string;
66
- buttonSmall: string;
67
- formContainer: string;
68
60
  }
69
61
 
70
62
  export type ModalClassNameConfig = Partial<ModalClassNames>;
@@ -75,55 +67,13 @@ export interface ModalText {
75
67
  cancel: string;
76
68
  }
77
69
 
78
- interface FlowStep {
79
- id?: string;
80
- title?: RenderableContent<Modal>;
81
- content?: ModalContent;
82
- modal?: ModalFlowView | ModalFlowViewFactory;
83
- view?: ModalFlowView | ModalFlowViewFactory;
84
- [key: string]: unknown;
85
- }
86
-
87
- interface FlowSnapshot {
88
- data?: Record<string, unknown> | null;
89
- currentData?: Record<string, unknown> | null;
90
- currentStep?: FlowStep;
91
- [key: string]: unknown;
92
- }
93
-
94
- interface FlowLike {
95
- currentStep?: FlowStep;
96
- next: (
97
- payload: FormDataRecord | null
98
- ) => Promise<FlowSnapshot> | FlowSnapshot;
99
- back: (
100
- payload: FormDataRecord | null
101
- ) => Promise<FlowSnapshot> | FlowSnapshot;
102
- snapshot: () => FlowSnapshot;
103
- reset?: () => void;
104
- destroy?: () => void;
105
- }
106
-
107
- interface ModalFlowView extends Partial<ModalProps> {
108
- [key: string]: unknown;
109
- }
110
-
111
- type ModalFlowViewFactory = (context: {
112
- flow: FlowLike;
113
- snapshot: FlowSnapshot | null;
114
- step: FlowStep | undefined;
115
- modal: Modal;
116
- data: Record<string, unknown> | null;
117
- currentData: Record<string, unknown> | null;
118
- }) => unknown;
119
-
120
70
  export interface ModalProps extends Record<string, unknown> {
71
+ mode?: ModalMode | null;
121
72
  content?: ModalContent;
122
73
  position?: string;
123
74
  showCancel?: boolean;
124
75
  showClose?: boolean;
125
76
  fullscreen?: boolean;
126
- flow?: FlowLike | null;
127
77
  text?: ModalTextInput;
128
78
  onShow?: ((modal: Modal) => void | Promise<void>) | null;
129
79
  onShown?: ((modal: Modal) => void | Promise<void>) | null;
@@ -137,21 +87,19 @@ export interface ModalProps extends Record<string, unknown> {
137
87
  fields?: readonly FormField[] | null;
138
88
  header?: boolean;
139
89
  footer?: boolean;
140
- style?: ModalStyle;
141
90
  id?: string | null;
142
91
  escClose?: boolean;
143
92
  bgClose?: boolean;
144
- lazy?: boolean;
145
93
  className?: ModalClassNameConfig;
146
94
  }
147
95
 
148
96
  interface ResolvedModalProps extends Record<string, unknown> {
97
+ mode: ModalMode;
149
98
  content: ModalContent;
150
99
  position: string;
151
100
  showCancel: boolean;
152
101
  showClose: boolean;
153
102
  fullscreen: boolean;
154
- flow: FlowLike | null;
155
103
  text: ModalText;
156
104
  onShow: NonNullable<ModalProps['onShow']> | null;
157
105
  onShown: NonNullable<ModalProps['onShown']> | null;
@@ -163,17 +111,18 @@ interface ResolvedModalProps extends Record<string, unknown> {
163
111
  fields: FormField[] | null;
164
112
  header: boolean;
165
113
  footer: boolean;
166
- style: ModalStyle;
167
114
  id: string;
168
115
  escClose: boolean;
169
116
  bgClose: boolean;
170
- lazy: boolean;
171
117
  className: ModalClassNames;
172
118
  }
173
119
 
174
- interface ModalState extends ResolvedModalProps {
120
+ interface ModalState extends Record<string, unknown> {
121
+ mode: ModalMode;
122
+ content: ModalContent;
123
+ fields: FormField[] | null;
175
124
  loading: boolean;
176
- submitting: boolean;
125
+ processing: boolean;
177
126
  visible: boolean;
178
127
  data: FormDataRecord | null;
179
128
  extraData: FormDataRecord | null;
@@ -185,7 +134,7 @@ interface ModalDOM extends ComponentDOM {
185
134
  header: HTMLElement | null;
186
135
  body: HTMLElement | null;
187
136
  footer: HTMLElement | null;
188
- form: Form | null;
137
+ form: FormInstance | null;
189
138
  formContainer: HTMLElement | null;
190
139
  }
191
140
 
@@ -197,7 +146,6 @@ interface ModalRuntime extends ComponentRuntime {
197
146
  interface ModalCache {
198
147
  initial: ResolvedModalProps | null;
199
148
  fieldIds: Map<string, string> | null;
200
- baseStyle: string;
201
149
  previousActiveElement: HTMLElement | null;
202
150
  formId: string;
203
151
  }
@@ -208,26 +156,19 @@ interface ModalCleanupExtras {
208
156
  hideTimer?: ReturnType<typeof setTimeout> | null;
209
157
  }
210
158
 
211
- type ModalPatch = Partial<ModalProps>;
212
159
  type ModalStatePatch = Partial<ModalState>;
213
160
 
214
161
  const DEFAULT_CLASS_NAMES: ModalClassNames = {
215
162
  layout: 'j-popup-layout',
216
163
  modal: 'j-modal',
217
- fullscreen: 'is-fullscreen',
218
164
  header: 'modal-header',
219
165
  body: 'modal-body',
220
166
  footer: 'modal-footer',
221
167
  title: 'modal-title',
222
- close: 'modal-close',
223
- cancel: 'modal-cancel',
224
- confirm: 'modal-confirm',
225
168
  button: 'j-button',
226
- buttonIcon: 'is-icon',
227
- buttonGhost: 'is-ghost',
228
- buttonPrimary: 'is-primary',
229
- buttonSmall: 'is-sm',
230
- formContainer: 'modal-form-container',
169
+ closeBtn: 'is-icon is-sm is-ghost',
170
+ cancelBtn: 'is-ghost',
171
+ confirmBtn: 'is-primary',
231
172
  };
232
173
 
233
174
  function cloneFields(
@@ -254,66 +195,13 @@ function cloneProps(props: ResolvedModalProps): ResolvedModalProps {
254
195
  };
255
196
  }
256
197
 
257
- function isUpdateProps(value: unknown): value is ModalPatch {
258
- return isPlainObject(value);
259
- }
260
-
261
- function isFlowLike(value: unknown): value is FlowLike | null {
262
- return (
263
- value == null ||
264
- (typeof value === 'object' &&
265
- typeof (value as Partial<FlowLike>).next === 'function' &&
266
- typeof (value as Partial<FlowLike>).back === 'function' &&
267
- typeof (value as Partial<FlowLike>).snapshot === 'function')
268
- );
269
- }
270
-
271
- function hydrateFields(
272
- fields: readonly FormField[] | null,
273
- data: Record<string, unknown> | null | undefined
274
- ): FormField[] | null {
275
- if (!Array.isArray(fields) || !isPlainObject(data))
276
- return fields ? cloneFields(fields) : null;
277
-
278
- return fields.map((field) => {
279
- if (!field?.name || !hasOwn(data, field.name)) return { ...field };
280
- const value = data[field.name];
281
-
282
- if (field.type === 'checkbox' || field.type === 'radio') {
283
- const optionValue = field.value ?? 'on';
284
- const checked = Array.isArray(value)
285
- ? value.map(String).includes(String(optionValue))
286
- : value === true || String(value) === String(optionValue);
287
- return { ...field, checked };
288
- }
289
-
290
- return {
291
- ...field,
292
- value: Array.isArray(value)
293
- ? value.map(String)
294
- : (value as FormField['value']),
295
- };
296
- });
297
- }
298
-
299
- function createTextState(
300
- props: Partial<ModalProps> | Partial<ResolvedModalProps>
301
- ): ModalText {
302
- const text = isPlainObject(props?.text) ? props.text : {};
303
- return {
304
- title: typeof text.title === 'string' ? text.title : 'Tip',
305
- confirm: typeof text.confirm === 'string' ? text.confirm : 'Confirm',
306
- cancel: typeof text.cancel === 'string' ? text.cancel : 'Cancel',
307
- };
308
- }
309
-
310
198
  function createModalState(props: ResolvedModalProps): ModalState {
311
199
  return {
312
- ...props,
200
+ mode: props.mode,
201
+ content: props.content,
313
202
  fields: Array.isArray(props.fields) ? cloneFields(props.fields) : null,
314
- text: createTextState(props),
315
203
  loading: false,
316
- submitting: false,
204
+ processing: false,
317
205
  visible: false,
318
206
  data: null,
319
207
  extraData: null,
@@ -328,33 +216,16 @@ function mergeExtraData(
328
216
  return Object.assign(data, extraData);
329
217
  }
330
218
 
331
- function clonePlainObject(value: unknown): ModalFlowView {
332
- return isPlainObject(value) ? { ...value } : {};
333
- }
334
-
335
- function joinClasses(
336
- ...classes: Array<string | false | null | undefined>
337
- ): string {
338
- return classes.filter(Boolean).join(' ');
339
- }
340
-
341
- function textValue(value: RenderableContent<Modal> | undefined): string {
342
- return typeof value === 'string' || typeof value === 'number'
343
- ? String(value)
344
- : '';
345
- }
346
-
347
219
  const MODAL_CONTENT_RULE = {
348
- validate: isRenderableContent,
349
- message: 'expects string, number, Node, array, function or null.',
220
+ type: 'renderable',
350
221
  };
351
222
 
352
223
  const MODAL_TEXT_RULE = {
353
224
  default: {},
354
- validate: (value: unknown) => isPlainObject(value),
355
- message: 'expects an object with text fields.',
225
+ type: 'object',
226
+ plain: true,
356
227
  normalize: (value: unknown) => {
357
- const text = isPlainObject(value) ? value : {};
228
+ const text = (isPlainObject(value) ? value : {}) as ModalTextInput;
358
229
  return {
359
230
  ...text,
360
231
  title: typeof text.title === 'string' ? text.title : 'Tip',
@@ -366,16 +237,20 @@ const MODAL_TEXT_RULE = {
366
237
 
367
238
  const MODAL_FIELDS_RULE = { types: ['array', 'null'] };
368
239
  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
+ },
369
249
  content: { default: '', ...MODAL_CONTENT_RULE },
370
250
  position: { default: 'center', type: 'string' },
371
251
  showCancel: { default: true, type: 'boolean' },
372
252
  showClose: { default: true, type: 'boolean' },
373
253
  fullscreen: { default: false, type: 'boolean' },
374
- flow: {
375
- default: null,
376
- validate: isFlowLike,
377
- message: 'expects a Flow instance or null.',
378
- },
379
254
  text: MODAL_TEXT_RULE,
380
255
  onShow: { default: null, types: ['function', 'null'] },
381
256
  onShown: { default: null, types: ['function', 'null'] },
@@ -387,7 +262,6 @@ const MODAL_PROPS_SCHEMA = {
387
262
  fields: { default: null, ...MODAL_FIELDS_RULE },
388
263
  header: { default: true, type: 'boolean' },
389
264
  footer: { default: true, type: 'boolean' },
390
- style: { default: null, types: ['string', 'null', 'object'] },
391
265
  id: {
392
266
  default: null,
393
267
  types: ['string', 'null'],
@@ -402,7 +276,6 @@ const MODAL_PROPS_SCHEMA = {
402
276
  },
403
277
  escClose: { default: false, type: 'boolean' },
404
278
  bgClose: { default: false, type: 'boolean' },
405
- lazy: { default: false, type: 'boolean' },
406
279
  className: {
407
280
  default: DEFAULT_CLASS_NAMES,
408
281
  type: 'object',
@@ -414,36 +287,29 @@ const MODAL_PROPS_SCHEMA = {
414
287
  } satisfies ResolveSchema<ModalProps>;
415
288
 
416
289
  const MODAL_STATE_SCHEMA = {
417
- ...MODAL_PROPS_SCHEMA,
290
+ mode: { type: 'string', enum: ['content', 'form'] },
291
+ content: MODAL_PROPS_SCHEMA.content,
292
+ fields: MODAL_PROPS_SCHEMA.fields,
418
293
  loading: { default: false, type: 'boolean' },
419
- submitting: { default: false, type: 'boolean' },
294
+ processing: { default: false, type: 'boolean' },
420
295
  visible: { default: false, type: 'boolean' },
421
296
  data: { default: null, types: ['object', 'null'] },
422
297
  extraData: { default: null, types: ['object', 'null'] },
423
298
  };
424
299
 
425
- const MODAL_UPDATE_BLOCKED_KEYS = new Set(['id', 'lazy']);
426
-
427
- const MODAL_EXTRA_FIELDS_RULE = {
428
- validate: (value: unknown) =>
429
- !!value && typeof value === 'object' && !Array.isArray(value),
430
- message: 'expects an object.',
431
- };
432
-
433
300
  const MODAL_UPDATE_RULE = {
434
- validate: isUpdateProps,
435
- message: 'expects a props object.',
301
+ type: 'plainObject',
436
302
  };
437
303
 
438
304
  function normalizeProps(input: ModalProps): ResolvedModalProps {
439
305
  const props = resolveProps(input, MODAL_PROPS_SCHEMA, 'Modal');
440
306
  return {
307
+ mode: props.mode as ModalMode,
441
308
  content: props.content as ModalContent,
442
309
  position: props.position as string,
443
310
  showCancel: props.showCancel as boolean,
444
311
  showClose: props.showClose as boolean,
445
312
  fullscreen: props.fullscreen as boolean,
446
- flow: props.flow as FlowLike | null,
447
313
  text: props.text as ModalText,
448
314
  onShow: props.onShow as ResolvedModalProps['onShow'],
449
315
  onShown: props.onShown as ResolvedModalProps['onShown'],
@@ -457,11 +323,9 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
457
323
  : null,
458
324
  header: props.header as boolean,
459
325
  footer: props.footer as boolean,
460
- style: props.style as ModalStyle,
461
326
  id: props.id as string,
462
327
  escClose: props.escClose as boolean,
463
328
  bgClose: props.bgClose as boolean,
464
- lazy: props.lazy as boolean,
465
329
  className: props.className as ModalClassNames,
466
330
  };
467
331
  }
@@ -469,11 +333,15 @@ function normalizeProps(input: ModalProps): ResolvedModalProps {
469
333
  let modalScrollLockCount = 0;
470
334
  let modalBodyOverflow = '';
471
335
 
472
- export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
336
+ class ModalComponent extends Component<
337
+ ResolvedModalProps,
338
+ ModalState,
339
+ ModalDOM,
340
+ ModalCache
341
+ > {
473
342
  declare runtime: ModalRuntime;
474
343
  declare state: ModalState;
475
344
  declare cleanup: Component['cleanup'] & ModalCleanupExtras;
476
- cache: ModalCache;
477
345
 
478
346
  constructor(input: ModalProps = {}) {
479
347
  const props = normalizeProps(input);
@@ -496,7 +364,6 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
496
364
  this.cache = {
497
365
  initial: cloneProps(this.props),
498
366
  fieldIds: new Map(),
499
- baseStyle: '',
500
367
  previousActiveElement: null,
501
368
  formId: `${this.props.id}_form`,
502
369
  };
@@ -508,33 +375,30 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
508
375
 
509
376
  protected override onInit(): void {
510
377
  this.bindReactiveVisibility();
511
- if (!this.state.lazy) this.buildRoot();
512
378
  }
513
379
 
514
- buildRoot(): HTMLElement {
515
- if (this.root instanceof HTMLElement) return this.root;
380
+ build(): this {
381
+ this.assertActive('build');
382
+ if (this.dom.root instanceof HTMLElement) return this;
516
383
 
517
- const { id, className } = this.state;
384
+ const { id, className } = this.props;
518
385
  const dialogChildren = [
519
386
  jsx('div', {
520
387
  className: className.header,
521
- 'data-modal-header': id,
522
- style: () => ({ display: this.state.header ? '' : 'none' }),
388
+ style: { display: this.props.header ? '' : 'none' },
523
389
  ref: (element: HTMLElement) => {
524
390
  this.dom.header = element;
525
391
  },
526
392
  }),
527
393
  jsx('div', {
528
394
  className: className.body,
529
- 'data-modal-body': id,
530
395
  ref: (element: HTMLElement) => {
531
396
  this.dom.body = element;
532
397
  },
533
398
  }),
534
399
  jsx('div', {
535
400
  className: className.footer,
536
- 'data-modal-footer': id,
537
- style: () => ({ display: this.state.footer ? '' : 'none' }),
401
+ style: { display: this.props.footer ? '' : 'none' },
538
402
  ref: (element: HTMLElement) => {
539
403
  this.dom.footer = element;
540
404
  },
@@ -544,35 +408,32 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
544
408
  const modal = jsx('div', {
545
409
  className: () =>
546
410
  joinClasses(
547
- this.state.className.modal,
548
- this.state.fullscreen && this.state.className.fullscreen
411
+ this.props.className.modal,
412
+ this.props.fullscreen && 'is-fullscreen'
549
413
  ),
550
414
  id,
551
415
  role: 'document',
552
416
  'data-modal-dialog': id,
553
417
  ref: (element: HTMLElement) => {
554
418
  this.dom.modal = element;
555
- this.applyStyle(element, this.state.style);
556
419
  },
557
420
  children: dialogChildren,
558
421
  }) as HTMLElement;
559
422
 
560
- const root = jsx('div', {
561
- className: () =>
562
- joinClasses(this.state.className.layout, `is-${this.state.position}`),
563
- role: 'dialog',
564
- 'data-modal': 'root',
565
- 'data-modal-position': () => this.state.position,
566
- 'aria-modal': 'true',
567
- 'aria-labelledby': () => (this.state.header ? `${id}_title` : null),
568
- 'aria-label': () =>
569
- this.state.header ? null : this.state.text?.title || 'Modal',
570
- children: modal,
571
- }) as HTMLElement;
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');
432
+ }
572
433
 
573
- this.root = root;
434
+ this.dom.root = root;
574
435
  this.mountView();
575
- return root;
436
+ return this;
576
437
  }
577
438
 
578
439
  mountView(): void {
@@ -584,7 +445,10 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
584
445
  onCleanup(headerDispose);
585
446
  }
586
447
 
587
- if (this.dom.body) render(() => this.bodyView(), this.dom.body);
448
+ if (this.dom.body) {
449
+ const bodyDispose = render(() => this.bodyView(), this.dom.body);
450
+ onCleanup(bodyDispose);
451
+ }
588
452
 
589
453
  if (this.dom.footer) {
590
454
  const footerDispose = render(() => this.footerView(), this.dom.footer);
@@ -592,34 +456,30 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
592
456
  }
593
457
 
594
458
  this.bindReactiveLoading();
595
- this.bindReactiveStyle();
596
459
  return dispose;
597
460
  });
598
461
  }
599
462
 
600
463
  headerView(): () => (Node | null)[] | null {
601
464
  return () => {
602
- if (!this.state.header) return null;
465
+ if (!this.props.header) return null;
603
466
 
604
467
  return [
605
468
  jsx('div', {
606
- className: this.state.className.title,
607
- id: `${this.state.id}_title`,
608
- 'data-modal-title': this.state.id,
609
- children: () => this.state.text?.title,
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,
610
473
  }) as Node,
611
- this.state.showClose
474
+ this.props.showClose
612
475
  ? (jsx('button', {
613
476
  type: 'button',
614
477
  className: joinClasses(
615
- this.state.className.button,
616
- this.state.className.buttonIcon,
617
- this.state.className.buttonGhost,
618
- this.state.className.buttonSmall,
619
- this.state.className.close
478
+ this.props.className.button,
479
+ this.props.className.closeBtn
620
480
  ),
621
481
  'data-action': 'close',
622
- 'aria-label': 'close',
482
+ 'aria-label': t('Close', locales),
623
483
  children: icon('close'),
624
484
  }) as Node)
625
485
  : null,
@@ -629,49 +489,44 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
629
489
 
630
490
  footerView(): () => (Node | null)[] | null {
631
491
  return () => {
632
- if (!this.state.footer) return null;
492
+ if (!this.props.footer) return null;
633
493
 
634
494
  return [
635
- this.state.showCancel
495
+ this.props.showCancel
636
496
  ? (jsx('button', {
637
497
  type: 'button',
638
498
  className: joinClasses(
639
- this.state.className.button,
640
- this.state.className.buttonGhost,
641
- this.state.className.cancel
499
+ this.props.className.button,
500
+ this.props.className.cancelBtn
642
501
  ),
643
- 'data-action': 'close',
644
- 'aria-label': 'close',
502
+ 'data-action': 'cancel',
645
503
  disabled: () => this.isBusy(),
646
- children: () => this.state.text?.cancel,
504
+ children: this.props.text.cancel,
647
505
  }) as Node)
648
506
  : null,
649
507
  jsx('button', {
650
- type: () => (this.isFormMode() ? 'submit' : 'button'),
651
- form: () => (this.isFormMode() ? this.cache.formId : null),
508
+ type: () => (this.state.mode === 'form' ? 'submit' : 'button'),
652
509
  className: joinClasses(
653
- this.state.className.button,
654
- this.state.className.buttonPrimary,
655
- this.state.className.confirm
510
+ this.props.className.button,
511
+ this.props.className.confirmBtn
656
512
  ),
657
- 'data-action': () => (this.isFormMode() ? 'submit' : 'confirm'),
513
+ 'data-action': () =>
514
+ this.state.mode === 'form' ? 'submit' : 'confirm',
658
515
  disabled: () => this.isBusy(),
659
- children: () => this.state.text?.confirm,
516
+ children: this.props.text.confirm,
660
517
  }) as Node,
661
518
  ];
662
519
  };
663
520
  }
664
521
 
665
522
  bodyView(): RenderableContent<Modal> {
666
- if (this.isFormMode()) return this.formView();
523
+ if (this.state.mode === 'form') return this.formView();
667
524
  this.destroyForm();
668
525
  return this.contentView(this.state.content);
669
526
  }
670
527
 
671
528
  formView(): HTMLElement {
672
529
  return jsx('div', {
673
- className: this.state.className.formContainer,
674
- 'data-modal-form-container': this.state.id,
675
530
  ref: (element: HTMLElement) => {
676
531
  this.mountForm(element);
677
532
  },
@@ -684,11 +539,18 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
684
539
 
685
540
  const props = this.createFormProps();
686
541
  if (this.dom.form) {
687
- this.dom.form.update(props);
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);
548
+ }
688
549
  return;
689
550
  }
690
551
 
691
- this.dom.form = new Form(props, container).build();
552
+ this.dom.form = createForm(props).build();
553
+ if (this.dom.form.dom.root) container.appendChild(this.dom.form.dom.root);
692
554
  }
693
555
 
694
556
  createFormProps(): FormProps {
@@ -729,144 +591,8 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
729
591
  });
730
592
  }
731
593
 
732
- bindReactiveStyle(): void {
733
- createEffect(() => {
734
- const style = this.state.style;
735
- if (this.dom.modal) this.applyStyle(this.dom.modal, style);
736
- });
737
- }
738
-
739
- isFormMode(): boolean {
740
- return Array.isArray(this.state.fields);
741
- }
742
-
743
594
  isBusy(): boolean {
744
- return !!(this.state.loading || this.state.submitting);
745
- }
746
-
747
- validatePropsPatch(patch: ModalPatch, namespace = 'Modal.update'): void {
748
- validateParam('props', patch, MODAL_UPDATE_RULE, namespace);
749
-
750
- for (const key of Object.keys(patch)) {
751
- if (!hasOwn(MODAL_PROPS_SCHEMA, key)) {
752
- throw new Error(
753
- `Validator: ${namespace}.${key} is not a supported modal prop.`
754
- );
755
- }
756
- if (MODAL_UPDATE_BLOCKED_KEYS.has(key)) {
757
- throw new Error(
758
- `Modal.update: "${key}" cannot be updated after initialization.`
759
- );
760
- }
761
- validateParam(
762
- key,
763
- patch[key],
764
- MODAL_PROPS_SCHEMA[key as keyof typeof MODAL_PROPS_SCHEMA],
765
- namespace
766
- );
767
- }
768
- }
769
-
770
- applyProps(
771
- patch: ModalPatch,
772
- {
773
- validate = true,
774
- force = false,
775
- }: { validate?: boolean; force?: boolean } = {}
776
- ): this {
777
- if (validate) this.validatePropsPatch(patch);
778
- if (!patch || Object.keys(patch).length === 0) return this;
779
-
780
- const hasFields = hasOwn(patch, 'fields');
781
- const hasContent = hasOwn(patch, 'content');
782
-
783
- if (hasContent && !hasFields && this.isFormMode() && !force) {
784
- throw new Error(
785
- 'Modal.update: Cannot update content when fields are defined.'
786
- );
787
- }
788
-
789
- const nextInput: ModalProps = {
790
- ...(this.props as ModalProps),
791
- ...patch,
792
- text: (hasOwn(patch, 'text')
793
- ? { ...this.props.text, ...patch.text }
794
- : this.props.text) as ModalTextInput,
795
- className: hasOwn(patch, 'className')
796
- ? { ...this.props.className, ...patch.className }
797
- : this.props.className,
798
- };
799
- const nextProps = normalizeProps(nextInput);
800
-
801
- this.props = nextProps;
802
- super.update(patch as Partial<ResolvedModalProps>, { force });
803
- this.props = nextProps;
804
-
805
- const statePatch: ModalStatePatch = {};
806
- if (hasFields) {
807
- this.cache.fieldIds?.clear();
808
- statePatch.fields = Array.isArray(patch.fields)
809
- ? cloneFields(patch.fields)
810
- : null;
811
- }
812
-
813
- for (const [key, value] of Object.entries(patch)) {
814
- if (key === 'fields' || key === 'text' || key === 'className') continue;
815
- statePatch[key as keyof ModalStatePatch] =
816
- value as ModalStatePatch[keyof ModalStatePatch];
817
- }
818
-
819
- if (hasOwn(patch, 'text')) {
820
- statePatch.text = createTextState(nextProps);
821
- }
822
- if (hasOwn(patch, 'className')) {
823
- statePatch.className = nextProps.className;
824
- }
825
-
826
- flushSync(() => {
827
- for (const [key, value] of Object.entries(statePatch)) {
828
- this.state[key as keyof ModalState] =
829
- value as ModalState[keyof ModalState];
830
- }
831
- });
832
-
833
- if (hasOwn(patch, 'style') && this.dom.modal) {
834
- this.applyStyle(this.dom.modal, this.state.style);
835
- }
836
-
837
- if (
838
- this.state.visible &&
839
- (hasOwn(patch, 'bgClose') || hasOwn(patch, 'escClose'))
840
- ) {
841
- this.bindEvents(this.root);
842
- }
843
-
844
- if (this.dom.form && hasFields) {
845
- this.dom.form.update(this.createFormProps());
846
- }
847
-
848
- return this;
849
- }
850
-
851
- applyStyle(element: HTMLElement, style: ModalStyle): void {
852
- element.removeAttribute('style');
853
- if (!style) {
854
- this.cache.baseStyle = '';
855
- return;
856
- }
857
- if (typeof style === 'string') {
858
- element.style.cssText = style;
859
- this.cache.baseStyle = element.getAttribute('style') || '';
860
- return;
861
- }
862
- Object.entries(style).forEach(([key, value]) => {
863
- if (value == null) return;
864
- const name = key.startsWith('--')
865
- ? key
866
- : key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
867
- element.style.setProperty(name, String(value));
868
- });
869
- this.cache.baseStyle = element.getAttribute('style') || '';
595
+ return !!(this.state.loading || this.state.processing);
870
596
  }
871
597
 
872
598
  bindReactiveVisibility(): void {
@@ -893,33 +619,35 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
893
619
  throw new Error('Modal: The current instance has been destroyed.');
894
620
  }
895
621
 
896
- if (!this.root) this.buildRoot();
622
+ if (!this.dom.root) {
623
+ throw new Error('Modal.show: build() must be called before show().');
624
+ }
897
625
 
898
626
  this.cancelHideTimer();
899
627
  this.resetAnimationStyles();
900
628
 
901
- const { onShow, onShown } = this.state;
629
+ const { onShow, onShown } = this.props;
902
630
  void onShow?.(this);
903
631
 
904
632
  this.cache.previousActiveElement =
905
633
  document.activeElement instanceof HTMLElement
906
634
  ? document.activeElement
907
635
  : null;
908
- if (this.root && !this.root.parentNode)
909
- document.body.appendChild(this.root);
636
+ if (this.dom.root && !this.dom.root.parentNode)
637
+ document.body.appendChild(this.dom.root);
910
638
  this.lockScroll();
911
639
  this.runtime.visibleApplied = true;
912
640
 
913
- this.bindEvents(this.root);
641
+ this.bindEvents(this.dom.root);
914
642
  this.focusFirst();
915
643
 
916
644
  void onShown?.(this);
917
645
  }
918
646
 
919
647
  hideFromState(): void {
920
- if (!this.runtime.visibleApplied || !this.root) return;
648
+ if (!this.runtime.visibleApplied || !this.dom.root) return;
921
649
 
922
- const { onHide, onHidden } = this.state;
650
+ const { onHide, onHidden } = this.props;
923
651
  void onHide?.(this);
924
652
 
925
653
  this.runtime.visibleApplied = false;
@@ -954,7 +682,7 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
954
682
  bindOverlayCloseEvent(root: Element | null): void {
955
683
  if (!root) return;
956
684
  this.cleanup.events.on('bg', root, 'click', (event) => {
957
- if (this.state.bgClose && event.target === root) {
685
+ if (this.props.bgClose && event.target === root) {
958
686
  this.hide();
959
687
  }
960
688
  });
@@ -964,7 +692,7 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
964
692
  this.cleanup.events.on('keydown', document, 'keydown', (event) => {
965
693
  if (!this.state.visible || !(event instanceof KeyboardEvent)) return;
966
694
 
967
- if (event.key === 'Escape' && this.state.escClose) {
695
+ if (event.key === 'Escape' && this.props.escClose) {
968
696
  event.preventDefault();
969
697
  this.hide();
970
698
  return;
@@ -993,18 +721,8 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
993
721
  return;
994
722
  }
995
723
 
996
- if (action === 'back') {
997
- void this.handleBack();
998
- return;
999
- }
1000
-
1001
- if (action === 'next') {
1002
- void this.handleNext();
1003
- return;
1004
- }
1005
-
1006
724
  if (action === 'confirm') {
1007
- if (this.isFormMode()) {
725
+ if (this.state.mode === 'form') {
1008
726
  this.requestSubmit();
1009
727
  return;
1010
728
  }
@@ -1018,7 +736,7 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1018
736
  }
1019
737
 
1020
738
  requestSubmit(): void {
1021
- if (!this.dom.form) {
739
+ if (this.state.mode !== 'form' || !this.dom.form) {
1022
740
  void this.handleConfirm();
1023
741
  return;
1024
742
  }
@@ -1038,138 +756,21 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1038
756
  await this.handleSubmit(data);
1039
757
  }
1040
758
 
1041
- async handleNext(): Promise<void> {
1042
- if (this.isBusy()) return;
1043
- if (!this.hasFlow()) return;
1044
- await this.moveFlow('next');
1045
- }
1046
-
1047
- async handleBack(): Promise<void> {
1048
- if (this.isBusy()) return;
1049
- if (!this.hasFlow()) return;
1050
- await this.moveFlow('back');
1051
- }
1052
-
1053
- hasFlow(): this is this & { state: ModalState & { flow: FlowLike } } {
1054
- return isFlowLike(this.state.flow) && !!this.state.flow;
1055
- }
1056
-
1057
- async moveFlow(direction: FlowDirection): Promise<void> {
1058
- if (!this.hasFlow()) return;
1059
- const payload = this.createFlowPayload();
1060
- if (payload === false) return;
1061
-
1062
- flushSync(() => {
1063
- this.state.submitting = true;
1064
- });
1065
-
1066
- try {
1067
- const snapshot = await Promise.resolve(
1068
- this.state.flow[direction](payload)
1069
- );
1070
- this.syncFlowView(this.state.flow, snapshot);
1071
- } catch (error) {
1072
- console.error(`Modal.flow.${direction} error:`, error);
1073
- this.syncFlowView(this.state.flow);
1074
- } finally {
1075
- if (this.state) {
1076
- flushSync(() => {
1077
- this.state.submitting = false;
1078
- });
1079
- }
1080
- }
1081
- }
1082
-
1083
- createFlowPayload(): FormDataRecord | null | false {
1084
- if (!this.isFormMode()) return null;
1085
-
1086
- const form = this.dom.form;
1087
- if (!form) return this.state.data || null;
1088
- if (!form.validate()) return false;
1089
-
1090
- const data = mergeExtraData(form.collectData(), this.state.extraData);
1091
- flushSync(() => {
1092
- this.state.data = data;
1093
- });
1094
-
1095
- return this.state.data;
1096
- }
1097
-
1098
- resolveFlowModalView(
1099
- flow: FlowLike,
1100
- snapshot: FlowSnapshot | null,
1101
- step?: FlowStep
1102
- ): ModalFlowView {
1103
- const source = step?.modal ?? step?.view;
1104
- if (typeof source === 'function') {
1105
- const result = source({
1106
- flow,
1107
- snapshot,
1108
- step,
1109
- modal: this,
1110
- data: snapshot?.data ?? null,
1111
- currentData: snapshot?.currentData ?? null,
1112
- });
1113
- return isPlainObject(result) ? result : {};
1114
- }
1115
- return clonePlainObject(source);
1116
- }
1117
-
1118
- syncFlowView(
1119
- flow: FlowLike | null,
1120
- snapshot: FlowSnapshot | null = null
1121
- ): void {
1122
- if (!flow || !this.state) return;
1123
- const state = flow.snapshot() || snapshot;
1124
- const step =
1125
- flow.currentStep || state?.currentStep || snapshot?.currentStep;
1126
- const modalView = this.resolveFlowModalView(flow, state, step);
1127
- const nextText = isPlainObject(modalView.text) ? modalView.text : {};
1128
- const shouldInjectStepTitle =
1129
- (!hasOwn(modalView, 'text') || !hasOwn(nextText, 'title')) &&
1130
- step?.title != null;
1131
-
1132
- const patch: ModalPatch = {
1133
- ...(shouldInjectStepTitle
1134
- ? {
1135
- text: {
1136
- ...nextText,
1137
- title: textValue(step.title),
1138
- },
1139
- }
1140
- : {}),
1141
- ...(!hasOwn(modalView, 'content') && !hasOwn(modalView, 'fields')
1142
- ? { content: step?.content ?? null }
1143
- : {}),
1144
- ...modalView,
1145
- };
1146
-
1147
- if (Array.isArray(patch.fields)) {
1148
- patch.fields = hydrateFields(patch.fields, state?.currentData);
1149
- }
1150
- if (!hasOwn(patch, 'fields') && hasOwn(patch, 'content')) {
1151
- patch.fields = null;
1152
- }
1153
-
1154
- this.update(patch);
1155
- }
1156
-
1157
759
  async handleConfirm(): Promise<void> {
1158
760
  if (this.isBusy()) return;
1159
761
 
1160
762
  flushSync(() => {
1161
- this.state.submitting = true;
763
+ this.state.processing = true;
1162
764
  });
1163
765
 
1164
766
  try {
1165
- await Promise.resolve(this.state.onConfirm?.(this));
1166
- this.hide();
767
+ await Promise.resolve(this.props.onConfirm?.(this));
1167
768
  } catch (error) {
1168
769
  console.error('Modal.onConfirm error:', error);
1169
770
  } finally {
1170
771
  if (this.state) {
1171
772
  flushSync(() => {
1172
- this.state.submitting = false;
773
+ this.state.processing = false;
1173
774
  });
1174
775
  }
1175
776
  }
@@ -1179,39 +780,39 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1179
780
  if (this.isBusy()) return;
1180
781
 
1181
782
  flushSync(() => {
1182
- this.state.submitting = true;
783
+ this.state.processing = true;
1183
784
  });
1184
785
 
1185
786
  try {
1186
- await Promise.resolve(this.state.onCancel?.(this));
787
+ await Promise.resolve(this.props.onCancel?.(this));
1187
788
  this.hide();
1188
789
  } catch (error) {
1189
790
  console.error('Modal.onCancel error:', error);
1190
791
  } finally {
1191
792
  if (this.state) {
1192
793
  flushSync(() => {
1193
- this.state.submitting = false;
794
+ this.state.processing = false;
1194
795
  });
1195
796
  }
1196
797
  }
1197
798
  }
1198
799
 
1199
800
  async handleSubmit(data: FormDataRecord): Promise<void> {
1200
- if (!this.state.onSubmit) return;
801
+ if (!this.props.onSubmit) return;
1201
802
 
1202
803
  flushSync(() => {
1203
- this.state.submitting = true;
804
+ this.state.processing = true;
1204
805
  });
1205
806
 
1206
807
  try {
1207
- await Promise.resolve(this.state.onSubmit(data, this));
808
+ await Promise.resolve(this.props.onSubmit(data, this));
1208
809
  this.state.extraData = null;
1209
810
  } catch (error) {
1210
811
  console.error('Modal.onSubmit error:', error);
1211
812
  } finally {
1212
813
  if (this.state) {
1213
814
  flushSync(() => {
1214
- this.state.submitting = false;
815
+ this.state.processing = false;
1215
816
  });
1216
817
  }
1217
818
  }
@@ -1289,14 +890,14 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1289
890
 
1290
891
  resetAnimationStyles(): void {
1291
892
  if (!this.dom.modal) return;
1292
- if (this.cache.baseStyle)
1293
- this.dom.modal.setAttribute('style', this.cache.baseStyle);
1294
- else this.dom.modal.removeAttribute('style');
893
+ this.dom.modal.style.removeProperty('transition');
894
+ this.dom.modal.style.removeProperty('opacity');
895
+ this.dom.modal.style.removeProperty('transform');
1295
896
  }
1296
897
 
1297
898
  finishHide(onHidden: ResolvedModalProps['onHidden']): void {
1298
899
  this.cleanup.hideTimer = null;
1299
- this.root?.remove();
900
+ this.dom.root?.remove();
1300
901
  this.resetAnimationStyles();
1301
902
  this.unlockScroll();
1302
903
  this.restoreFocus();
@@ -1323,22 +924,27 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1323
924
  }
1324
925
  }
1325
926
 
1326
- validateStatePatch(patch: ModalStatePatch): void {
1327
- if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
1328
- throw new Error('Modal.setState: expects a plain object patch.');
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;
1329
935
  }
936
+ return nextPatch;
937
+ }
938
+
939
+ protected override validateStatePatch(patch: ModalStatePatch): void {
940
+ validateParam('state', patch, MODAL_UPDATE_RULE, 'Modal.setState');
1330
941
 
1331
942
  for (const key of Object.keys(patch)) {
1332
- if (!hasOwn(MODAL_STATE_SCHEMA, key)) {
943
+ if (!Object.hasOwn(MODAL_STATE_SCHEMA, key)) {
1333
944
  throw new Error(
1334
945
  `Modal.setState: "${key}" is not a supported state key.`
1335
946
  );
1336
947
  }
1337
- if (MODAL_UPDATE_BLOCKED_KEYS.has(key)) {
1338
- throw new Error(
1339
- `Modal.setState: "${key}" cannot be updated after initialization.`
1340
- );
1341
- }
1342
948
  validateParam(
1343
949
  key,
1344
950
  patch[key as keyof ModalStatePatch],
@@ -1348,119 +954,52 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1348
954
  }
1349
955
  }
1350
956
 
1351
- override setState(patch: ModalStatePatch = {}): this {
1352
- this.assertActive('setState');
1353
- this.validateStatePatch(patch);
1354
-
1355
- flushSync(() => {
1356
- for (const [key, value] of Object.entries(patch)) {
1357
- if (key === 'fields') this.cache.fieldIds?.clear();
1358
- this.state[key as keyof ModalState] =
1359
- key === 'fields' && Array.isArray(value)
1360
- ? (cloneFields(
1361
- value as FormField[]
1362
- ) as ModalState[keyof ModalState])
1363
- : (value as ModalState[keyof ModalState]);
1364
- }
1365
- });
1366
- return this;
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 || []);
1367
961
  }
1368
962
 
1369
963
  show(): this {
1370
964
  this.assertActive('show');
1371
- flushSync(() => {
1372
- this.state.visible = true;
965
+ if (!this.dom.root) {
966
+ throw new Error('Modal.show: build() must be called before show().');
967
+ }
968
+ this.setState({
969
+ visible: true,
1373
970
  });
1374
971
  return this;
1375
972
  }
1376
973
 
1377
974
  hide(): this {
1378
975
  this.assertActive('hide');
1379
- flushSync(() => {
1380
- this.state.visible = false;
976
+ this.setState({
977
+ visible: false,
1381
978
  });
1382
979
  return this;
1383
980
  }
1384
981
 
1385
- setFields(data: readonly FormField[] | null, force = false): this {
1386
- validateParam('data', data, MODAL_FIELDS_RULE, 'Modal.setFields');
1387
- this.applyProps({ fields: data }, { validate: false, force });
1388
- return this;
1389
- }
1390
-
1391
- addFields(data: FormDataRecord): this {
1392
- validateParam('data', data, MODAL_EXTRA_FIELDS_RULE, 'Modal.addFields');
1393
- flushSync(() => {
1394
- this.state.extraData = data;
1395
- });
1396
- return this;
1397
- }
1398
-
1399
- setContent(content: ModalContent, force = false): this {
1400
- validateParam('content', content, MODAL_CONTENT_RULE, 'Modal.setContent');
1401
-
1402
- if (this.isFormMode() && !force) {
1403
- throw new Error(
1404
- 'Modal.setContent: Cannot setContent when fields are defined.'
1405
- );
1406
- }
1407
-
1408
- this.applyProps({ content }, { validate: false, force });
1409
- return this;
1410
- }
1411
-
1412
- override update(
1413
- patch?: Partial<ResolvedModalProps> | null,
1414
- options?: ComponentUpdateOptions
1415
- ): this;
1416
- update(patch?: ModalPatch | null, force?: boolean): this;
1417
- update(
1418
- patch: ModalPatch | Partial<ResolvedModalProps> | null = {},
1419
- forceOrOptions: boolean | ComponentUpdateOptions = false
1420
- ): this {
1421
- const force =
1422
- typeof forceOrOptions === 'boolean'
1423
- ? forceOrOptions
1424
- : !!forceOrOptions.force;
1425
- return this.applyProps((patch || {}) as ModalPatch, {
1426
- validate: true,
1427
- force,
1428
- });
1429
- }
1430
-
1431
982
  reset(): this {
1432
983
  this.cache.fieldIds?.clear();
1433
984
  if (!this.cache.initial) return this;
1434
985
 
1435
- const initialProps = cloneProps(this.cache.initial);
1436
- const patch: ModalPatch = { ...(initialProps as unknown as ModalProps) };
1437
- delete patch.id;
1438
- delete patch.lazy;
1439
- this.props = normalizeProps(patch);
1440
- this.applyProps(patch, { validate: false });
1441
- flushSync(() => {
1442
- this.state.data = null;
1443
- this.state.extraData = null;
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,
1444
996
  });
1445
997
  return this;
1446
998
  }
1447
999
 
1448
- resetContent(): this {
1449
- return this.setContent(this.cache.initial?.content ?? '');
1450
- }
1451
-
1452
- resetFields(): this {
1453
- return this.setFields(
1454
- Array.isArray(this.cache.initial?.fields)
1455
- ? cloneFields(this.cache.initial.fields)
1456
- : null
1457
- );
1458
- }
1459
-
1460
1000
  protected override onDestroy(): void {
1461
1001
  const wasVisible = !!this.runtime.visibleApplied;
1462
- const onHide = this.state?.onHide;
1463
- const onHidden = this.state?.onHidden;
1002
+ const { onHide, onHidden } = this.props;
1464
1003
 
1465
1004
  if (wasVisible) void onHide?.(this);
1466
1005
 
@@ -1473,13 +1012,12 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1473
1012
  this.cleanup.view?.();
1474
1013
  this.cleanup.view = null;
1475
1014
 
1476
- this.root?.remove();
1015
+ this.dom.root?.remove();
1477
1016
  if (wasVisible) void onHidden?.(this);
1478
1017
 
1479
1018
  this.cache = {
1480
1019
  initial: null,
1481
1020
  fieldIds: null,
1482
- baseStyle: '',
1483
1021
  previousActiveElement: null,
1484
1022
  formId: '',
1485
1023
  };
@@ -1492,6 +1030,8 @@ export class Modal extends Component<ResolvedModalProps, ModalState, ModalDOM> {
1492
1030
  }
1493
1031
  }
1494
1032
 
1033
+ export type Modal = ModalComponent;
1034
+
1495
1035
  export function createModal(input: ModalProps = {}): Modal {
1496
- return new Modal(input);
1036
+ return new ModalComponent(input);
1497
1037
  }