@prosdevlab/experience-sdk-plugins 0.2.0 → 0.3.0

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.
@@ -0,0 +1,685 @@
1
+ import type { SDK } from '@lytics/sdk-kit';
2
+ import type { ExperienceButton } from '../types';
3
+ import { sanitizeHTML } from '../utils/sanitize';
4
+ import { renderForm, renderFormState } from './form-rendering';
5
+ import { getInputErrorStyles } from './form-styles';
6
+ import { validateField, validateForm } from './form-validation';
7
+ import {
8
+ getBackdropStyles,
9
+ getButtonContainerStyles,
10
+ getCloseButtonDefaultOpacity,
11
+ getCloseButtonHoverOpacity,
12
+ getCloseButtonStyles,
13
+ getContentWrapperStyles,
14
+ getDialogStyles,
15
+ getHeroImageStyles,
16
+ getMessageStyles,
17
+ getPrimaryButtonDefaultBg,
18
+ getPrimaryButtonHoverBg,
19
+ getPrimaryButtonStyles,
20
+ getSecondaryButtonDefaultBg,
21
+ getSecondaryButtonHoverBg,
22
+ getSecondaryButtonStyles,
23
+ getTitleStyles,
24
+ } from './modal-styles';
25
+ import type { ModalContent, ModalPlugin } from './types';
26
+
27
+ /**
28
+ * Modal Plugin for @prosdevlab/experience-sdk
29
+ *
30
+ * Renders experiences as accessible modal dialogs with:
31
+ * - Focus trap and keyboard handling
32
+ * - ARIA attributes for screen readers
33
+ * - Backdrop and close button
34
+ * - Responsive design
35
+ */
36
+ export const modalPlugin = (plugin: any, instance: SDK): void => {
37
+ plugin.ns('experiences.modal');
38
+ plugin.defaults({
39
+ modal: {
40
+ dismissable: true,
41
+ backdropDismiss: true,
42
+ zIndex: 10001,
43
+ size: 'md',
44
+ mobileFullscreen: false,
45
+ position: 'center',
46
+ animation: 'fade',
47
+ animationDuration: 200,
48
+ },
49
+ });
50
+
51
+ // Track active modals
52
+ const activeModals = new Map<string, HTMLElement>();
53
+ // Track focus before modal opened
54
+ const previouslyFocusedElement = new Map<string, HTMLElement | null>();
55
+ // Track form data by experience ID
56
+ const formData = new Map<string, Record<string, string>>();
57
+
58
+ /**
59
+ * Get focusable elements within a container
60
+ */
61
+ const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
62
+ const selector =
63
+ 'a[href], button, textarea, input, select, details, [tabindex]:not([tabindex="-1"])';
64
+ return Array.from(container.querySelectorAll(selector)).filter(
65
+ (el) => !(el as HTMLElement).hasAttribute('disabled')
66
+ ) as HTMLElement[];
67
+ };
68
+
69
+ /**
70
+ * Create focus trap for modal
71
+ */
72
+ const createFocusTrap = (container: HTMLElement): (() => void) => {
73
+ const focusable = getFocusableElements(container);
74
+ if (focusable.length === 0) return () => {};
75
+
76
+ const firstFocusable = focusable[0];
77
+ const lastFocusable = focusable[focusable.length - 1];
78
+
79
+ const handleKeyDown = (e: KeyboardEvent) => {
80
+ if (e.key !== 'Tab') return;
81
+
82
+ if (e.shiftKey) {
83
+ // Shift + Tab
84
+ if (document.activeElement === firstFocusable) {
85
+ e.preventDefault();
86
+ lastFocusable.focus();
87
+ }
88
+ } else {
89
+ // Tab
90
+ if (document.activeElement === lastFocusable) {
91
+ e.preventDefault();
92
+ firstFocusable.focus();
93
+ }
94
+ }
95
+ };
96
+
97
+ container.addEventListener('keydown', handleKeyDown);
98
+
99
+ // Set initial focus
100
+ firstFocusable.focus();
101
+
102
+ // Return cleanup function
103
+ return () => {
104
+ container.removeEventListener('keydown', handleKeyDown);
105
+ };
106
+ };
107
+
108
+ /**
109
+ * Get modal size width
110
+ */
111
+ const getSizeWidth = (size: string): string => {
112
+ switch (size) {
113
+ case 'sm':
114
+ return '400px';
115
+ case 'md':
116
+ return '600px';
117
+ case 'lg':
118
+ return '800px';
119
+ case 'fullscreen':
120
+ return '100vw';
121
+ case 'auto':
122
+ return 'auto';
123
+ default:
124
+ return '600px'; // md default
125
+ }
126
+ };
127
+
128
+ /**
129
+ * Check if mobile viewport
130
+ */
131
+ const isMobile = (): boolean => {
132
+ return typeof window !== 'undefined' && window.innerWidth < 640;
133
+ };
134
+
135
+ /**
136
+ * Render modal DOM structure
137
+ */
138
+ const renderModal = (experienceId: string, content: ModalContent): HTMLElement => {
139
+ const modalConfig = instance.get('modal') || {};
140
+ const zIndex = modalConfig.zIndex || 10001;
141
+ const size = modalConfig.size || 'md';
142
+ const position = modalConfig.position || 'center';
143
+ const animation = modalConfig.animation || 'fade';
144
+ const animationDuration = modalConfig.animationDuration || 200;
145
+
146
+ // Determine if should be fullscreen
147
+ const mobileFullscreen =
148
+ modalConfig.mobileFullscreen !== undefined ? modalConfig.mobileFullscreen : size === 'lg'; // Auto-enable for lg size
149
+ const shouldBeFullscreen = size === 'fullscreen' || (mobileFullscreen && isMobile());
150
+
151
+ // Create modal container
152
+ const container = document.createElement('div');
153
+ const sizeClass = shouldBeFullscreen ? 'fullscreen' : size;
154
+ const positionClass = position === 'bottom' ? 'xp-modal--bottom' : 'xp-modal--center';
155
+ const animationClass = animation !== 'none' ? `xp-modal--${animation}` : '';
156
+ container.className =
157
+ `xp-modal xp-modal--${sizeClass} ${positionClass} ${animationClass} ${content.className || ''}`.trim();
158
+ container.setAttribute('data-xp-id', experienceId);
159
+ container.setAttribute('role', 'dialog');
160
+ container.setAttribute('aria-modal', 'true');
161
+ if (content.title) {
162
+ container.setAttribute('aria-labelledby', `xp-modal-title-${experienceId}`);
163
+ }
164
+
165
+ // Container styles
166
+ const alignItems = position === 'bottom' ? 'flex-end' : 'center';
167
+ container.style.cssText = `position: fixed; inset: 0; z-index: ${zIndex}; display: flex; align-items: ${alignItems}; justify-content: center;`;
168
+
169
+ // Apply animation
170
+ if (animation !== 'none') {
171
+ container.style.opacity = '0';
172
+ container.style.transition = `opacity ${animationDuration}ms ease-in-out`;
173
+
174
+ if (animation === 'slide-up') {
175
+ container.style.transform = 'translateY(100%)';
176
+ container.style.transition += `, transform ${animationDuration}ms ease-out`;
177
+ }
178
+ }
179
+
180
+ // Apply custom styles
181
+ if (content.style) {
182
+ Object.entries(content.style).forEach(([key, value]) => {
183
+ container.style.setProperty(key, String(value));
184
+ });
185
+ }
186
+
187
+ // Backdrop
188
+ const backdrop = document.createElement('div');
189
+ backdrop.className = 'xp-modal__backdrop';
190
+ backdrop.style.cssText = getBackdropStyles();
191
+ container.appendChild(backdrop);
192
+
193
+ // Dialog
194
+ const dialog = document.createElement('div');
195
+ const dialogWidth = shouldBeFullscreen ? '100%' : size === 'auto' ? 'none' : getSizeWidth(size);
196
+ const dialogHeight = shouldBeFullscreen ? '100%' : 'auto';
197
+ const dialogMaxWidth = shouldBeFullscreen ? '100%' : size === 'auto' ? 'none' : '90%';
198
+ const dialogBorderRadius = shouldBeFullscreen ? '0' : '8px';
199
+ const dialogPadding = content.image ? '0' : '24px';
200
+
201
+ dialog.className = `xp-modal__dialog${content.image ? ' xp-modal__dialog--has-image' : ''}`;
202
+ dialog.style.cssText = getDialogStyles({
203
+ width: dialogWidth,
204
+ maxWidth: dialogMaxWidth,
205
+ height: dialogHeight,
206
+ maxHeight: shouldBeFullscreen ? '100%' : '90vh',
207
+ borderRadius: dialogBorderRadius,
208
+ padding: dialogPadding,
209
+ });
210
+ container.appendChild(dialog);
211
+
212
+ // Hero image (if provided)
213
+ if (content.image) {
214
+ const img = document.createElement('img');
215
+ img.className = 'xp-modal__hero-image';
216
+ img.src = content.image.src;
217
+ img.alt = content.image.alt;
218
+ img.loading = 'lazy';
219
+
220
+ // Use custom maxHeight if provided, otherwise default based on viewport
221
+ const maxHeight = content.image.maxHeight || (isMobile() ? 200 : 300);
222
+ img.style.cssText = getHeroImageStyles({
223
+ maxHeight,
224
+ borderRadius: shouldBeFullscreen ? '0' : '8px 8px 0 0',
225
+ });
226
+
227
+ dialog.appendChild(img);
228
+ }
229
+
230
+ // Close button
231
+ if (modalConfig.dismissable !== false) {
232
+ const closeButton = document.createElement('button');
233
+ closeButton.className = 'xp-modal__close';
234
+ closeButton.setAttribute('aria-label', 'Close dialog');
235
+ closeButton.innerHTML = '&times;';
236
+ closeButton.style.cssText = getCloseButtonStyles();
237
+ closeButton.onmouseover = () => {
238
+ closeButton.style.opacity = getCloseButtonHoverOpacity();
239
+ };
240
+ closeButton.onmouseout = () => {
241
+ closeButton.style.opacity = getCloseButtonDefaultOpacity();
242
+ };
243
+ closeButton.onclick = () => removeModal(experienceId);
244
+ dialog.appendChild(closeButton);
245
+ }
246
+
247
+ // Content wrapper
248
+ const contentWrapper = document.createElement('div');
249
+ contentWrapper.className = 'xp-modal__content';
250
+ const contentPadding = content.image ? '24px' : '24px 24px 0 24px';
251
+ contentWrapper.style.cssText = getContentWrapperStyles(contentPadding);
252
+
253
+ // Title
254
+ if (content.title) {
255
+ const title = document.createElement('h2');
256
+ title.id = `xp-modal-title-${experienceId}`;
257
+ title.className = 'xp-modal__title';
258
+ title.textContent = content.title;
259
+ title.style.cssText = getTitleStyles();
260
+ contentWrapper.appendChild(title);
261
+ }
262
+
263
+ // Message
264
+ const message = document.createElement('div');
265
+ message.className = 'xp-modal__message';
266
+ message.innerHTML = sanitizeHTML(content.message);
267
+ message.style.cssText = getMessageStyles();
268
+ contentWrapper.appendChild(message);
269
+
270
+ // Form or Buttons
271
+ if (content.form) {
272
+ // Render form
273
+ const form = renderForm(experienceId, content.form);
274
+ contentWrapper.appendChild(form);
275
+
276
+ // Store form config for later use (state rendering)
277
+ (container as any).__formConfig = content.form;
278
+
279
+ // Initialize form data
280
+ const data: Record<string, string> = {};
281
+ content.form.fields.forEach((field) => {
282
+ data[field.name] = '';
283
+ });
284
+ formData.set(experienceId, data);
285
+
286
+ // Add form event listeners
287
+ content.form.fields.forEach((field) => {
288
+ const input = form.querySelector(`#${experienceId}-${field.name}`) as
289
+ | HTMLInputElement
290
+ | HTMLTextAreaElement;
291
+ const errorEl = form.querySelector(`#${experienceId}-${field.name}-error`) as HTMLElement;
292
+
293
+ if (!input) return;
294
+
295
+ // Update form data on input change
296
+ input.addEventListener('input', () => {
297
+ const currentData = formData.get(experienceId) || {};
298
+ currentData[field.name] = input.value;
299
+ formData.set(experienceId, currentData);
300
+
301
+ // Emit change event
302
+ instance.emit('experiences:modal:form:change', {
303
+ experienceId,
304
+ field: field.name,
305
+ value: input.value,
306
+ formData: { ...currentData },
307
+ timestamp: Date.now(),
308
+ });
309
+ });
310
+
311
+ // Validate on blur
312
+ input.addEventListener('blur', () => {
313
+ const currentData = formData.get(experienceId) || {};
314
+ const result = validateField(field, currentData[field.name] || '');
315
+
316
+ if (!result.valid && result.errors) {
317
+ // Show error
318
+ input.style.cssText += `; ${getInputErrorStyles()}`;
319
+ input.setAttribute('aria-invalid', 'true');
320
+ errorEl.textContent = result.errors[field.name] || '';
321
+
322
+ // Emit validation event
323
+ instance.emit('experiences:modal:form:validation', {
324
+ experienceId,
325
+ field: field.name,
326
+ valid: false,
327
+ errors: result.errors,
328
+ timestamp: Date.now(),
329
+ });
330
+ } else {
331
+ // Clear error
332
+ input.style.cssText = input.style.cssText.replace(getInputErrorStyles(), '');
333
+ input.setAttribute('aria-invalid', 'false');
334
+ errorEl.textContent = '';
335
+
336
+ // Emit validation event
337
+ instance.emit('experiences:modal:form:validation', {
338
+ experienceId,
339
+ field: field.name,
340
+ valid: true,
341
+ timestamp: Date.now(),
342
+ });
343
+ }
344
+ });
345
+ });
346
+
347
+ // Handle form submission
348
+ form.addEventListener('submit', async (e) => {
349
+ e.preventDefault();
350
+
351
+ if (!content.form) return;
352
+
353
+ const currentData = formData.get(experienceId) || {};
354
+ const result = validateForm(content.form, currentData);
355
+
356
+ if (!result.valid && result.errors) {
357
+ // Show all errors
358
+ content.form.fields.forEach((field) => {
359
+ if (result.errors?.[field.name]) {
360
+ const input = form.querySelector(
361
+ `#${experienceId}-${field.name}`
362
+ ) as HTMLInputElement;
363
+ const errorEl = form.querySelector(
364
+ `#${experienceId}-${field.name}-error`
365
+ ) as HTMLElement;
366
+
367
+ if (input) {
368
+ input.style.cssText += `; ${getInputErrorStyles()}`;
369
+ input.setAttribute('aria-invalid', 'true');
370
+ }
371
+ if (errorEl) {
372
+ errorEl.textContent = result.errors[field.name] || '';
373
+ }
374
+ }
375
+ });
376
+
377
+ // Emit validation failure
378
+ instance.emit('experiences:modal:form:validation', {
379
+ experienceId,
380
+ valid: false,
381
+ errors: result.errors,
382
+ timestamp: Date.now(),
383
+ });
384
+
385
+ return;
386
+ }
387
+
388
+ // Disable submit button
389
+ const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
390
+ if (submitButton) {
391
+ submitButton.disabled = true;
392
+ submitButton.textContent = 'Submitting...';
393
+ }
394
+
395
+ // Emit submit event
396
+ instance.emit('experiences:modal:form:submit', {
397
+ experienceId,
398
+ formData: { ...currentData },
399
+ timestamp: Date.now(),
400
+ });
401
+ });
402
+ } else if (content.buttons && content.buttons.length > 0) {
403
+ // Render buttons
404
+ const buttonContainer = document.createElement('div');
405
+ buttonContainer.className = 'xp-modal__buttons';
406
+ buttonContainer.style.cssText = getButtonContainerStyles();
407
+
408
+ content.buttons.forEach((button: ExperienceButton) => {
409
+ const btn = document.createElement('button');
410
+ btn.className = `xp-modal__button xp-modal__button--${button.variant || 'secondary'}`;
411
+ btn.textContent = button.text;
412
+
413
+ // Apply button styles based on variant
414
+ if (button.variant === 'primary') {
415
+ btn.style.cssText = getPrimaryButtonStyles();
416
+ btn.onmouseover = () => {
417
+ btn.style.background = getPrimaryButtonHoverBg();
418
+ };
419
+ btn.onmouseout = () => {
420
+ btn.style.background = getPrimaryButtonDefaultBg();
421
+ };
422
+ } else {
423
+ btn.style.cssText = getSecondaryButtonStyles();
424
+ btn.onmouseover = () => {
425
+ btn.style.background = getSecondaryButtonHoverBg();
426
+ };
427
+ btn.onmouseout = () => {
428
+ btn.style.background = getSecondaryButtonDefaultBg();
429
+ };
430
+ }
431
+
432
+ btn.onclick = () => {
433
+ instance.emit('experiences:action', {
434
+ experienceId,
435
+ action: button.action,
436
+ button,
437
+ timestamp: Date.now(),
438
+ });
439
+
440
+ if (button.dismiss) {
441
+ removeModal(experienceId);
442
+ }
443
+
444
+ if (button.url) {
445
+ window.location.href = button.url;
446
+ }
447
+ };
448
+
449
+ buttonContainer.appendChild(btn);
450
+ });
451
+
452
+ contentWrapper.appendChild(buttonContainer);
453
+ }
454
+
455
+ dialog.appendChild(contentWrapper);
456
+
457
+ // Backdrop dismiss
458
+ if (modalConfig.backdropDismiss !== false) {
459
+ backdrop.onclick = () => removeModal(experienceId);
460
+ }
461
+
462
+ // Escape key handler
463
+ const handleEscape = (e: KeyboardEvent) => {
464
+ if (e.key === 'Escape' && modalConfig.dismissable !== false) {
465
+ removeModal(experienceId);
466
+ }
467
+ };
468
+ document.addEventListener('keydown', handleEscape);
469
+
470
+ // Store cleanup for escape listener
471
+ (container as any).__cleanupEscape = () => {
472
+ document.removeEventListener('keydown', handleEscape);
473
+ };
474
+
475
+ return container;
476
+ };
477
+
478
+ /**
479
+ * Show a modal experience
480
+ */
481
+ const showModal = (experience: any): void => {
482
+ const experienceId = experience.id;
483
+
484
+ // Don't show if already showing
485
+ if (activeModals.has(experienceId)) return;
486
+
487
+ // Hide any existing modals (prevent stacking for better UX)
488
+ if (activeModals.size > 0) {
489
+ const existingIds = Array.from(activeModals.keys());
490
+ for (const id of existingIds) {
491
+ removeModal(id);
492
+ }
493
+ }
494
+
495
+ // Store currently focused element
496
+ previouslyFocusedElement.set(experienceId, document.activeElement as HTMLElement);
497
+
498
+ // Render modal
499
+ const modal = renderModal(experienceId, experience.content);
500
+ document.body.appendChild(modal);
501
+ activeModals.set(experienceId, modal);
502
+
503
+ // Trigger animation after adding to DOM
504
+ const modalConfig = instance.get('modal') || {};
505
+ const animation = modalConfig.animation || 'fade';
506
+
507
+ if (animation !== 'none') {
508
+ requestAnimationFrame(() => {
509
+ modal.style.opacity = '1';
510
+
511
+ if (animation === 'slide-up') {
512
+ modal.style.transform = 'translateY(0)';
513
+ }
514
+ });
515
+ }
516
+
517
+ // Setup focus trap
518
+ const cleanupFocusTrap = createFocusTrap(modal);
519
+ (modal as any).__cleanupFocusTrap = cleanupFocusTrap;
520
+
521
+ // Emit shown event
522
+ instance.emit('experiences:shown', {
523
+ experienceId,
524
+ timestamp: Date.now(),
525
+ });
526
+
527
+ // Emit trigger event for context
528
+ instance.emit('trigger:modal', {
529
+ experienceId,
530
+ timestamp: Date.now(),
531
+ shown: true,
532
+ });
533
+ };
534
+
535
+ /**
536
+ * Remove a modal
537
+ */
538
+ const removeModal = (experienceId: string): void => {
539
+ const modal = activeModals.get(experienceId);
540
+ if (!modal) return;
541
+
542
+ // Cleanup focus trap
543
+ if ((modal as any).__cleanupFocusTrap) {
544
+ (modal as any).__cleanupFocusTrap();
545
+ }
546
+
547
+ // Cleanup escape listener
548
+ if ((modal as any).__cleanupEscape) {
549
+ (modal as any).__cleanupEscape();
550
+ }
551
+
552
+ // Return focus
553
+ const previousElement = previouslyFocusedElement.get(experienceId);
554
+ if (previousElement && document.body.contains(previousElement)) {
555
+ previousElement.focus();
556
+ }
557
+ previouslyFocusedElement.delete(experienceId);
558
+
559
+ // Remove from DOM
560
+ modal.remove();
561
+ activeModals.delete(experienceId);
562
+
563
+ // Emit dismissed event
564
+ instance.emit('experiences:dismissed', {
565
+ experienceId,
566
+ timestamp: Date.now(),
567
+ });
568
+ };
569
+
570
+ /**
571
+ * Check if a modal is showing
572
+ */
573
+ const isShowing = (experienceId?: string): boolean => {
574
+ if (experienceId) {
575
+ return activeModals.has(experienceId);
576
+ }
577
+ return activeModals.size > 0;
578
+ };
579
+
580
+ /**
581
+ * Show form success or error state
582
+ */
583
+ const showFormState = (experienceId: string, state: 'success' | 'error'): void => {
584
+ const modal = activeModals.get(experienceId);
585
+ if (!modal) return;
586
+
587
+ const form = modal.querySelector('.xp-modal__form') as HTMLFormElement;
588
+ if (!form) return;
589
+
590
+ // Get the form config from the experience
591
+ // Note: We need to store this when the modal is created
592
+ const formConfig = (modal as any).__formConfig;
593
+ if (!formConfig) return;
594
+
595
+ const stateConfig = state === 'success' ? formConfig.successState : formConfig.errorState;
596
+ if (!stateConfig) return;
597
+
598
+ // Render state element
599
+ const stateEl = renderFormState(state, stateConfig);
600
+
601
+ // Replace form with state
602
+ form.replaceWith(stateEl);
603
+
604
+ // Emit state event
605
+ instance.emit('experiences:modal:form:state', {
606
+ experienceId,
607
+ state,
608
+ timestamp: Date.now(),
609
+ });
610
+ };
611
+
612
+ /**
613
+ * Reset form to initial state
614
+ */
615
+ const resetForm = (experienceId: string): void => {
616
+ const modal = activeModals.get(experienceId);
617
+ if (!modal) return;
618
+
619
+ const form = modal.querySelector('.xp-modal__form') as HTMLFormElement;
620
+ if (!form) return;
621
+
622
+ // Reset form element
623
+ form.reset();
624
+
625
+ // Clear form data
626
+ const data = formData.get(experienceId);
627
+ if (data) {
628
+ Object.keys(data).forEach((key) => {
629
+ data[key] = '';
630
+ });
631
+ }
632
+
633
+ // Clear all error messages
634
+ const errors = form.querySelectorAll('.xp-form__error');
635
+ errors.forEach((error) => {
636
+ (error as HTMLElement).textContent = '';
637
+ });
638
+
639
+ // Reset all inputs
640
+ const inputs = form.querySelectorAll('.xp-form__input') as NodeListOf<
641
+ HTMLInputElement | HTMLTextAreaElement
642
+ >;
643
+ inputs.forEach((input) => {
644
+ input.setAttribute('aria-invalid', 'false');
645
+ input.style.cssText = input.style.cssText.replace(getInputErrorStyles(), '');
646
+ });
647
+ };
648
+
649
+ /**
650
+ * Get current form data
651
+ */
652
+ const getFormData = (experienceId: string): Record<string, string> | null => {
653
+ return formData.get(experienceId) || null;
654
+ };
655
+
656
+ // Expose public API
657
+ plugin.expose({
658
+ modal: {
659
+ show: showModal,
660
+ remove: removeModal,
661
+ isShowing,
662
+ showFormState,
663
+ resetForm,
664
+ getFormData,
665
+ } as ModalPlugin,
666
+ });
667
+
668
+ // Auto-show modal experiences when evaluated
669
+ instance.on('experiences:evaluated', (data: any) => {
670
+ const { decision, experience } = data;
671
+ if (decision.show && decision.experienceId && experience) {
672
+ // Check if this is a modal experience (using 'type' property)
673
+ if (experience.type === 'modal') {
674
+ showModal(experience);
675
+ }
676
+ }
677
+ });
678
+
679
+ // Cleanup on destroy
680
+ instance.on('sdk:destroy', () => {
681
+ activeModals.forEach((_, id) => {
682
+ removeModal(id);
683
+ });
684
+ });
685
+ };