@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +120 -0
- package/README.md +141 -79
- package/dist/index.d.ts +206 -35
- package/dist/index.js +1229 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/banner/banner.ts +63 -62
- package/src/exit-intent/exit-intent.ts +2 -3
- package/src/index.ts +2 -0
- package/src/inline/index.ts +3 -0
- package/src/inline/inline.test.ts +620 -0
- package/src/inline/inline.ts +269 -0
- package/src/inline/insertion.ts +66 -0
- package/src/inline/types.ts +52 -0
- package/src/integration.test.ts +356 -297
- package/src/modal/form-rendering.ts +262 -0
- package/src/modal/form-styles.ts +212 -0
- package/src/modal/form-validation.test.ts +413 -0
- package/src/modal/form-validation.ts +126 -0
- package/src/modal/index.ts +3 -0
- package/src/modal/modal-styles.ts +204 -0
- package/src/modal/modal.browser.test.ts +164 -0
- package/src/modal/modal.test.ts +1294 -0
- package/src/modal/modal.ts +685 -0
- package/src/modal/types.ts +114 -0
- package/src/scroll-depth/scroll-depth.test.ts +35 -0
- package/src/scroll-depth/scroll-depth.ts +2 -4
- package/src/time-delay/time-delay.test.ts +2 -2
- package/src/time-delay/time-delay.ts +2 -3
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +4 -1
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment happy-dom
|
|
3
|
+
*/
|
|
4
|
+
import { SDK } from '@lytics/sdk-kit';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { modalPlugin } from './modal';
|
|
7
|
+
|
|
8
|
+
// Helper to initialize SDK with modal plugin
|
|
9
|
+
function initPlugin(config = {}) {
|
|
10
|
+
const sdk = new SDK({
|
|
11
|
+
name: 'test-sdk',
|
|
12
|
+
...config,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
sdk.use(modalPlugin);
|
|
16
|
+
|
|
17
|
+
// Mock dom-required functionality
|
|
18
|
+
if (typeof document !== 'undefined') {
|
|
19
|
+
// Ensure body exists
|
|
20
|
+
if (!document.body) {
|
|
21
|
+
document.body = document.createElement('body');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return sdk;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Modal Plugin', () => {
|
|
29
|
+
let sdk: SDK & { modal?: any };
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
sdk = initPlugin();
|
|
34
|
+
await sdk.init();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
// Clean up any leftover modals first
|
|
39
|
+
document.querySelectorAll('.xp-modal').forEach((el) => {
|
|
40
|
+
el.remove();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (sdk) {
|
|
44
|
+
await sdk.destroy();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
vi.restoreAllMocks();
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should register the modal plugin', () => {
|
|
52
|
+
expect(sdk.modal).toBeDefined();
|
|
53
|
+
expect(typeof sdk.modal.show).toBe('function');
|
|
54
|
+
expect(typeof sdk.modal.remove).toBe('function');
|
|
55
|
+
expect(typeof sdk.modal.isShowing).toBe('function');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should set default configuration', () => {
|
|
59
|
+
const config = sdk.get('modal');
|
|
60
|
+
expect(config).toBeDefined();
|
|
61
|
+
expect(config.dismissable).toBe(true);
|
|
62
|
+
expect(config.backdropDismiss).toBe(true);
|
|
63
|
+
expect(config.zIndex).toBe(10001);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Modal Rendering', () => {
|
|
67
|
+
it('should render a modal with title and message', () => {
|
|
68
|
+
const experience = {
|
|
69
|
+
id: 'test-modal',
|
|
70
|
+
layout: 'modal',
|
|
71
|
+
content: {
|
|
72
|
+
title: 'Test Title',
|
|
73
|
+
message: 'Test message',
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
sdk.modal.show(experience);
|
|
78
|
+
|
|
79
|
+
const modal = document.querySelector('.xp-modal');
|
|
80
|
+
expect(modal).toBeTruthy();
|
|
81
|
+
expect(modal?.querySelector('.xp-modal__title')?.textContent).toBe('Test Title');
|
|
82
|
+
expect(modal?.querySelector('.xp-modal__message')?.textContent).toBe('Test message');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should render a modal without title', () => {
|
|
86
|
+
const experience = {
|
|
87
|
+
id: 'no-title-modal',
|
|
88
|
+
layout: 'modal',
|
|
89
|
+
content: {
|
|
90
|
+
message: 'Just a message',
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
sdk.modal.show(experience);
|
|
95
|
+
|
|
96
|
+
const modal = document.querySelector('.xp-modal');
|
|
97
|
+
expect(modal).toBeTruthy();
|
|
98
|
+
expect(modal?.querySelector('.xp-modal__title')).toBeFalsy();
|
|
99
|
+
expect(modal?.querySelector('.xp-modal__message')?.textContent).toBe('Just a message');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should render modal with buttons', () => {
|
|
103
|
+
const experience = {
|
|
104
|
+
id: 'button-modal',
|
|
105
|
+
layout: 'modal',
|
|
106
|
+
content: {
|
|
107
|
+
title: 'Confirm',
|
|
108
|
+
message: 'Are you sure?',
|
|
109
|
+
buttons: [
|
|
110
|
+
{ text: 'Cancel', variant: 'secondary' },
|
|
111
|
+
{ text: 'Confirm', variant: 'primary' },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
sdk.modal.show(experience);
|
|
117
|
+
|
|
118
|
+
const buttons = document.querySelectorAll('.xp-modal__button');
|
|
119
|
+
expect(buttons.length).toBe(2);
|
|
120
|
+
expect(buttons[0]?.textContent).toBe('Cancel');
|
|
121
|
+
expect(buttons[1]?.textContent).toBe('Confirm');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should apply custom className and style', () => {
|
|
125
|
+
const experience = {
|
|
126
|
+
id: 'custom-modal',
|
|
127
|
+
layout: 'modal',
|
|
128
|
+
content: {
|
|
129
|
+
message: 'Custom styled',
|
|
130
|
+
className: 'my-custom-class',
|
|
131
|
+
style: {
|
|
132
|
+
'background-color': 'red',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
sdk.modal.show(experience);
|
|
138
|
+
|
|
139
|
+
const modal = document.querySelector('.xp-modal');
|
|
140
|
+
expect(modal?.classList.contains('my-custom-class')).toBe(true);
|
|
141
|
+
expect(modal?.style.backgroundColor).toBe('red');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should render close button when dismissable', () => {
|
|
145
|
+
const experience = {
|
|
146
|
+
id: 'dismissable-modal',
|
|
147
|
+
layout: 'modal',
|
|
148
|
+
content: {
|
|
149
|
+
message: 'Can close',
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
sdk.modal.show(experience);
|
|
154
|
+
|
|
155
|
+
const closeButton = document.querySelector('.xp-modal__close');
|
|
156
|
+
expect(closeButton).toBeTruthy();
|
|
157
|
+
expect(closeButton?.getAttribute('aria-label')).toBe('Close dialog');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should not render close button when dismissable is false', () => {
|
|
161
|
+
sdk.set('modal.dismissable', false);
|
|
162
|
+
|
|
163
|
+
const experience = {
|
|
164
|
+
id: 'non-dismissable-modal',
|
|
165
|
+
layout: 'modal',
|
|
166
|
+
content: {
|
|
167
|
+
message: 'Cannot close',
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
sdk.modal.show(experience);
|
|
172
|
+
|
|
173
|
+
const closeButton = document.querySelector('.xp-modal__close');
|
|
174
|
+
expect(closeButton).toBeFalsy();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should sanitize HTML in message', () => {
|
|
178
|
+
const experience = {
|
|
179
|
+
id: 'html-modal',
|
|
180
|
+
layout: 'modal',
|
|
181
|
+
content: {
|
|
182
|
+
message: '<strong>Bold text</strong><script>alert("xss")</script>',
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
sdk.modal.show(experience);
|
|
187
|
+
|
|
188
|
+
const message = document.querySelector('.xp-modal__message');
|
|
189
|
+
expect(message?.innerHTML).toContain('<strong>Bold text</strong>');
|
|
190
|
+
expect(message?.innerHTML).not.toContain('<script>');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('Modal Dismissal', () => {
|
|
195
|
+
it('should remove modal when close button is clicked', () => {
|
|
196
|
+
const experience = {
|
|
197
|
+
id: 'close-test',
|
|
198
|
+
layout: 'modal',
|
|
199
|
+
content: {
|
|
200
|
+
message: 'Click close',
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
sdk.modal.show(experience);
|
|
205
|
+
|
|
206
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
207
|
+
|
|
208
|
+
const closeButton = document.querySelector('.xp-modal__close') as HTMLElement;
|
|
209
|
+
closeButton.click();
|
|
210
|
+
|
|
211
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should remove modal when backdrop is clicked', () => {
|
|
215
|
+
const experience = {
|
|
216
|
+
id: 'backdrop-test',
|
|
217
|
+
layout: 'modal',
|
|
218
|
+
content: {
|
|
219
|
+
message: 'Click backdrop',
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
sdk.modal.show(experience);
|
|
224
|
+
|
|
225
|
+
const backdrop = document.querySelector('.xp-modal__backdrop') as HTMLElement;
|
|
226
|
+
backdrop.click();
|
|
227
|
+
|
|
228
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should not dismiss on backdrop click when backdropDismiss is false', () => {
|
|
232
|
+
sdk.set('modal.backdropDismiss', false);
|
|
233
|
+
|
|
234
|
+
const experience = {
|
|
235
|
+
id: 'no-backdrop-dismiss',
|
|
236
|
+
layout: 'modal',
|
|
237
|
+
content: {
|
|
238
|
+
message: 'Backdrop disabled',
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
sdk.modal.show(experience);
|
|
243
|
+
|
|
244
|
+
const backdrop = document.querySelector('.xp-modal__backdrop') as HTMLElement;
|
|
245
|
+
backdrop.click();
|
|
246
|
+
|
|
247
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should remove modal on Escape key when dismissable', () => {
|
|
251
|
+
const experience = {
|
|
252
|
+
id: 'escape-test',
|
|
253
|
+
layout: 'modal',
|
|
254
|
+
content: {
|
|
255
|
+
message: 'Press escape',
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
sdk.modal.show(experience);
|
|
260
|
+
|
|
261
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
262
|
+
document.dispatchEvent(event);
|
|
263
|
+
|
|
264
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should not dismiss on Escape when dismissable is false', () => {
|
|
268
|
+
sdk.set('modal.dismissable', false);
|
|
269
|
+
|
|
270
|
+
const experience = {
|
|
271
|
+
id: 'no-escape',
|
|
272
|
+
layout: 'modal',
|
|
273
|
+
content: {
|
|
274
|
+
message: 'Cannot escape',
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
sdk.modal.show(experience);
|
|
279
|
+
|
|
280
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
281
|
+
document.dispatchEvent(event);
|
|
282
|
+
|
|
283
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should emit dismissed event when closed', async () => {
|
|
287
|
+
const dismissedListener = vi.fn();
|
|
288
|
+
sdk.on('experiences:dismissed', dismissedListener);
|
|
289
|
+
|
|
290
|
+
const experience = {
|
|
291
|
+
id: 'dismiss-event',
|
|
292
|
+
layout: 'modal',
|
|
293
|
+
content: {
|
|
294
|
+
message: 'Will dismiss',
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
sdk.modal.show(experience);
|
|
299
|
+
sdk.modal.remove('dismiss-event');
|
|
300
|
+
|
|
301
|
+
await vi.waitFor(() => {
|
|
302
|
+
expect(dismissedListener).toHaveBeenCalled();
|
|
303
|
+
const event = dismissedListener.mock.calls[0][0];
|
|
304
|
+
expect(event.experienceId).toBe('dismiss-event');
|
|
305
|
+
expect(event.timestamp).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('Button Actions', () => {
|
|
311
|
+
it('should emit action event when button is clicked', async () => {
|
|
312
|
+
const actionListener = vi.fn();
|
|
313
|
+
sdk.on('experiences:action', actionListener);
|
|
314
|
+
|
|
315
|
+
const experience = {
|
|
316
|
+
id: 'button-action',
|
|
317
|
+
layout: 'modal',
|
|
318
|
+
content: {
|
|
319
|
+
message: 'Click button',
|
|
320
|
+
buttons: [{ text: 'Submit', action: 'submit' }],
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
sdk.modal.show(experience);
|
|
325
|
+
|
|
326
|
+
const button = document.querySelector('.xp-modal__button') as HTMLElement;
|
|
327
|
+
button.click();
|
|
328
|
+
|
|
329
|
+
await vi.waitFor(() => {
|
|
330
|
+
expect(actionListener).toHaveBeenCalled();
|
|
331
|
+
const event = actionListener.mock.calls[0][0];
|
|
332
|
+
expect(event.experienceId).toBe('button-action');
|
|
333
|
+
expect(event.action).toBe('submit');
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should dismiss modal when button has dismiss:true', () => {
|
|
338
|
+
const experience = {
|
|
339
|
+
id: 'dismiss-button',
|
|
340
|
+
layout: 'modal',
|
|
341
|
+
content: {
|
|
342
|
+
message: 'Auto dismiss',
|
|
343
|
+
buttons: [{ text: 'Close', dismiss: true }],
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
sdk.modal.show(experience);
|
|
348
|
+
|
|
349
|
+
const button = document.querySelector('.xp-modal__button') as HTMLElement;
|
|
350
|
+
button.click();
|
|
351
|
+
|
|
352
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should navigate to URL when button has url property', () => {
|
|
356
|
+
// Mock window.location.href
|
|
357
|
+
delete (window as any).location;
|
|
358
|
+
window.location = { href: '' } as any;
|
|
359
|
+
|
|
360
|
+
const experience = {
|
|
361
|
+
id: 'url-button',
|
|
362
|
+
layout: 'modal',
|
|
363
|
+
content: {
|
|
364
|
+
message: 'Navigate',
|
|
365
|
+
buttons: [{ text: 'Go', url: 'https://example.com' }],
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
sdk.modal.show(experience);
|
|
370
|
+
|
|
371
|
+
const button = document.querySelector('.xp-modal__button') as HTMLElement;
|
|
372
|
+
button.click();
|
|
373
|
+
|
|
374
|
+
expect(window.location.href).toBe('https://example.com');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('Focus Management', () => {
|
|
379
|
+
it('should set focus to first focusable element on open', () => {
|
|
380
|
+
const experience = {
|
|
381
|
+
id: 'focus-test',
|
|
382
|
+
layout: 'modal',
|
|
383
|
+
content: {
|
|
384
|
+
message: 'Focus test',
|
|
385
|
+
buttons: [{ text: 'First' }, { text: 'Second' }],
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
sdk.modal.show(experience);
|
|
390
|
+
|
|
391
|
+
// First focusable element should be the close button
|
|
392
|
+
const closeButton = document.querySelector('.xp-modal__close') as HTMLElement;
|
|
393
|
+
expect(document.activeElement).toBe(closeButton);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should return focus to previous element on close', () => {
|
|
397
|
+
// Create a button outside the modal
|
|
398
|
+
const externalButton = document.createElement('button');
|
|
399
|
+
externalButton.id = 'external-button';
|
|
400
|
+
document.body.appendChild(externalButton);
|
|
401
|
+
externalButton.focus();
|
|
402
|
+
|
|
403
|
+
const experience = {
|
|
404
|
+
id: 'focus-return',
|
|
405
|
+
layout: 'modal',
|
|
406
|
+
content: {
|
|
407
|
+
message: 'Focus return test',
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
sdk.modal.show(experience);
|
|
412
|
+
sdk.modal.remove('focus-return');
|
|
413
|
+
|
|
414
|
+
expect(document.activeElement).toBe(externalButton);
|
|
415
|
+
|
|
416
|
+
// Cleanup
|
|
417
|
+
externalButton.remove();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should trap focus within modal (Tab key)', () => {
|
|
421
|
+
const experience = {
|
|
422
|
+
id: 'focus-trap',
|
|
423
|
+
layout: 'modal',
|
|
424
|
+
content: {
|
|
425
|
+
message: 'Focus trap',
|
|
426
|
+
buttons: [{ text: 'First' }, { text: 'Last' }],
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
sdk.modal.show(experience);
|
|
431
|
+
|
|
432
|
+
const buttons = Array.from(document.querySelectorAll('.xp-modal__button')) as HTMLElement[];
|
|
433
|
+
const lastButton = buttons[buttons.length - 1];
|
|
434
|
+
|
|
435
|
+
// Focus last button
|
|
436
|
+
lastButton.focus();
|
|
437
|
+
|
|
438
|
+
// Simulate Tab key (should cycle to first)
|
|
439
|
+
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true });
|
|
440
|
+
document.querySelector('.xp-modal')?.dispatchEvent(tabEvent);
|
|
441
|
+
|
|
442
|
+
// If not prevented, activeElement would change
|
|
443
|
+
// Note: Full focus trap testing requires actual DOM focus behavior
|
|
444
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('Accessibility', () => {
|
|
449
|
+
it('should have correct ARIA attributes', () => {
|
|
450
|
+
const experience = {
|
|
451
|
+
id: 'aria-test',
|
|
452
|
+
layout: 'modal',
|
|
453
|
+
content: {
|
|
454
|
+
title: 'Accessible Modal',
|
|
455
|
+
message: 'ARIA test',
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
sdk.modal.show(experience);
|
|
460
|
+
|
|
461
|
+
const modal = document.querySelector('.xp-modal');
|
|
462
|
+
expect(modal?.getAttribute('role')).toBe('dialog');
|
|
463
|
+
expect(modal?.getAttribute('aria-modal')).toBe('true');
|
|
464
|
+
expect(modal?.getAttribute('aria-labelledby')).toBe('xp-modal-title-aria-test');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should not have aria-labelledby without title', () => {
|
|
468
|
+
const experience = {
|
|
469
|
+
id: 'no-title',
|
|
470
|
+
layout: 'modal',
|
|
471
|
+
content: {
|
|
472
|
+
message: 'No title',
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
sdk.modal.show(experience);
|
|
477
|
+
|
|
478
|
+
const modal = document.querySelector('.xp-modal');
|
|
479
|
+
expect(modal?.hasAttribute('aria-labelledby')).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('API Methods', () => {
|
|
484
|
+
it('should check if a specific modal is showing', () => {
|
|
485
|
+
const experience = {
|
|
486
|
+
id: 'check-modal',
|
|
487
|
+
layout: 'modal',
|
|
488
|
+
content: {
|
|
489
|
+
message: 'Check me',
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
expect(sdk.modal.isShowing('check-modal')).toBe(false);
|
|
494
|
+
|
|
495
|
+
sdk.modal.show(experience);
|
|
496
|
+
expect(sdk.modal.isShowing('check-modal')).toBe(true);
|
|
497
|
+
|
|
498
|
+
sdk.modal.remove('check-modal');
|
|
499
|
+
expect(sdk.modal.isShowing('check-modal')).toBe(false);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should check if any modal is showing', () => {
|
|
503
|
+
expect(sdk.modal.isShowing()).toBe(false);
|
|
504
|
+
|
|
505
|
+
const experience = {
|
|
506
|
+
id: 'any-modal',
|
|
507
|
+
layout: 'modal',
|
|
508
|
+
content: {
|
|
509
|
+
message: 'Any check',
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
sdk.modal.show(experience);
|
|
514
|
+
expect(sdk.modal.isShowing()).toBe(true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should not show duplicate modals', () => {
|
|
518
|
+
const experience = {
|
|
519
|
+
id: 'duplicate-test',
|
|
520
|
+
layout: 'modal',
|
|
521
|
+
content: {
|
|
522
|
+
message: 'Duplicate',
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
sdk.modal.show(experience);
|
|
527
|
+
sdk.modal.show(experience);
|
|
528
|
+
|
|
529
|
+
const modals = document.querySelectorAll('.xp-modal');
|
|
530
|
+
expect(modals.length).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('should handle removing non-existent modal gracefully', () => {
|
|
534
|
+
expect(() => sdk.modal.remove('non-existent')).not.toThrow();
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe('Events', () => {
|
|
539
|
+
it('should emit shown event when modal is displayed', async () => {
|
|
540
|
+
const shownListener = vi.fn();
|
|
541
|
+
sdk.on('experiences:shown', shownListener);
|
|
542
|
+
|
|
543
|
+
const experience = {
|
|
544
|
+
id: 'shown-event',
|
|
545
|
+
layout: 'modal',
|
|
546
|
+
content: {
|
|
547
|
+
message: 'Show event',
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
sdk.modal.show(experience);
|
|
552
|
+
|
|
553
|
+
await vi.waitFor(() => {
|
|
554
|
+
expect(shownListener).toHaveBeenCalled();
|
|
555
|
+
const event = shownListener.mock.calls[0][0];
|
|
556
|
+
expect(event.experienceId).toBe('shown-event');
|
|
557
|
+
expect(event.timestamp).toBeDefined();
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('should emit trigger:modal event when shown', async () => {
|
|
562
|
+
const triggerListener = vi.fn();
|
|
563
|
+
sdk.on('trigger:modal', triggerListener);
|
|
564
|
+
|
|
565
|
+
const experience = {
|
|
566
|
+
id: 'trigger-event',
|
|
567
|
+
layout: 'modal',
|
|
568
|
+
content: {
|
|
569
|
+
message: 'Trigger event',
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
sdk.modal.show(experience);
|
|
574
|
+
|
|
575
|
+
await vi.waitFor(() => {
|
|
576
|
+
expect(triggerListener).toHaveBeenCalled();
|
|
577
|
+
const event = triggerListener.mock.calls[0][0];
|
|
578
|
+
expect(event.experienceId).toBe('trigger-event');
|
|
579
|
+
expect(event.shown).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
describe('Cleanup', () => {
|
|
585
|
+
it('should remove all modals on destroy', async () => {
|
|
586
|
+
const experience1 = {
|
|
587
|
+
id: 'cleanup-1',
|
|
588
|
+
layout: 'modal',
|
|
589
|
+
content: { message: 'First' },
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
sdk.modal.show(experience1);
|
|
593
|
+
|
|
594
|
+
expect(document.querySelectorAll('.xp-modal').length).toBe(1);
|
|
595
|
+
|
|
596
|
+
await sdk.destroy();
|
|
597
|
+
|
|
598
|
+
expect(document.querySelectorAll('.xp-modal').length).toBe(0);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('should remove event listeners on modal removal', () => {
|
|
602
|
+
const experience = {
|
|
603
|
+
id: 'listener-cleanup',
|
|
604
|
+
layout: 'modal',
|
|
605
|
+
content: {
|
|
606
|
+
message: 'Cleanup listeners',
|
|
607
|
+
},
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
sdk.modal.show(experience);
|
|
611
|
+
sdk.modal.remove('listener-cleanup');
|
|
612
|
+
|
|
613
|
+
// Try to trigger escape again - should not cause issues
|
|
614
|
+
const event = new KeyboardEvent('keydown', { key: 'Escape' });
|
|
615
|
+
document.dispatchEvent(event);
|
|
616
|
+
|
|
617
|
+
// No modal should exist
|
|
618
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
describe('Size Variants', () => {
|
|
623
|
+
it('should render small modal (400px)', () => {
|
|
624
|
+
sdk.set('modal.size', 'sm');
|
|
625
|
+
|
|
626
|
+
const experience = {
|
|
627
|
+
id: 'small-modal',
|
|
628
|
+
layout: 'modal',
|
|
629
|
+
content: {
|
|
630
|
+
message: 'Small modal',
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
sdk.modal.show(experience);
|
|
635
|
+
|
|
636
|
+
const dialog = document.querySelector('.xp-modal__dialog') as HTMLElement;
|
|
637
|
+
expect(dialog).toBeTruthy();
|
|
638
|
+
expect(dialog.style.maxWidth).toContain('400px');
|
|
639
|
+
expect(document.querySelector('.xp-modal--sm')).toBeTruthy();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('should render medium modal (600px, default)', () => {
|
|
643
|
+
const experience = {
|
|
644
|
+
id: 'medium-modal',
|
|
645
|
+
layout: 'modal',
|
|
646
|
+
content: {
|
|
647
|
+
message: 'Medium modal',
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
sdk.modal.show(experience);
|
|
652
|
+
|
|
653
|
+
const dialog = document.querySelector('.xp-modal__dialog') as HTMLElement;
|
|
654
|
+
expect(dialog.style.maxWidth).toContain('600px');
|
|
655
|
+
expect(document.querySelector('.xp-modal--md')).toBeTruthy();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should render large modal (800px)', () => {
|
|
659
|
+
sdk.set('modal.size', 'lg');
|
|
660
|
+
|
|
661
|
+
const experience = {
|
|
662
|
+
id: 'large-modal',
|
|
663
|
+
layout: 'modal',
|
|
664
|
+
content: {
|
|
665
|
+
message: 'Large modal',
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
sdk.modal.show(experience);
|
|
670
|
+
|
|
671
|
+
const dialog = document.querySelector('.xp-modal__dialog') as HTMLElement;
|
|
672
|
+
expect(dialog.style.maxWidth).toContain('800px');
|
|
673
|
+
expect(document.querySelector('.xp-modal--lg')).toBeTruthy();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should render fullscreen modal', () => {
|
|
677
|
+
sdk.set('modal.size', 'fullscreen');
|
|
678
|
+
|
|
679
|
+
const experience = {
|
|
680
|
+
id: 'fullscreen-modal',
|
|
681
|
+
layout: 'modal',
|
|
682
|
+
content: {
|
|
683
|
+
message: 'Fullscreen modal',
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
sdk.modal.show(experience);
|
|
688
|
+
|
|
689
|
+
const dialog = document.querySelector('.xp-modal__dialog') as HTMLElement;
|
|
690
|
+
expect(dialog.style.maxWidth).toBe('100%');
|
|
691
|
+
expect(dialog.style.height).toBe('100%');
|
|
692
|
+
expect(document.querySelector('.xp-modal--fullscreen')).toBeTruthy();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('should render auto-sized modal', () => {
|
|
696
|
+
sdk.set('modal.size', 'auto');
|
|
697
|
+
|
|
698
|
+
const experience = {
|
|
699
|
+
id: 'auto-modal',
|
|
700
|
+
layout: 'modal',
|
|
701
|
+
content: {
|
|
702
|
+
message: 'Auto modal',
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
sdk.modal.show(experience);
|
|
707
|
+
|
|
708
|
+
const dialog = document.querySelector('.xp-modal__dialog') as HTMLElement;
|
|
709
|
+
expect(dialog.style.maxWidth).toBe('none'); // CSS 'none' for no max-width
|
|
710
|
+
expect(document.querySelector('.xp-modal--auto')).toBeTruthy();
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe('Mobile Behavior', () => {
|
|
715
|
+
it('should respect mobileFullscreen: false override', async () => {
|
|
716
|
+
// Test configuration API (doesn't depend on window.innerWidth)
|
|
717
|
+
const newSdk = initPlugin({
|
|
718
|
+
modal: {
|
|
719
|
+
size: 'lg',
|
|
720
|
+
mobileFullscreen: false,
|
|
721
|
+
},
|
|
722
|
+
}) as SDK & { modal: any };
|
|
723
|
+
await newSdk.init();
|
|
724
|
+
|
|
725
|
+
const experience = {
|
|
726
|
+
id: 'lg-no-mobile',
|
|
727
|
+
layout: 'modal',
|
|
728
|
+
content: {
|
|
729
|
+
message: 'Large without mobile fullscreen',
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
newSdk.modal.show(experience);
|
|
734
|
+
|
|
735
|
+
// When mobileFullscreen is false, should show 'lg' not 'fullscreen'
|
|
736
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
737
|
+
expect(modal.classList.contains('xp-modal--lg')).toBe(true);
|
|
738
|
+
|
|
739
|
+
// Cleanup
|
|
740
|
+
await newSdk.destroy();
|
|
741
|
+
});
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
describe('Hero Images', () => {
|
|
745
|
+
it('should render hero image at top', () => {
|
|
746
|
+
const experience = {
|
|
747
|
+
id: 'image-modal',
|
|
748
|
+
layout: 'modal',
|
|
749
|
+
content: {
|
|
750
|
+
image: {
|
|
751
|
+
src: 'https://example.com/hero.jpg',
|
|
752
|
+
alt: 'Hero image',
|
|
753
|
+
},
|
|
754
|
+
message: 'With hero image',
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
sdk.modal.show(experience);
|
|
759
|
+
|
|
760
|
+
const image = document.querySelector('.xp-modal__hero-image') as HTMLImageElement;
|
|
761
|
+
expect(image).toBeTruthy();
|
|
762
|
+
expect(image.src).toBe('https://example.com/hero.jpg');
|
|
763
|
+
expect(image.alt).toBe('Hero image');
|
|
764
|
+
expect(image.loading).toBe('lazy');
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it('should apply custom maxHeight to image', () => {
|
|
768
|
+
const experience = {
|
|
769
|
+
id: 'custom-image',
|
|
770
|
+
layout: 'modal',
|
|
771
|
+
content: {
|
|
772
|
+
image: {
|
|
773
|
+
src: 'https://example.com/hero.jpg',
|
|
774
|
+
alt: 'Hero image',
|
|
775
|
+
maxHeight: 400,
|
|
776
|
+
},
|
|
777
|
+
message: 'Custom image height',
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
sdk.modal.show(experience);
|
|
782
|
+
|
|
783
|
+
const image = document.querySelector('.xp-modal__hero-image') as HTMLElement;
|
|
784
|
+
expect(image.style.maxHeight).toBe('400px');
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('should add has-image class to dialog', () => {
|
|
788
|
+
const experience = {
|
|
789
|
+
id: 'image-class',
|
|
790
|
+
layout: 'modal',
|
|
791
|
+
content: {
|
|
792
|
+
image: {
|
|
793
|
+
src: 'https://example.com/hero.jpg',
|
|
794
|
+
alt: 'Hero',
|
|
795
|
+
},
|
|
796
|
+
message: 'Test',
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
sdk.modal.show(experience);
|
|
801
|
+
|
|
802
|
+
expect(document.querySelector('.xp-modal__dialog--has-image')).toBeTruthy();
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
describe('Position & Animation', () => {
|
|
807
|
+
it('should position modal at bottom', () => {
|
|
808
|
+
sdk.set('modal.position', 'bottom');
|
|
809
|
+
|
|
810
|
+
const experience = {
|
|
811
|
+
id: 'bottom-modal',
|
|
812
|
+
layout: 'modal',
|
|
813
|
+
content: {
|
|
814
|
+
message: 'Bottom positioned',
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
sdk.modal.show(experience);
|
|
819
|
+
|
|
820
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
821
|
+
expect(modal.classList.contains('xp-modal--bottom')).toBe(true);
|
|
822
|
+
expect(modal.style.alignItems).toBe('flex-end');
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('should apply fade animation by default', () => {
|
|
826
|
+
const experience = {
|
|
827
|
+
id: 'fade-modal',
|
|
828
|
+
layout: 'modal',
|
|
829
|
+
content: {
|
|
830
|
+
message: 'Fade in',
|
|
831
|
+
},
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
sdk.modal.show(experience);
|
|
835
|
+
|
|
836
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
837
|
+
expect(modal.classList.contains('xp-modal--fade')).toBe(true);
|
|
838
|
+
expect(modal.style.transition).toContain('opacity');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('should apply slide-up animation', () => {
|
|
842
|
+
sdk.set('modal.animation', 'slide-up');
|
|
843
|
+
|
|
844
|
+
const experience = {
|
|
845
|
+
id: 'slide-modal',
|
|
846
|
+
layout: 'modal',
|
|
847
|
+
content: {
|
|
848
|
+
message: 'Slide up',
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
|
|
852
|
+
sdk.modal.show(experience);
|
|
853
|
+
|
|
854
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
855
|
+
expect(modal.classList.contains('xp-modal--slide-up')).toBe(true);
|
|
856
|
+
expect(modal.style.transition).toContain('transform');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('should support no animation', () => {
|
|
860
|
+
sdk.set('modal.animation', 'none');
|
|
861
|
+
|
|
862
|
+
const experience = {
|
|
863
|
+
id: 'no-animation',
|
|
864
|
+
layout: 'modal',
|
|
865
|
+
content: {
|
|
866
|
+
message: 'No animation',
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
sdk.modal.show(experience);
|
|
871
|
+
|
|
872
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
873
|
+
expect(modal.classList.contains('xp-modal--fade')).toBe(false);
|
|
874
|
+
expect(modal.classList.contains('xp-modal--slide-up')).toBe(false);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('should respect custom animation duration', () => {
|
|
878
|
+
sdk.set('modal.animationDuration', 500);
|
|
879
|
+
|
|
880
|
+
const experience = {
|
|
881
|
+
id: 'custom-duration',
|
|
882
|
+
layout: 'modal',
|
|
883
|
+
content: {
|
|
884
|
+
message: 'Custom duration',
|
|
885
|
+
},
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
sdk.modal.show(experience);
|
|
889
|
+
|
|
890
|
+
const modal = document.querySelector('.xp-modal') as HTMLElement;
|
|
891
|
+
expect(modal.style.transition).toContain('500ms');
|
|
892
|
+
});
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
describe('Form Support', () => {
|
|
896
|
+
it('should render form with fields', () => {
|
|
897
|
+
const experience = {
|
|
898
|
+
id: 'form-test',
|
|
899
|
+
layout: 'modal',
|
|
900
|
+
content: {
|
|
901
|
+
title: 'Newsletter Signup',
|
|
902
|
+
message: 'Subscribe to our newsletter',
|
|
903
|
+
form: {
|
|
904
|
+
fields: [
|
|
905
|
+
{
|
|
906
|
+
name: 'email',
|
|
907
|
+
type: 'email' as const,
|
|
908
|
+
label: 'Email',
|
|
909
|
+
required: true,
|
|
910
|
+
placeholder: 'you@example.com',
|
|
911
|
+
},
|
|
912
|
+
{ name: 'name', type: 'text' as const, label: 'Name', required: false },
|
|
913
|
+
],
|
|
914
|
+
submitButton: { text: 'Subscribe', action: 'submit' },
|
|
915
|
+
},
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
sdk.modal.show(experience);
|
|
920
|
+
|
|
921
|
+
const form = document.querySelector('.xp-modal__form');
|
|
922
|
+
expect(form).toBeTruthy();
|
|
923
|
+
|
|
924
|
+
const emailInput = document.querySelector('#form-test-email') as HTMLInputElement;
|
|
925
|
+
const nameInput = document.querySelector('#form-test-name') as HTMLInputElement;
|
|
926
|
+
const submitButton = form?.querySelector('button[type="submit"]');
|
|
927
|
+
|
|
928
|
+
expect(emailInput).toBeTruthy();
|
|
929
|
+
expect(emailInput.type).toBe('email');
|
|
930
|
+
expect(emailInput.placeholder).toBe('you@example.com');
|
|
931
|
+
expect(emailInput.required).toBe(true);
|
|
932
|
+
|
|
933
|
+
expect(nameInput).toBeTruthy();
|
|
934
|
+
expect(nameInput.type).toBe('text');
|
|
935
|
+
|
|
936
|
+
expect(submitButton).toBeTruthy();
|
|
937
|
+
expect(submitButton?.textContent).toBe('Subscribe');
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('should emit change event on input', async () => {
|
|
941
|
+
const experience = {
|
|
942
|
+
id: 'change-test',
|
|
943
|
+
layout: 'modal',
|
|
944
|
+
content: {
|
|
945
|
+
message: 'Test',
|
|
946
|
+
form: {
|
|
947
|
+
fields: [{ name: 'email', type: 'email' as const }],
|
|
948
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const changeHandler = vi.fn();
|
|
954
|
+
sdk.on('experiences:modal:form:change', changeHandler);
|
|
955
|
+
|
|
956
|
+
sdk.modal.show(experience);
|
|
957
|
+
|
|
958
|
+
const input = document.querySelector('#change-test-email') as HTMLInputElement;
|
|
959
|
+
input.value = 'test@example.com';
|
|
960
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
961
|
+
|
|
962
|
+
await vi.waitFor(() => {
|
|
963
|
+
expect(changeHandler).toHaveBeenCalledWith(
|
|
964
|
+
expect.objectContaining({
|
|
965
|
+
experienceId: 'change-test',
|
|
966
|
+
field: 'email',
|
|
967
|
+
value: 'test@example.com',
|
|
968
|
+
})
|
|
969
|
+
);
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it('should validate field on blur', async () => {
|
|
974
|
+
const experience = {
|
|
975
|
+
id: 'validation-test',
|
|
976
|
+
layout: 'modal',
|
|
977
|
+
content: {
|
|
978
|
+
message: 'Test',
|
|
979
|
+
form: {
|
|
980
|
+
fields: [{ name: 'email', type: 'email' as const, required: true }],
|
|
981
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const validationHandler = vi.fn();
|
|
987
|
+
sdk.on('experiences:modal:form:validation', validationHandler);
|
|
988
|
+
|
|
989
|
+
sdk.modal.show(experience);
|
|
990
|
+
|
|
991
|
+
const input = document.querySelector('#validation-test-email') as HTMLInputElement;
|
|
992
|
+
const errorEl = document.querySelector('#validation-test-email-error') as HTMLElement;
|
|
993
|
+
|
|
994
|
+
// Blur with empty value (required field)
|
|
995
|
+
input.value = '';
|
|
996
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
997
|
+
|
|
998
|
+
await vi.waitFor(() => {
|
|
999
|
+
expect(validationHandler).toHaveBeenCalledWith(
|
|
1000
|
+
expect.objectContaining({
|
|
1001
|
+
experienceId: 'validation-test',
|
|
1002
|
+
field: 'email',
|
|
1003
|
+
valid: false,
|
|
1004
|
+
})
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
expect(errorEl.textContent).toContain('required');
|
|
1009
|
+
expect(input.getAttribute('aria-invalid')).toBe('true');
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('should clear errors when field becomes valid', async () => {
|
|
1013
|
+
const experience = {
|
|
1014
|
+
id: 'clear-error-test',
|
|
1015
|
+
layout: 'modal',
|
|
1016
|
+
content: {
|
|
1017
|
+
message: 'Test',
|
|
1018
|
+
form: {
|
|
1019
|
+
fields: [{ name: 'email', type: 'email' as const, required: true }],
|
|
1020
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
sdk.modal.show(experience);
|
|
1026
|
+
|
|
1027
|
+
const input = document.querySelector('#clear-error-test-email') as HTMLInputElement;
|
|
1028
|
+
const errorEl = document.querySelector('#clear-error-test-email-error') as HTMLElement;
|
|
1029
|
+
|
|
1030
|
+
// First, trigger error
|
|
1031
|
+
input.value = '';
|
|
1032
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1033
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
1034
|
+
|
|
1035
|
+
await vi.waitFor(() => {
|
|
1036
|
+
expect(errorEl.textContent).toContain('required');
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// Now fix the value
|
|
1040
|
+
input.value = 'valid@example.com';
|
|
1041
|
+
input.dispatchEvent(new Event('input', { bubbles: true })); // Update formData first!
|
|
1042
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }));
|
|
1043
|
+
|
|
1044
|
+
await vi.waitFor(() => {
|
|
1045
|
+
expect(errorEl.textContent).toBe('');
|
|
1046
|
+
expect(input.getAttribute('aria-invalid')).toBe('false');
|
|
1047
|
+
});
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('should validate entire form on submit', async () => {
|
|
1051
|
+
const experience = {
|
|
1052
|
+
id: 'submit-test',
|
|
1053
|
+
layout: 'modal',
|
|
1054
|
+
content: {
|
|
1055
|
+
message: 'Test',
|
|
1056
|
+
form: {
|
|
1057
|
+
fields: [
|
|
1058
|
+
{ name: 'email', type: 'email' as const, required: true },
|
|
1059
|
+
{ name: 'name', type: 'text' as const, required: true },
|
|
1060
|
+
],
|
|
1061
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const submitHandler = vi.fn();
|
|
1067
|
+
const validationHandler = vi.fn();
|
|
1068
|
+
sdk.on('experiences:modal:form:submit', submitHandler);
|
|
1069
|
+
sdk.on('experiences:modal:form:validation', validationHandler);
|
|
1070
|
+
|
|
1071
|
+
sdk.modal.show(experience);
|
|
1072
|
+
|
|
1073
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
1074
|
+
const emailError = document.querySelector('#submit-test-email-error') as HTMLElement;
|
|
1075
|
+
const nameError = document.querySelector('#submit-test-name-error') as HTMLElement;
|
|
1076
|
+
|
|
1077
|
+
// Submit with empty values
|
|
1078
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
1079
|
+
|
|
1080
|
+
await vi.waitFor(() => {
|
|
1081
|
+
expect(emailError.textContent).toContain('required');
|
|
1082
|
+
expect(nameError.textContent).toContain('required');
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
// Should not emit submit event
|
|
1086
|
+
expect(submitHandler).not.toHaveBeenCalled();
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('should emit submit event when form is valid', async () => {
|
|
1090
|
+
const experience = {
|
|
1091
|
+
id: 'valid-submit-test',
|
|
1092
|
+
layout: 'modal',
|
|
1093
|
+
content: {
|
|
1094
|
+
message: 'Test',
|
|
1095
|
+
form: {
|
|
1096
|
+
fields: [{ name: 'email', type: 'email' as const, required: true }],
|
|
1097
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1098
|
+
},
|
|
1099
|
+
},
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
const submitHandler = vi.fn();
|
|
1103
|
+
sdk.on('experiences:modal:form:submit', submitHandler);
|
|
1104
|
+
|
|
1105
|
+
sdk.modal.show(experience);
|
|
1106
|
+
|
|
1107
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
1108
|
+
const input = document.querySelector('#valid-submit-test-email') as HTMLInputElement;
|
|
1109
|
+
|
|
1110
|
+
// Fill in valid data
|
|
1111
|
+
input.value = 'test@example.com';
|
|
1112
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1113
|
+
|
|
1114
|
+
// Submit form
|
|
1115
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
1116
|
+
|
|
1117
|
+
await vi.waitFor(() => {
|
|
1118
|
+
expect(submitHandler).toHaveBeenCalledWith(
|
|
1119
|
+
expect.objectContaining({
|
|
1120
|
+
experienceId: 'valid-submit-test',
|
|
1121
|
+
formData: { email: 'test@example.com' },
|
|
1122
|
+
})
|
|
1123
|
+
);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
it('should disable submit button during submission', async () => {
|
|
1128
|
+
const experience = {
|
|
1129
|
+
id: 'disable-test',
|
|
1130
|
+
layout: 'modal',
|
|
1131
|
+
content: {
|
|
1132
|
+
message: 'Test',
|
|
1133
|
+
form: {
|
|
1134
|
+
fields: [{ name: 'email', type: 'email' as const, required: true }],
|
|
1135
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
sdk.modal.show(experience);
|
|
1141
|
+
|
|
1142
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
1143
|
+
const input = document.querySelector('#disable-test-email') as HTMLInputElement;
|
|
1144
|
+
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
|
1145
|
+
|
|
1146
|
+
input.value = 'test@example.com';
|
|
1147
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1148
|
+
|
|
1149
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
1150
|
+
|
|
1151
|
+
await vi.waitFor(() => {
|
|
1152
|
+
expect(submitButton.disabled).toBe(true);
|
|
1153
|
+
expect(submitButton.textContent).toBe('Submitting...');
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it('should get form data', () => {
|
|
1158
|
+
const experience = {
|
|
1159
|
+
id: 'get-data-test',
|
|
1160
|
+
layout: 'modal',
|
|
1161
|
+
content: {
|
|
1162
|
+
message: 'Test',
|
|
1163
|
+
form: {
|
|
1164
|
+
fields: [
|
|
1165
|
+
{ name: 'email', type: 'email' as const },
|
|
1166
|
+
{ name: 'name', type: 'text' as const },
|
|
1167
|
+
],
|
|
1168
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
sdk.modal.show(experience);
|
|
1174
|
+
|
|
1175
|
+
const emailInput = document.querySelector('#get-data-test-email') as HTMLInputElement;
|
|
1176
|
+
const nameInput = document.querySelector('#get-data-test-name') as HTMLInputElement;
|
|
1177
|
+
|
|
1178
|
+
emailInput.value = 'test@example.com';
|
|
1179
|
+
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1180
|
+
|
|
1181
|
+
nameInput.value = 'John Doe';
|
|
1182
|
+
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1183
|
+
|
|
1184
|
+
const data = sdk.modal.getFormData('get-data-test');
|
|
1185
|
+
expect(data).toEqual({
|
|
1186
|
+
email: 'test@example.com',
|
|
1187
|
+
name: 'John Doe',
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it('should reset form', () => {
|
|
1192
|
+
const experience = {
|
|
1193
|
+
id: 'reset-test',
|
|
1194
|
+
layout: 'modal',
|
|
1195
|
+
content: {
|
|
1196
|
+
message: 'Test',
|
|
1197
|
+
form: {
|
|
1198
|
+
fields: [{ name: 'email', type: 'email' as const }],
|
|
1199
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1200
|
+
},
|
|
1201
|
+
},
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
sdk.modal.show(experience);
|
|
1205
|
+
|
|
1206
|
+
const input = document.querySelector('#reset-test-email') as HTMLInputElement;
|
|
1207
|
+
const errorEl = document.querySelector('#reset-test-email-error') as HTMLElement;
|
|
1208
|
+
|
|
1209
|
+
// Add some data and error
|
|
1210
|
+
input.value = 'test@example.com';
|
|
1211
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
1212
|
+
errorEl.textContent = 'Some error';
|
|
1213
|
+
|
|
1214
|
+
sdk.modal.resetForm('reset-test');
|
|
1215
|
+
|
|
1216
|
+
expect(input.value).toBe('');
|
|
1217
|
+
expect(errorEl.textContent).toBe('');
|
|
1218
|
+
|
|
1219
|
+
const data = sdk.modal.getFormData('reset-test');
|
|
1220
|
+
expect(data).toEqual({ email: '' });
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it('should show success state', async () => {
|
|
1224
|
+
const experience = {
|
|
1225
|
+
id: 'success-test',
|
|
1226
|
+
layout: 'modal',
|
|
1227
|
+
content: {
|
|
1228
|
+
message: 'Test',
|
|
1229
|
+
form: {
|
|
1230
|
+
fields: [{ name: 'email', type: 'email' as const }],
|
|
1231
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1232
|
+
successState: {
|
|
1233
|
+
title: 'Success!',
|
|
1234
|
+
message: 'Thank you for subscribing',
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
const stateHandler = vi.fn();
|
|
1241
|
+
sdk.on('experiences:modal:form:state', stateHandler);
|
|
1242
|
+
|
|
1243
|
+
sdk.modal.show(experience);
|
|
1244
|
+
|
|
1245
|
+
sdk.modal.showFormState('success-test', 'success');
|
|
1246
|
+
|
|
1247
|
+
await vi.waitFor(() => {
|
|
1248
|
+
expect(stateHandler).toHaveBeenCalledWith(
|
|
1249
|
+
expect.objectContaining({
|
|
1250
|
+
experienceId: 'success-test',
|
|
1251
|
+
state: 'success',
|
|
1252
|
+
})
|
|
1253
|
+
);
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
const form = document.querySelector('.xp-modal__form');
|
|
1257
|
+
const state = document.querySelector('.xp-form__state--success');
|
|
1258
|
+
|
|
1259
|
+
expect(form).toBeFalsy();
|
|
1260
|
+
expect(state).toBeTruthy();
|
|
1261
|
+
expect(state?.textContent).toContain('Success!');
|
|
1262
|
+
expect(state?.textContent).toContain('Thank you for subscribing');
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it('should show error state', async () => {
|
|
1266
|
+
const experience = {
|
|
1267
|
+
id: 'error-test',
|
|
1268
|
+
layout: 'modal',
|
|
1269
|
+
content: {
|
|
1270
|
+
message: 'Test',
|
|
1271
|
+
form: {
|
|
1272
|
+
fields: [{ name: 'email', type: 'email' as const }],
|
|
1273
|
+
submitButton: { text: 'Submit', action: 'submit' },
|
|
1274
|
+
errorState: {
|
|
1275
|
+
title: 'Error',
|
|
1276
|
+
message: 'Something went wrong',
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
};
|
|
1281
|
+
|
|
1282
|
+
sdk.modal.show(experience);
|
|
1283
|
+
|
|
1284
|
+
sdk.modal.showFormState('error-test', 'error');
|
|
1285
|
+
|
|
1286
|
+
await vi.waitFor(() => {
|
|
1287
|
+
const state = document.querySelector('.xp-form__state--error');
|
|
1288
|
+
expect(state).toBeTruthy();
|
|
1289
|
+
expect(state?.textContent).toContain('Error');
|
|
1290
|
+
expect(state?.textContent).toContain('Something went wrong');
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
});
|
|
1294
|
+
});
|