@prosdevlab/experience-sdk-plugins 0.1.4 → 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 +150 -0
- package/README.md +141 -79
- package/dist/index.d.ts +813 -35
- package/dist/index.js +1910 -66
- 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.test.ts +423 -0
- package/src/exit-intent/exit-intent.ts +371 -0
- package/src/exit-intent/index.ts +6 -0
- package/src/exit-intent/types.ts +59 -0
- package/src/index.ts +7 -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 +421 -0
- 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/page-visits/index.ts +6 -0
- package/src/page-visits/page-visits.test.ts +562 -0
- package/src/page-visits/page-visits.ts +314 -0
- package/src/page-visits/types.ts +119 -0
- package/src/scroll-depth/index.ts +6 -0
- package/src/scroll-depth/scroll-depth.test.ts +580 -0
- package/src/scroll-depth/scroll-depth.ts +398 -0
- package/src/scroll-depth/types.ts +122 -0
- package/src/time-delay/index.ts +6 -0
- package/src/time-delay/time-delay.test.ts +477 -0
- package/src/time-delay/time-delay.ts +296 -0
- package/src/time-delay/types.ts +89 -0
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +5 -2
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the interaction between plugins to ensure they work together correctly.
|
|
5
|
+
*
|
|
6
|
+
* @vitest-environment happy-dom
|
|
7
|
+
*/
|
|
8
|
+
import { SDK } from '@lytics/sdk-kit';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { inlinePlugin } from './inline';
|
|
11
|
+
import { modalPlugin } from './modal';
|
|
12
|
+
|
|
13
|
+
function initSDK() {
|
|
14
|
+
const sdk = new SDK({ name: 'integration-test' });
|
|
15
|
+
sdk.use(modalPlugin);
|
|
16
|
+
sdk.use(inlinePlugin);
|
|
17
|
+
|
|
18
|
+
if (!document.body) {
|
|
19
|
+
document.body = document.createElement('body');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return sdk;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Plugin Integration Tests', () => {
|
|
26
|
+
let sdk: SDK & { modal?: any; inline?: any };
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
sdk = initSDK();
|
|
30
|
+
await sdk.init();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
for (const el of document.querySelectorAll('.xp-modal, .xp-inline')) {
|
|
35
|
+
el.remove();
|
|
36
|
+
}
|
|
37
|
+
document.body.innerHTML = '';
|
|
38
|
+
if (sdk) {
|
|
39
|
+
await sdk.destroy();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('Modal + Inline Interaction', () => {
|
|
44
|
+
it('should show modal and inline simultaneously', async () => {
|
|
45
|
+
const shownHandler = vi.fn();
|
|
46
|
+
sdk.on('experiences:shown', shownHandler);
|
|
47
|
+
|
|
48
|
+
const target = document.createElement('div');
|
|
49
|
+
target.id = 'content';
|
|
50
|
+
document.body.appendChild(target);
|
|
51
|
+
|
|
52
|
+
const modalExp = {
|
|
53
|
+
id: 'popup',
|
|
54
|
+
type: 'modal',
|
|
55
|
+
content: {
|
|
56
|
+
title: 'Special Offer',
|
|
57
|
+
message: 'Limited time only!',
|
|
58
|
+
buttons: [{ text: 'Learn More', variant: 'primary' }],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const inlineExp = {
|
|
63
|
+
id: 'inline-banner',
|
|
64
|
+
type: 'inline',
|
|
65
|
+
content: {
|
|
66
|
+
selector: '#content',
|
|
67
|
+
message: '<p>Related: Check out our guide.</p>',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
sdk.modal.show(modalExp);
|
|
72
|
+
sdk.inline.show(inlineExp);
|
|
73
|
+
|
|
74
|
+
await vi.waitFor(() => {
|
|
75
|
+
expect(shownHandler).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
79
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should dismiss modal without affecting inline', async () => {
|
|
83
|
+
const dismissedHandler = vi.fn();
|
|
84
|
+
sdk.on('experiences:dismissed', dismissedHandler);
|
|
85
|
+
|
|
86
|
+
const target = document.createElement('div');
|
|
87
|
+
target.id = 'content';
|
|
88
|
+
document.body.appendChild(target);
|
|
89
|
+
|
|
90
|
+
const modalExp = {
|
|
91
|
+
id: 'dismissable-modal',
|
|
92
|
+
type: 'modal',
|
|
93
|
+
content: {
|
|
94
|
+
title: 'Notification',
|
|
95
|
+
message: 'This is a modal.',
|
|
96
|
+
dismissable: true,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const inlineExp = {
|
|
101
|
+
id: 'persistent-inline',
|
|
102
|
+
type: 'inline',
|
|
103
|
+
content: {
|
|
104
|
+
selector: '#content',
|
|
105
|
+
message: '<p>This stays.</p>',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
sdk.modal.show(modalExp);
|
|
110
|
+
sdk.inline.show(inlineExp);
|
|
111
|
+
|
|
112
|
+
await vi.waitFor(() => {
|
|
113
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
114
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Dismiss modal
|
|
118
|
+
const closeBtn = document.querySelector('.xp-modal__close') as HTMLElement;
|
|
119
|
+
closeBtn.click();
|
|
120
|
+
|
|
121
|
+
await vi.waitFor(() => {
|
|
122
|
+
expect(dismissedHandler).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
experienceId: 'dismissable-modal',
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Modal gone, inline remains
|
|
130
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
131
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should dismiss inline without affecting modal', async () => {
|
|
135
|
+
const target = document.createElement('div');
|
|
136
|
+
target.id = 'content';
|
|
137
|
+
document.body.appendChild(target);
|
|
138
|
+
|
|
139
|
+
const modalExp = {
|
|
140
|
+
id: 'persistent-modal',
|
|
141
|
+
type: 'modal',
|
|
142
|
+
content: {
|
|
143
|
+
title: 'Stay Open',
|
|
144
|
+
message: 'This modal stays.',
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const inlineExp = {
|
|
149
|
+
id: 'dismissable-inline',
|
|
150
|
+
type: 'inline',
|
|
151
|
+
content: {
|
|
152
|
+
selector: '#content',
|
|
153
|
+
message: '<p>Can dismiss</p>',
|
|
154
|
+
dismissable: true,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
sdk.modal.show(modalExp);
|
|
159
|
+
sdk.inline.show(inlineExp);
|
|
160
|
+
|
|
161
|
+
await vi.waitFor(() => {
|
|
162
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
163
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Dismiss inline
|
|
167
|
+
const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
|
|
168
|
+
closeBtn.click();
|
|
169
|
+
|
|
170
|
+
await vi.waitFor(() => {
|
|
171
|
+
expect(document.querySelector('.xp-inline')).toBeFalsy();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Inline gone, modal remains
|
|
175
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('Modal Forms', () => {
|
|
180
|
+
it('should render and submit form in modal', async () => {
|
|
181
|
+
const formSubmitHandler = vi.fn();
|
|
182
|
+
sdk.on('experiences:modal:form:submit', formSubmitHandler);
|
|
183
|
+
|
|
184
|
+
const experience = {
|
|
185
|
+
id: 'newsletter',
|
|
186
|
+
type: 'modal',
|
|
187
|
+
content: {
|
|
188
|
+
title: 'Subscribe',
|
|
189
|
+
message: 'Get updates.',
|
|
190
|
+
size: 'sm',
|
|
191
|
+
form: {
|
|
192
|
+
fields: [
|
|
193
|
+
{ name: 'email', type: 'email', required: true, placeholder: 'you@example.com' },
|
|
194
|
+
],
|
|
195
|
+
submitButton: { text: 'Subscribe', variant: 'primary' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
sdk.modal.show(experience);
|
|
201
|
+
|
|
202
|
+
await vi.waitFor(() => {
|
|
203
|
+
expect(document.querySelector('.xp-modal__form')).toBeTruthy();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Fill and submit form
|
|
207
|
+
const emailInput = document.querySelector('input[name="email"]') as HTMLInputElement;
|
|
208
|
+
emailInput.value = 'test@example.com';
|
|
209
|
+
emailInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
210
|
+
|
|
211
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
212
|
+
form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
213
|
+
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
expect(formSubmitHandler).toHaveBeenCalledWith(
|
|
216
|
+
expect.objectContaining({
|
|
217
|
+
experienceId: 'newsletter',
|
|
218
|
+
formData: { email: 'test@example.com' },
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should validate form fields', async () => {
|
|
225
|
+
const validationHandler = vi.fn();
|
|
226
|
+
sdk.on('experiences:modal:form:validation', validationHandler);
|
|
227
|
+
|
|
228
|
+
const experience = {
|
|
229
|
+
id: 'form-validation',
|
|
230
|
+
type: 'modal',
|
|
231
|
+
content: {
|
|
232
|
+
form: {
|
|
233
|
+
fields: [{ name: 'email', type: 'email', required: true }],
|
|
234
|
+
submitButton: { text: 'Submit', variant: 'primary' },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
sdk.modal.show(experience);
|
|
240
|
+
|
|
241
|
+
await vi.waitFor(() => {
|
|
242
|
+
expect(document.querySelector('.xp-modal__form')).toBeTruthy();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Submit empty form (should fail validation)
|
|
246
|
+
const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
247
|
+
form.dispatchEvent(new Event('submit', { bubbles: true }));
|
|
248
|
+
|
|
249
|
+
await vi.waitFor(() => {
|
|
250
|
+
expect(validationHandler).toHaveBeenCalledWith(
|
|
251
|
+
expect.objectContaining({
|
|
252
|
+
valid: false,
|
|
253
|
+
errors: expect.any(Object),
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('Multiple Instances', () => {
|
|
261
|
+
it('should handle multiple inline experiences in different locations', async () => {
|
|
262
|
+
const target1 = document.createElement('div');
|
|
263
|
+
target1.id = 'sidebar';
|
|
264
|
+
document.body.appendChild(target1);
|
|
265
|
+
|
|
266
|
+
const target2 = document.createElement('div');
|
|
267
|
+
target2.id = 'footer';
|
|
268
|
+
document.body.appendChild(target2);
|
|
269
|
+
|
|
270
|
+
sdk.inline.show({
|
|
271
|
+
id: 'sidebar-promo',
|
|
272
|
+
type: 'inline',
|
|
273
|
+
content: {
|
|
274
|
+
selector: '#sidebar',
|
|
275
|
+
message: '<p>Sidebar content</p>',
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
sdk.inline.show({
|
|
280
|
+
id: 'footer-cta',
|
|
281
|
+
type: 'inline',
|
|
282
|
+
content: {
|
|
283
|
+
selector: '#footer',
|
|
284
|
+
message: '<p>Footer content</p>',
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await vi.waitFor(() => {
|
|
289
|
+
expect(document.querySelectorAll('.xp-inline').length).toBe(2);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(target1.querySelector('.xp-inline')).toBeTruthy();
|
|
293
|
+
expect(target2.querySelector('.xp-inline')).toBeTruthy();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should replace existing modal when showing a new one', async () => {
|
|
297
|
+
const dismissedHandler = vi.fn();
|
|
298
|
+
sdk.on('experiences:dismissed', dismissedHandler);
|
|
299
|
+
|
|
300
|
+
// Show first modal
|
|
301
|
+
sdk.modal.show({
|
|
302
|
+
id: 'modal1',
|
|
303
|
+
type: 'modal',
|
|
304
|
+
content: { title: 'First', message: 'Modal 1' },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await vi.waitFor(() => {
|
|
308
|
+
expect(sdk.modal.isShowing('modal1')).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Show second modal (should replace first)
|
|
312
|
+
sdk.modal.show({
|
|
313
|
+
id: 'modal2',
|
|
314
|
+
type: 'modal',
|
|
315
|
+
content: { title: 'Second', message: 'Modal 2' },
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
await vi.waitFor(() => {
|
|
319
|
+
expect(sdk.modal.isShowing('modal2')).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Only second modal should be showing
|
|
323
|
+
expect(sdk.modal.isShowing('modal1')).toBe(false);
|
|
324
|
+
expect(sdk.modal.isShowing('modal2')).toBe(true);
|
|
325
|
+
expect(document.querySelectorAll('.xp-modal').length).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should prevent showing the same modal twice', async () => {
|
|
329
|
+
const shownHandler = vi.fn();
|
|
330
|
+
sdk.on('experiences:shown', shownHandler);
|
|
331
|
+
|
|
332
|
+
const experience = {
|
|
333
|
+
id: 'duplicate-test',
|
|
334
|
+
type: 'modal',
|
|
335
|
+
content: { title: 'Test', message: 'Cannot show twice' },
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
sdk.modal.show(experience);
|
|
339
|
+
sdk.modal.show(experience); // Try to show again
|
|
340
|
+
|
|
341
|
+
await vi.waitFor(() => {
|
|
342
|
+
expect(shownHandler).toHaveBeenCalledTimes(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Only one modal in DOM
|
|
346
|
+
expect(document.querySelectorAll('[data-xp-id="duplicate-test"]').length).toBe(1);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('Cleanup', () => {
|
|
351
|
+
it('should clean up all experiences on destroy', async () => {
|
|
352
|
+
const target = document.createElement('div');
|
|
353
|
+
target.id = 'content';
|
|
354
|
+
document.body.appendChild(target);
|
|
355
|
+
|
|
356
|
+
sdk.modal.show({
|
|
357
|
+
id: 'modal',
|
|
358
|
+
type: 'modal',
|
|
359
|
+
content: { title: 'Modal', message: 'Content' },
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
sdk.inline.show({
|
|
363
|
+
id: 'inline',
|
|
364
|
+
type: 'inline',
|
|
365
|
+
content: { selector: '#content', message: '<p>Inline</p>' },
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await vi.waitFor(() => {
|
|
369
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
370
|
+
expect(document.querySelector('.xp-inline')).toBeTruthy();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
await sdk.destroy();
|
|
374
|
+
|
|
375
|
+
expect(document.querySelector('.xp-modal')).toBeFalsy();
|
|
376
|
+
expect(document.querySelector('.xp-inline')).toBeFalsy();
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe('Event Flow', () => {
|
|
381
|
+
it('should emit events in correct order for modal', async () => {
|
|
382
|
+
const events: string[] = [];
|
|
383
|
+
|
|
384
|
+
sdk.on('experiences:shown', () => events.push('shown'));
|
|
385
|
+
sdk.on('experiences:action', () => events.push('action'));
|
|
386
|
+
sdk.on('experiences:dismissed', () => events.push('dismissed'));
|
|
387
|
+
|
|
388
|
+
sdk.modal.show({
|
|
389
|
+
id: 'event-test',
|
|
390
|
+
type: 'modal',
|
|
391
|
+
content: {
|
|
392
|
+
title: 'Test',
|
|
393
|
+
message: 'Testing events',
|
|
394
|
+
buttons: [{ text: 'Click Me', variant: 'primary', action: 'test' }],
|
|
395
|
+
dismissable: true,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await vi.waitFor(() => {
|
|
400
|
+
expect(document.querySelector('.xp-modal')).toBeTruthy();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Click button
|
|
404
|
+
const button = document.querySelector('.xp-modal__button') as HTMLElement;
|
|
405
|
+
button.click();
|
|
406
|
+
|
|
407
|
+
await vi.waitFor(() => {
|
|
408
|
+
expect(events).toContain('action');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Dismiss modal
|
|
412
|
+
sdk.modal.remove('event-test');
|
|
413
|
+
|
|
414
|
+
await vi.waitFor(() => {
|
|
415
|
+
expect(events).toContain('dismissed');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(events).toEqual(['shown', 'action', 'dismissed']);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure form rendering functions
|
|
3
|
+
*
|
|
4
|
+
* These functions are intentionally pure (return DOM elements, no side effects) to make them:
|
|
5
|
+
* - Easy to test
|
|
6
|
+
* - Easy to extract into a separate form plugin later
|
|
7
|
+
* - Reusable across different contexts
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExperienceButton } from '../types';
|
|
11
|
+
import {
|
|
12
|
+
getErrorMessageStyles,
|
|
13
|
+
getErrorStateStyles,
|
|
14
|
+
getFieldStyles,
|
|
15
|
+
getFormStateStyles,
|
|
16
|
+
getFormStyles,
|
|
17
|
+
getInputStyles,
|
|
18
|
+
getLabelStyles,
|
|
19
|
+
getRequiredStyles,
|
|
20
|
+
getStateButtonsStyles,
|
|
21
|
+
getStateMessageStyles,
|
|
22
|
+
getStateTitleStyles,
|
|
23
|
+
getSubmitButtonHoverBg,
|
|
24
|
+
getSubmitButtonStyles,
|
|
25
|
+
getSuccessStateStyles,
|
|
26
|
+
} from './form-styles';
|
|
27
|
+
import type { FormConfig, FormField } from './types';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render complete form element
|
|
31
|
+
*
|
|
32
|
+
* @param experienceId - Experience ID for namespacing field IDs
|
|
33
|
+
* @param config - Form configuration
|
|
34
|
+
* @returns Form HTML element
|
|
35
|
+
*/
|
|
36
|
+
export function renderForm(experienceId: string, config: FormConfig): HTMLFormElement {
|
|
37
|
+
const form = document.createElement('form');
|
|
38
|
+
form.className = 'xp-modal__form';
|
|
39
|
+
form.style.cssText = getFormStyles();
|
|
40
|
+
form.dataset.xpExperienceId = experienceId;
|
|
41
|
+
form.setAttribute('novalidate', ''); // Use custom validation instead of browser default
|
|
42
|
+
|
|
43
|
+
// Render each field
|
|
44
|
+
config.fields.forEach((field) => {
|
|
45
|
+
const fieldElement = renderFormField(experienceId, field);
|
|
46
|
+
form.appendChild(fieldElement);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Render submit button
|
|
50
|
+
const submitButton = renderSubmitButton(config.submitButton);
|
|
51
|
+
form.appendChild(submitButton);
|
|
52
|
+
|
|
53
|
+
return form;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a single form field with label and error container
|
|
58
|
+
*
|
|
59
|
+
* @param experienceId - Experience ID for namespacing field ID
|
|
60
|
+
* @param field - Field configuration
|
|
61
|
+
* @returns Field wrapper HTML element
|
|
62
|
+
*/
|
|
63
|
+
export function renderFormField(experienceId: string, field: FormField): HTMLElement {
|
|
64
|
+
const wrapper = document.createElement('div');
|
|
65
|
+
wrapper.className = 'xp-form__field';
|
|
66
|
+
wrapper.style.cssText = getFieldStyles();
|
|
67
|
+
|
|
68
|
+
// Label (optional)
|
|
69
|
+
if (field.label) {
|
|
70
|
+
const label = document.createElement('label');
|
|
71
|
+
label.className = 'xp-form__label';
|
|
72
|
+
label.style.cssText = getLabelStyles();
|
|
73
|
+
label.htmlFor = `${experienceId}-${field.name}`;
|
|
74
|
+
label.textContent = field.label;
|
|
75
|
+
|
|
76
|
+
// Required indicator
|
|
77
|
+
if (field.required) {
|
|
78
|
+
const required = document.createElement('span');
|
|
79
|
+
required.className = 'xp-form__required';
|
|
80
|
+
required.style.cssText = getRequiredStyles();
|
|
81
|
+
required.textContent = ' *';
|
|
82
|
+
required.setAttribute('aria-label', 'required');
|
|
83
|
+
label.appendChild(required);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
wrapper.appendChild(label);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Input element (input or textarea)
|
|
90
|
+
const input =
|
|
91
|
+
field.type === 'textarea'
|
|
92
|
+
? document.createElement('textarea')
|
|
93
|
+
: document.createElement('input');
|
|
94
|
+
|
|
95
|
+
input.className = 'xp-form__input';
|
|
96
|
+
input.style.cssText = getInputStyles();
|
|
97
|
+
input.id = `${experienceId}-${field.name}`;
|
|
98
|
+
input.name = field.name;
|
|
99
|
+
|
|
100
|
+
// Set type for input elements
|
|
101
|
+
if (input instanceof HTMLInputElement) {
|
|
102
|
+
input.type = field.type;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Placeholder
|
|
106
|
+
if (field.placeholder) {
|
|
107
|
+
input.placeholder = field.placeholder;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Required attribute (for screen readers)
|
|
111
|
+
if (field.required) {
|
|
112
|
+
input.required = true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Pattern attribute (for HTML5 validation as fallback)
|
|
116
|
+
if (field.pattern && input instanceof HTMLInputElement) {
|
|
117
|
+
input.setAttribute('pattern', field.pattern);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Accessibility attributes
|
|
121
|
+
input.setAttribute('aria-invalid', 'false');
|
|
122
|
+
input.setAttribute('aria-describedby', `${experienceId}-${field.name}-error`);
|
|
123
|
+
|
|
124
|
+
// Custom styling
|
|
125
|
+
if (field.className) {
|
|
126
|
+
input.className += ` ${field.className}`;
|
|
127
|
+
}
|
|
128
|
+
if (field.style) {
|
|
129
|
+
Object.assign(input.style, field.style);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
wrapper.appendChild(input);
|
|
133
|
+
|
|
134
|
+
// Error message container
|
|
135
|
+
const error = document.createElement('div');
|
|
136
|
+
error.className = 'xp-form__error';
|
|
137
|
+
error.style.cssText = getErrorMessageStyles();
|
|
138
|
+
error.id = `${experienceId}-${field.name}-error`;
|
|
139
|
+
error.setAttribute('role', 'alert');
|
|
140
|
+
error.setAttribute('aria-live', 'polite');
|
|
141
|
+
wrapper.appendChild(error);
|
|
142
|
+
|
|
143
|
+
return wrapper;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Render submit button
|
|
148
|
+
*
|
|
149
|
+
* @param buttonConfig - Button configuration
|
|
150
|
+
* @returns Button HTML element
|
|
151
|
+
*/
|
|
152
|
+
export function renderSubmitButton(buttonConfig: ExperienceButton): HTMLButtonElement {
|
|
153
|
+
const button = document.createElement('button');
|
|
154
|
+
button.type = 'submit';
|
|
155
|
+
button.className = 'xp-form__submit xp-modal__button';
|
|
156
|
+
button.style.cssText = getSubmitButtonStyles();
|
|
157
|
+
|
|
158
|
+
// Variant styling
|
|
159
|
+
if (buttonConfig.variant) {
|
|
160
|
+
button.className += ` xp-modal__button--${buttonConfig.variant}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Custom class
|
|
164
|
+
if (buttonConfig.className) {
|
|
165
|
+
button.className += ` ${buttonConfig.className}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Button text
|
|
169
|
+
button.textContent = buttonConfig.text;
|
|
170
|
+
|
|
171
|
+
// Hover effect using CSS variable
|
|
172
|
+
const hoverBg = getSubmitButtonHoverBg();
|
|
173
|
+
button.onmouseover = () => {
|
|
174
|
+
button.style.backgroundColor = hoverBg;
|
|
175
|
+
};
|
|
176
|
+
button.onmouseout = () => {
|
|
177
|
+
button.style.backgroundColor = ''; // Reset to CSS variable default
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// Custom styling (applied last to allow overrides)
|
|
181
|
+
if (buttonConfig.style) {
|
|
182
|
+
Object.assign(button.style, buttonConfig.style);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return button;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Render form state (success or error)
|
|
190
|
+
*
|
|
191
|
+
* @param state - State configuration ('success' or 'error')
|
|
192
|
+
* @param stateConfig - State content configuration
|
|
193
|
+
* @returns State HTML element
|
|
194
|
+
*/
|
|
195
|
+
export function renderFormState(
|
|
196
|
+
state: 'success' | 'error',
|
|
197
|
+
stateConfig: { title?: string; message: string; buttons?: ExperienceButton[] }
|
|
198
|
+
): HTMLElement {
|
|
199
|
+
const stateEl = document.createElement('div');
|
|
200
|
+
stateEl.className = `xp-form__state xp-form__state--${state}`;
|
|
201
|
+
|
|
202
|
+
// Base styles + state-specific styles
|
|
203
|
+
const baseStyles = getFormStateStyles();
|
|
204
|
+
const stateStyles = state === 'success' ? getSuccessStateStyles() : getErrorStateStyles();
|
|
205
|
+
stateEl.style.cssText = `${baseStyles}; ${stateStyles}`;
|
|
206
|
+
|
|
207
|
+
// Title (optional)
|
|
208
|
+
if (stateConfig.title) {
|
|
209
|
+
const title = document.createElement('h3');
|
|
210
|
+
title.className = 'xp-form__state-title';
|
|
211
|
+
title.style.cssText = getStateTitleStyles();
|
|
212
|
+
title.textContent = stateConfig.title;
|
|
213
|
+
stateEl.appendChild(title);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Message
|
|
217
|
+
const message = document.createElement('div');
|
|
218
|
+
message.className = 'xp-form__state-message';
|
|
219
|
+
message.style.cssText = getStateMessageStyles();
|
|
220
|
+
message.textContent = stateConfig.message;
|
|
221
|
+
stateEl.appendChild(message);
|
|
222
|
+
|
|
223
|
+
// Buttons (optional)
|
|
224
|
+
if (stateConfig.buttons && stateConfig.buttons.length > 0) {
|
|
225
|
+
const buttonContainer = document.createElement('div');
|
|
226
|
+
buttonContainer.className = 'xp-form__state-buttons';
|
|
227
|
+
buttonContainer.style.cssText = getStateButtonsStyles();
|
|
228
|
+
|
|
229
|
+
stateConfig.buttons.forEach((btnConfig) => {
|
|
230
|
+
const btn = document.createElement('button');
|
|
231
|
+
btn.type = 'button';
|
|
232
|
+
btn.className = 'xp-modal__button';
|
|
233
|
+
|
|
234
|
+
if (btnConfig.variant) {
|
|
235
|
+
btn.className += ` xp-modal__button--${btnConfig.variant}`;
|
|
236
|
+
}
|
|
237
|
+
if (btnConfig.className) {
|
|
238
|
+
btn.className += ` ${btnConfig.className}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
btn.textContent = btnConfig.text;
|
|
242
|
+
|
|
243
|
+
if (btnConfig.style) {
|
|
244
|
+
Object.assign(btn.style, btnConfig.style);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Store action/dismiss metadata for event handlers
|
|
248
|
+
if (btnConfig.action) {
|
|
249
|
+
btn.dataset.action = btnConfig.action;
|
|
250
|
+
}
|
|
251
|
+
if (btnConfig.dismiss) {
|
|
252
|
+
btn.dataset.dismiss = 'true';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
buttonContainer.appendChild(btn);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
stateEl.appendChild(buttonContainer);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return stateEl;
|
|
262
|
+
}
|