@hortonstudio/main 1.7.13 → 1.7.14
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/autoInit/form.js +46 -603
- package/index.js +0 -1
- package/package.json +1 -1
- package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +10 -0
- package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +29 -0
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +17 -0
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +16 -0
- package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +46 -0
- package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +39 -0
- package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +5 -0
- package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +7 -0
- package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +7 -0
- package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +40 -0
- package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +77 -0
- package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +6 -0
- package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +9 -0
- package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +8 -0
- package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +32 -0
- package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +56 -0
- package/utils/css-animations/buttons/text/color/text-footer-color.html +5 -0
- package/utils/css-animations/buttons/text/color/text-main-color.html +5 -0
- package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +56 -0
- package/utils/css-animations/buttons/text/scale/text-footer-scale.html +6 -0
- package/utils/css-animations/buttons/text/scale/text-main-scale.html +6 -0
- package/utils/css-animations/buttons/text/underline/text-footer-underline.html +45 -0
- package/utils/css-animations/buttons/text/underline/text-main-underline.html +58 -0
- package/utils/css-animations/cards/card-clickable.html +11 -0
- package/utils/css-animations/defaults.html +69 -0
package/autoInit/form.js
CHANGED
|
@@ -1,32 +1,4 @@
|
|
|
1
1
|
export function init() {
|
|
2
|
-
const config = {
|
|
3
|
-
selectors: {
|
|
4
|
-
form: 'form',
|
|
5
|
-
errorTemplate: '[data-hs-form="form-error"]',
|
|
6
|
-
requiredStep: '[data-hs-form="required-step"]',
|
|
7
|
-
nextButton: '[data-hs-form="next-button"]',
|
|
8
|
-
nextAnim: '[data-hs-form="next-anim"]',
|
|
9
|
-
finalAnim: '[data-hs-form="final-anim"]'
|
|
10
|
-
},
|
|
11
|
-
errorMessages: {
|
|
12
|
-
email: 'Please enter a valid email address',
|
|
13
|
-
tel: 'Please enter a valid phone number',
|
|
14
|
-
number: 'Please enter a valid number',
|
|
15
|
-
url: 'Please enter a valid URL',
|
|
16
|
-
text: 'This field is required',
|
|
17
|
-
textarea: 'This field is required',
|
|
18
|
-
select: 'Please select an option',
|
|
19
|
-
checkbox: 'Please check this box',
|
|
20
|
-
radio: 'Please select an option',
|
|
21
|
-
default: 'This field is required'
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const activeErrors = new Map();
|
|
26
|
-
let errorTemplate = null;
|
|
27
|
-
let originalErrorTemplate = null;
|
|
28
|
-
let isValidating = false;
|
|
29
|
-
|
|
30
2
|
// Simple Custom Select Component for Webflow
|
|
31
3
|
(function() {
|
|
32
4
|
'use strict';
|
|
@@ -43,7 +15,7 @@ export function init() {
|
|
|
43
15
|
});
|
|
44
16
|
|
|
45
17
|
const selectWrappers = document.querySelectorAll('[data-hs-form="select"]');
|
|
46
|
-
|
|
18
|
+
|
|
47
19
|
selectWrappers.forEach(wrapper => {
|
|
48
20
|
initSingleSelect(wrapper);
|
|
49
21
|
});
|
|
@@ -54,36 +26,36 @@ export function init() {
|
|
|
54
26
|
// Find all required elements
|
|
55
27
|
const realSelect = wrapper.querySelector('select');
|
|
56
28
|
if (!realSelect) return;
|
|
57
|
-
|
|
29
|
+
|
|
58
30
|
const selectName = realSelect.getAttribute('name') || 'custom-select';
|
|
59
31
|
const customList = wrapper.querySelector('[data-hs-form="select-list"]');
|
|
60
32
|
const button = wrapper.querySelector('button') || wrapper.querySelector('[role="button"]');
|
|
61
|
-
|
|
33
|
+
|
|
62
34
|
if (!customList || !button) return;
|
|
63
|
-
|
|
35
|
+
|
|
64
36
|
// Get and clone the option template
|
|
65
37
|
const optionTemplate = customList.firstElementChild;
|
|
66
38
|
if (!optionTemplate) return;
|
|
67
|
-
|
|
39
|
+
|
|
68
40
|
const templateClone = optionTemplate.cloneNode(true);
|
|
69
41
|
optionTemplate.remove();
|
|
70
|
-
|
|
42
|
+
|
|
71
43
|
// Build options from real select
|
|
72
44
|
const realOptions = realSelect.querySelectorAll('option');
|
|
73
45
|
realOptions.forEach((option, index) => {
|
|
74
46
|
const optionElement = templateClone.cloneNode(true);
|
|
75
47
|
const textSpan = optionElement.querySelector('span');
|
|
76
|
-
|
|
48
|
+
|
|
77
49
|
if (textSpan) {
|
|
78
50
|
textSpan.textContent = option.textContent;
|
|
79
51
|
}
|
|
80
|
-
|
|
52
|
+
|
|
81
53
|
// Add attributes
|
|
82
54
|
optionElement.setAttribute('data-value', option.value);
|
|
83
55
|
optionElement.setAttribute('role', 'option');
|
|
84
56
|
optionElement.setAttribute('id', `${selectName}-option-${index}`);
|
|
85
57
|
optionElement.setAttribute('tabindex', '-1');
|
|
86
|
-
|
|
58
|
+
|
|
87
59
|
// Set selected state if this option is selected
|
|
88
60
|
if (option.selected) {
|
|
89
61
|
optionElement.setAttribute('aria-selected', 'true');
|
|
@@ -95,22 +67,22 @@ export function init() {
|
|
|
95
67
|
} else {
|
|
96
68
|
optionElement.setAttribute('aria-selected', 'false');
|
|
97
69
|
}
|
|
98
|
-
|
|
70
|
+
|
|
99
71
|
customList.appendChild(optionElement);
|
|
100
72
|
});
|
|
101
|
-
|
|
73
|
+
|
|
102
74
|
// Add ARIA attributes
|
|
103
75
|
customList.setAttribute('role', 'listbox');
|
|
104
76
|
customList.setAttribute('id', `${selectName}-listbox`);
|
|
105
|
-
|
|
77
|
+
|
|
106
78
|
button.setAttribute('role', 'combobox');
|
|
107
79
|
button.setAttribute('aria-haspopup', 'listbox');
|
|
108
80
|
button.setAttribute('aria-controls', `${selectName}-listbox`);
|
|
109
81
|
button.setAttribute('aria-expanded', 'false');
|
|
110
82
|
button.setAttribute('id', `${selectName}-button`);
|
|
111
|
-
|
|
83
|
+
|
|
112
84
|
// Find and connect label if exists
|
|
113
|
-
const label = wrapper.querySelector('label') ||
|
|
85
|
+
const label = wrapper.querySelector('label') ||
|
|
114
86
|
document.querySelector(`label[for="${realSelect.id}"]`);
|
|
115
87
|
if (label) {
|
|
116
88
|
const labelId = label.id || `${selectName}-label`;
|
|
@@ -122,28 +94,28 @@ export function init() {
|
|
|
122
94
|
label.setAttribute('for', realSelect.id);
|
|
123
95
|
button.setAttribute('aria-labelledby', labelId);
|
|
124
96
|
}
|
|
125
|
-
|
|
97
|
+
|
|
126
98
|
// Track state
|
|
127
99
|
let currentIndex = -1;
|
|
128
100
|
let isOpen = false;
|
|
129
|
-
|
|
101
|
+
|
|
130
102
|
// Update expanded state
|
|
131
103
|
function updateExpandedState(expanded) {
|
|
132
104
|
isOpen = expanded;
|
|
133
105
|
button.setAttribute('aria-expanded', expanded.toString());
|
|
134
106
|
}
|
|
135
|
-
|
|
107
|
+
|
|
136
108
|
// Focus option by index
|
|
137
109
|
function focusOption(index) {
|
|
138
110
|
const options = customList.querySelectorAll('[role="option"]');
|
|
139
111
|
if (index < 0 || index >= options.length) return;
|
|
140
|
-
|
|
112
|
+
|
|
141
113
|
// Remove previous focus
|
|
142
114
|
options.forEach(opt => {
|
|
143
115
|
opt.classList.remove('focused');
|
|
144
116
|
opt.setAttribute('tabindex', '-1');
|
|
145
117
|
});
|
|
146
|
-
|
|
118
|
+
|
|
147
119
|
// Add new focus
|
|
148
120
|
currentIndex = index;
|
|
149
121
|
options[index].classList.add('focused');
|
|
@@ -151,32 +123,32 @@ export function init() {
|
|
|
151
123
|
options[index].focus();
|
|
152
124
|
button.setAttribute('aria-activedescendant', options[index].id);
|
|
153
125
|
}
|
|
154
|
-
|
|
126
|
+
|
|
155
127
|
// Select option
|
|
156
128
|
function selectOption(optionElement) {
|
|
157
129
|
const value = optionElement.getAttribute('data-value');
|
|
158
130
|
const text = optionElement.querySelector('span')?.textContent || optionElement.textContent;
|
|
159
|
-
|
|
131
|
+
|
|
160
132
|
// Update real select
|
|
161
133
|
realSelect.value = value;
|
|
162
134
|
realSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
163
|
-
|
|
135
|
+
|
|
164
136
|
// Update button text
|
|
165
137
|
const buttonText = button.querySelector('span') || button;
|
|
166
138
|
if (buttonText.tagName === 'SPAN') {
|
|
167
139
|
buttonText.textContent = text;
|
|
168
140
|
}
|
|
169
|
-
|
|
141
|
+
|
|
170
142
|
// Update aria-selected
|
|
171
143
|
customList.querySelectorAll('[role="option"]').forEach(opt => {
|
|
172
144
|
opt.setAttribute('aria-selected', 'false');
|
|
173
145
|
});
|
|
174
146
|
optionElement.setAttribute('aria-selected', 'true');
|
|
175
|
-
|
|
147
|
+
|
|
176
148
|
// Click the button to close
|
|
177
149
|
button.click();
|
|
178
150
|
}
|
|
179
|
-
|
|
151
|
+
|
|
180
152
|
// Button keyboard events
|
|
181
153
|
button.addEventListener('keydown', (e) => {
|
|
182
154
|
switch(e.key) {
|
|
@@ -185,7 +157,7 @@ export function init() {
|
|
|
185
157
|
e.preventDefault();
|
|
186
158
|
button.click();
|
|
187
159
|
break;
|
|
188
|
-
|
|
160
|
+
|
|
189
161
|
case 'ArrowDown':
|
|
190
162
|
e.preventDefault();
|
|
191
163
|
if (!isOpen) {
|
|
@@ -194,7 +166,7 @@ export function init() {
|
|
|
194
166
|
focusOption(0);
|
|
195
167
|
}
|
|
196
168
|
break;
|
|
197
|
-
|
|
169
|
+
|
|
198
170
|
case 'ArrowUp':
|
|
199
171
|
e.preventDefault();
|
|
200
172
|
if (isOpen) {
|
|
@@ -202,7 +174,7 @@ export function init() {
|
|
|
202
174
|
focusOption(options.length - 1);
|
|
203
175
|
}
|
|
204
176
|
break;
|
|
205
|
-
|
|
177
|
+
|
|
206
178
|
case 'Escape':
|
|
207
179
|
if (isOpen) {
|
|
208
180
|
e.preventDefault();
|
|
@@ -211,15 +183,15 @@ export function init() {
|
|
|
211
183
|
break;
|
|
212
184
|
}
|
|
213
185
|
});
|
|
214
|
-
|
|
186
|
+
|
|
215
187
|
// Option keyboard events (delegated)
|
|
216
188
|
customList.addEventListener('keydown', (e) => {
|
|
217
189
|
const option = e.target.closest('[role="option"]');
|
|
218
190
|
if (!option) return;
|
|
219
|
-
|
|
191
|
+
|
|
220
192
|
const options = Array.from(customList.querySelectorAll('[role="option"]'));
|
|
221
193
|
const currentIdx = options.indexOf(option);
|
|
222
|
-
|
|
194
|
+
|
|
223
195
|
switch(e.key) {
|
|
224
196
|
case 'ArrowDown':
|
|
225
197
|
e.preventDefault();
|
|
@@ -227,7 +199,7 @@ export function init() {
|
|
|
227
199
|
focusOption(currentIdx + 1);
|
|
228
200
|
}
|
|
229
201
|
break;
|
|
230
|
-
|
|
202
|
+
|
|
231
203
|
case 'ArrowUp':
|
|
232
204
|
e.preventDefault();
|
|
233
205
|
if (currentIdx === 0) {
|
|
@@ -237,13 +209,13 @@ export function init() {
|
|
|
237
209
|
focusOption(currentIdx - 1);
|
|
238
210
|
}
|
|
239
211
|
break;
|
|
240
|
-
|
|
212
|
+
|
|
241
213
|
case 'Enter':
|
|
242
214
|
case ' ':
|
|
243
215
|
e.preventDefault();
|
|
244
216
|
selectOption(option);
|
|
245
217
|
break;
|
|
246
|
-
|
|
218
|
+
|
|
247
219
|
case 'Escape':
|
|
248
220
|
e.preventDefault();
|
|
249
221
|
button.click();
|
|
@@ -251,7 +223,7 @@ export function init() {
|
|
|
251
223
|
break;
|
|
252
224
|
}
|
|
253
225
|
});
|
|
254
|
-
|
|
226
|
+
|
|
255
227
|
// Option click events
|
|
256
228
|
customList.addEventListener('click', (e) => {
|
|
257
229
|
const option = e.target.closest('[role="option"]');
|
|
@@ -259,18 +231,18 @@ export function init() {
|
|
|
259
231
|
selectOption(option);
|
|
260
232
|
}
|
|
261
233
|
});
|
|
262
|
-
|
|
234
|
+
|
|
263
235
|
// Track open/close state
|
|
264
236
|
const observer = new MutationObserver((mutations) => {
|
|
265
237
|
mutations.forEach((mutation) => {
|
|
266
238
|
if (mutation.type === 'attributes') {
|
|
267
239
|
// Check if dropdown is visible
|
|
268
|
-
const isVisible = !customList.hidden &&
|
|
240
|
+
const isVisible = !customList.hidden &&
|
|
269
241
|
customList.style.display !== 'none' &&
|
|
270
242
|
!customList.classList.contains('hidden');
|
|
271
|
-
|
|
243
|
+
|
|
272
244
|
updateExpandedState(isVisible);
|
|
273
|
-
|
|
245
|
+
|
|
274
246
|
if (!isVisible) {
|
|
275
247
|
currentIndex = -1;
|
|
276
248
|
button.removeAttribute('aria-activedescendant');
|
|
@@ -282,13 +254,13 @@ export function init() {
|
|
|
282
254
|
}
|
|
283
255
|
});
|
|
284
256
|
});
|
|
285
|
-
|
|
257
|
+
|
|
286
258
|
// Observe the custom list for visibility changes
|
|
287
259
|
observer.observe(customList, {
|
|
288
260
|
attributes: true,
|
|
289
261
|
attributeFilter: ['hidden', 'style', 'class']
|
|
290
262
|
});
|
|
291
|
-
|
|
263
|
+
|
|
292
264
|
// Sync with real select changes
|
|
293
265
|
realSelect.addEventListener('change', () => {
|
|
294
266
|
const selectedOption = realSelect.options[realSelect.selectedIndex];
|
|
@@ -301,7 +273,7 @@ export function init() {
|
|
|
301
273
|
if (buttonText.tagName === 'SPAN') {
|
|
302
274
|
buttonText.textContent = text;
|
|
303
275
|
}
|
|
304
|
-
|
|
276
|
+
|
|
305
277
|
// Update aria-selected
|
|
306
278
|
customList.querySelectorAll('[role="option"]').forEach(opt => {
|
|
307
279
|
opt.setAttribute('aria-selected', 'false');
|
|
@@ -311,546 +283,17 @@ export function init() {
|
|
|
311
283
|
}
|
|
312
284
|
});
|
|
313
285
|
}
|
|
314
|
-
|
|
286
|
+
|
|
315
287
|
// Initialize on DOM ready
|
|
316
288
|
if (document.readyState === 'loading') {
|
|
317
289
|
document.addEventListener('DOMContentLoaded', initCustomSelects);
|
|
318
290
|
} else {
|
|
319
291
|
initCustomSelects();
|
|
320
292
|
}
|
|
321
|
-
|
|
293
|
+
|
|
322
294
|
// Reinitialize for dynamic content
|
|
323
295
|
window.initCustomSelects = initCustomSelects;
|
|
324
296
|
})();
|
|
325
297
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
forms.forEach(form => {
|
|
329
|
-
form.setAttribute('novalidate', '');
|
|
330
|
-
|
|
331
|
-
// Completely disable all browser validation methods
|
|
332
|
-
form.checkValidity = () => true;
|
|
333
|
-
form.reportValidity = () => true;
|
|
334
|
-
|
|
335
|
-
// Override all input validation as well
|
|
336
|
-
const inputs = form.querySelectorAll('input, textarea, select');
|
|
337
|
-
inputs.forEach(input => {
|
|
338
|
-
// Remove required attribute and store it
|
|
339
|
-
if (input.hasAttribute('required')) {
|
|
340
|
-
input.removeAttribute('required');
|
|
341
|
-
input.setAttribute('data-was-required', 'true');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Override validation methods
|
|
345
|
-
input.checkValidity = () => true;
|
|
346
|
-
input.reportValidity = () => true;
|
|
347
|
-
input.setCustomValidity = () => {};
|
|
348
|
-
});
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
errorTemplate = document.querySelector(config.selectors.errorTemplate);
|
|
352
|
-
if (!errorTemplate) {
|
|
353
|
-
console.warn('Form validation: Error template not found');
|
|
354
|
-
} else {
|
|
355
|
-
// Clone the template, clean it up, and remove original from DOM
|
|
356
|
-
originalErrorTemplate = errorTemplate.cloneNode(true);
|
|
357
|
-
|
|
358
|
-
// Clean up the cloned template
|
|
359
|
-
originalErrorTemplate.removeAttribute('data-hs-form');
|
|
360
|
-
originalErrorTemplate.removeAttribute('style');
|
|
361
|
-
originalErrorTemplate.textContent = ''; // Clear any placeholder text
|
|
362
|
-
|
|
363
|
-
// Remove the original template from the DOM
|
|
364
|
-
errorTemplate.remove();
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
const getErrorMessage = (input) => {
|
|
369
|
-
const inputType = input.type || input.tagName.toLowerCase();
|
|
370
|
-
const value = input.value.trim();
|
|
371
|
-
|
|
372
|
-
// If field is empty and was required, return required message
|
|
373
|
-
if (input.hasAttribute('data-was-required') && !value) {
|
|
374
|
-
return config.errorMessages[inputType] || config.errorMessages.default;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// For non-empty invalid fields, return type-specific messages
|
|
378
|
-
if (value) {
|
|
379
|
-
switch (inputType) {
|
|
380
|
-
case 'email':
|
|
381
|
-
return 'Please enter a valid email address';
|
|
382
|
-
case 'url':
|
|
383
|
-
return 'Please enter a valid URL';
|
|
384
|
-
case 'number':
|
|
385
|
-
if (input.hasAttribute('min') && parseFloat(value) < parseFloat(input.min)) {
|
|
386
|
-
return `Number must be at least ${input.min}`;
|
|
387
|
-
}
|
|
388
|
-
if (input.hasAttribute('max') && parseFloat(value) > parseFloat(input.max)) {
|
|
389
|
-
return `Number must be no more than ${input.max}`;
|
|
390
|
-
}
|
|
391
|
-
return 'Please enter a valid number';
|
|
392
|
-
case 'tel':
|
|
393
|
-
return 'Please enter a valid phone number';
|
|
394
|
-
default:
|
|
395
|
-
if (input.hasAttribute('minlength') && value.length < parseInt(input.minlength)) {
|
|
396
|
-
return `Must be at least ${input.minlength} characters`;
|
|
397
|
-
}
|
|
398
|
-
if (input.hasAttribute('maxlength') && value.length > parseInt(input.maxlength)) {
|
|
399
|
-
return `Must be no more than ${input.maxlength} characters`;
|
|
400
|
-
}
|
|
401
|
-
if (input.hasAttribute('pattern')) {
|
|
402
|
-
return 'Please match the required format';
|
|
403
|
-
}
|
|
404
|
-
return config.errorMessages[inputType] || config.errorMessages.default;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return config.errorMessages[inputType] || config.errorMessages.default;
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
const createErrorElement = (message) => {
|
|
412
|
-
if (!originalErrorTemplate) return null;
|
|
413
|
-
|
|
414
|
-
const errorElement = originalErrorTemplate.cloneNode(true);
|
|
415
|
-
errorElement.textContent = message;
|
|
416
|
-
|
|
417
|
-
return errorElement;
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
const positionError = (errorElement, input) => {
|
|
421
|
-
const inputRect = input.getBoundingClientRect();
|
|
422
|
-
const errorRect = errorElement.getBoundingClientRect();
|
|
423
|
-
|
|
424
|
-
let top = inputRect.bottom + window.scrollY + 8;
|
|
425
|
-
let left = inputRect.left + window.scrollX + (inputRect.width / 2) - (errorRect.width / 2);
|
|
426
|
-
|
|
427
|
-
// Check viewport boundaries
|
|
428
|
-
const viewportWidth = window.innerWidth;
|
|
429
|
-
const viewportHeight = window.innerHeight;
|
|
430
|
-
|
|
431
|
-
// Adjust horizontal position
|
|
432
|
-
if (left < 10) {
|
|
433
|
-
left = 10;
|
|
434
|
-
} else if (left + errorRect.width > viewportWidth - 10) {
|
|
435
|
-
left = viewportWidth - errorRect.width - 10;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Adjust vertical position if error would be below viewport
|
|
439
|
-
if (inputRect.bottom + errorRect.height + 16 > viewportHeight) {
|
|
440
|
-
top = inputRect.top + window.scrollY - errorRect.height - 8;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const finalTop = top - window.scrollY;
|
|
444
|
-
const finalLeft = left - window.scrollX;
|
|
445
|
-
|
|
446
|
-
// Only set the minimal positioning styles needed
|
|
447
|
-
errorElement.style.position = 'fixed';
|
|
448
|
-
errorElement.style.top = `${finalTop}px`;
|
|
449
|
-
errorElement.style.left = `${finalLeft}px`;
|
|
450
|
-
errorElement.style.zIndex = '9999';
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
const showError = (input, message) => {
|
|
454
|
-
removeError(input);
|
|
455
|
-
|
|
456
|
-
const errorElement = createErrorElement(message);
|
|
457
|
-
if (!errorElement) return;
|
|
458
|
-
document.body.appendChild(errorElement);
|
|
459
|
-
positionError(errorElement, input);
|
|
460
|
-
|
|
461
|
-
activeErrors.set(input, errorElement);
|
|
462
|
-
|
|
463
|
-
input.setAttribute('aria-invalid', 'true');
|
|
464
|
-
input.setAttribute('aria-describedby', `error-${Date.now()}`);
|
|
465
|
-
errorElement.id = input.getAttribute('aria-describedby');
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
const removeError = (input) => {
|
|
469
|
-
const errorElement = activeErrors.get(input);
|
|
470
|
-
if (errorElement) {
|
|
471
|
-
errorElement.remove();
|
|
472
|
-
activeErrors.delete(input);
|
|
473
|
-
input.removeAttribute('aria-invalid');
|
|
474
|
-
input.removeAttribute('aria-describedby');
|
|
475
|
-
}
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const customValidateField = (field) => {
|
|
479
|
-
const value = field.value.trim();
|
|
480
|
-
const type = field.type || field.tagName.toLowerCase();
|
|
481
|
-
|
|
482
|
-
// Check if field was required (now stored in data-was-required)
|
|
483
|
-
if (field.hasAttribute('data-was-required')) {
|
|
484
|
-
// Special handling for checkboxes
|
|
485
|
-
if (type === 'checkbox') {
|
|
486
|
-
return field.checked;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Special handling for radio buttons - check if ANY radio in the group is checked
|
|
490
|
-
if (type === 'radio') {
|
|
491
|
-
const radioName = field.getAttribute('name');
|
|
492
|
-
if (radioName) {
|
|
493
|
-
// Find all radios with the same name in the same form
|
|
494
|
-
const form = field.closest('form');
|
|
495
|
-
const radioGroup = form ?
|
|
496
|
-
form.querySelectorAll(`input[type="radio"][name="${radioName}"]`) :
|
|
497
|
-
document.querySelectorAll(`input[type="radio"][name="${radioName}"]`);
|
|
498
|
-
|
|
499
|
-
// Check if any radio in the group is checked
|
|
500
|
-
return Array.from(radioGroup).some(radio => radio.checked);
|
|
501
|
-
}
|
|
502
|
-
// Fallback to individual check if no name attribute
|
|
503
|
-
return field.checked;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// For other field types, check if empty
|
|
507
|
-
if (!value) {
|
|
508
|
-
return false;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Type-specific validation for non-empty values
|
|
512
|
-
switch (type) {
|
|
513
|
-
case 'email':
|
|
514
|
-
// Basic email validation
|
|
515
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
516
|
-
return emailRegex.test(value);
|
|
517
|
-
|
|
518
|
-
case 'url':
|
|
519
|
-
// Basic URL validation
|
|
520
|
-
try {
|
|
521
|
-
new URL(value);
|
|
522
|
-
return true;
|
|
523
|
-
} catch {
|
|
524
|
-
return false;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
case 'number':
|
|
528
|
-
// Number validation
|
|
529
|
-
const num = parseFloat(value);
|
|
530
|
-
if (isNaN(num)) return false;
|
|
531
|
-
|
|
532
|
-
// Check min/max if present
|
|
533
|
-
if (field.hasAttribute('min') && num < parseFloat(field.min)) return false;
|
|
534
|
-
if (field.hasAttribute('max') && num > parseFloat(field.max)) return false;
|
|
535
|
-
return true;
|
|
536
|
-
|
|
537
|
-
case 'tel':
|
|
538
|
-
// Basic phone validation (at least 10 digits)
|
|
539
|
-
const phoneRegex = /\d{10,}/;
|
|
540
|
-
return phoneRegex.test(value.replace(/\D/g, ''));
|
|
541
|
-
|
|
542
|
-
default:
|
|
543
|
-
// For text, textarea, etc. - check minlength/maxlength
|
|
544
|
-
if (field.hasAttribute('minlength') && value.length < parseInt(field.minlength)) return false;
|
|
545
|
-
if (field.hasAttribute('maxlength') && value.length > parseInt(field.maxlength)) return false;
|
|
546
|
-
|
|
547
|
-
// Check pattern if present
|
|
548
|
-
if (field.hasAttribute('pattern')) {
|
|
549
|
-
const pattern = new RegExp(field.pattern);
|
|
550
|
-
return pattern.test(value);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
return true;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
return true; // Field is valid if not required or passes validation
|
|
558
|
-
};
|
|
559
|
-
|
|
560
|
-
const validateContainer = (container) => {
|
|
561
|
-
const requiredFields = container.querySelectorAll('input[data-was-required], textarea[data-was-required], select[data-was-required]');
|
|
562
|
-
let isValid = true;
|
|
563
|
-
let firstInvalidField = null;
|
|
564
|
-
const validatedRadioGroups = new Set(); // Track validated radio groups
|
|
565
|
-
|
|
566
|
-
requiredFields.forEach((field) => {
|
|
567
|
-
const type = field.type || field.tagName.toLowerCase();
|
|
568
|
-
|
|
569
|
-
// For radio buttons, only validate once per group
|
|
570
|
-
if (type === 'radio') {
|
|
571
|
-
const radioName = field.getAttribute('name');
|
|
572
|
-
if (radioName && validatedRadioGroups.has(radioName)) {
|
|
573
|
-
return; // Skip - already validated this radio group
|
|
574
|
-
}
|
|
575
|
-
if (radioName) {
|
|
576
|
-
validatedRadioGroups.add(radioName);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
const fieldValid = customValidateField(field);
|
|
581
|
-
|
|
582
|
-
if (!fieldValid) {
|
|
583
|
-
isValid = false;
|
|
584
|
-
|
|
585
|
-
if (!firstInvalidField) {
|
|
586
|
-
firstInvalidField = field;
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
if (!isValid && firstInvalidField) {
|
|
592
|
-
activeErrors.forEach((_, input) => {
|
|
593
|
-
removeError(input);
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
firstInvalidField.focus();
|
|
597
|
-
|
|
598
|
-
const message = getErrorMessage(firstInvalidField);
|
|
599
|
-
showError(firstInvalidField, message);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
return isValid;
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
// Helper function to parse comma-separated config values
|
|
606
|
-
const parseFormConfig = (configString) => {
|
|
607
|
-
if (!configString) return [];
|
|
608
|
-
return configString.split(',').map(config => config.trim());
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
const handleFormSubmit = (event) => {
|
|
612
|
-
const form = event.target;
|
|
613
|
-
|
|
614
|
-
// Set validation flag to prevent click outside interference
|
|
615
|
-
isValidating = true;
|
|
616
|
-
|
|
617
|
-
const isValid = validateContainer(form);
|
|
618
|
-
|
|
619
|
-
// Reset validation flag after a brief delay
|
|
620
|
-
setTimeout(() => {
|
|
621
|
-
isValidating = false;
|
|
622
|
-
}, 200);
|
|
623
|
-
|
|
624
|
-
if (!isValid) {
|
|
625
|
-
// Only prevent submission if form is invalid
|
|
626
|
-
event.preventDefault();
|
|
627
|
-
event.stopPropagation();
|
|
628
|
-
event.stopImmediatePropagation();
|
|
629
|
-
return false;
|
|
630
|
-
} else {
|
|
631
|
-
// Clear all errors before submission
|
|
632
|
-
activeErrors.forEach((_, input) => {
|
|
633
|
-
removeError(input);
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
// Handle text replacement if this form has replace fields
|
|
637
|
-
const replaceFieldElements = form.querySelectorAll('input[data-hs-form^="replace-field-"], textarea[data-hs-form^="replace-field-"], select[data-hs-form^="replace-field-"]');
|
|
638
|
-
if (replaceFieldElements.length > 0) {
|
|
639
|
-
replaceFieldElements.forEach(field => {
|
|
640
|
-
const dataHsForm = field.getAttribute('data-hs-form');
|
|
641
|
-
const suffix = dataHsForm.replace('replace-field-', '');
|
|
642
|
-
const value = field.value;
|
|
643
|
-
|
|
644
|
-
// Find all matching text elements
|
|
645
|
-
const textElements = document.querySelectorAll(`[data-hs-form="replace-text-${suffix}"]`);
|
|
646
|
-
|
|
647
|
-
textElements.forEach(element => {
|
|
648
|
-
element.textContent = value;
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// Handle form configuration
|
|
654
|
-
const formWrapper = form.closest('[data-hs-form="wrapper"]');
|
|
655
|
-
let shouldPreventSubmit = false;
|
|
656
|
-
|
|
657
|
-
if (formWrapper && formWrapper.hasAttribute('data-hs-config')) {
|
|
658
|
-
const configString = formWrapper.getAttribute('data-hs-config');
|
|
659
|
-
const configs = parseFormConfig(configString);
|
|
660
|
-
|
|
661
|
-
// Check for prevent-submit config
|
|
662
|
-
if (configs.includes('prevent-submit')) {
|
|
663
|
-
shouldPreventSubmit = true;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Check for click-trigger configs
|
|
667
|
-
configs.forEach(config => {
|
|
668
|
-
if (config.startsWith('click-trigger-')) {
|
|
669
|
-
const trigger = document.querySelector(`[data-hs-form="trigger"][data-hs-config*="${config}"]`);
|
|
670
|
-
if (trigger) {
|
|
671
|
-
setTimeout(() => {
|
|
672
|
-
trigger.click();
|
|
673
|
-
}, 100);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Trigger final animation if it exists
|
|
680
|
-
const finalAnimElement = form.querySelector(config.selectors.finalAnim);
|
|
681
|
-
if (finalAnimElement) {
|
|
682
|
-
finalAnimElement.click();
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// Prevent submission if configured to do so
|
|
686
|
-
if (shouldPreventSubmit) {
|
|
687
|
-
event.preventDefault();
|
|
688
|
-
event.stopPropagation();
|
|
689
|
-
event.stopImmediatePropagation();
|
|
690
|
-
return false;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Don't prevent default - let the form submit naturally with its action/method
|
|
694
|
-
}
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
const handleNextButtonClick = (event) => {
|
|
698
|
-
event.preventDefault();
|
|
699
|
-
|
|
700
|
-
const button = event.target;
|
|
701
|
-
const requiredStepContainer = button.closest(config.selectors.requiredStep);
|
|
702
|
-
|
|
703
|
-
if (!requiredStepContainer) {
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// Set validation flag to prevent click outside interference
|
|
708
|
-
isValidating = true;
|
|
709
|
-
|
|
710
|
-
const isValid = validateContainer(requiredStepContainer);
|
|
711
|
-
|
|
712
|
-
// Reset validation flag after a brief delay
|
|
713
|
-
setTimeout(() => {
|
|
714
|
-
isValidating = false;
|
|
715
|
-
}, 200);
|
|
716
|
-
|
|
717
|
-
if (isValid) {
|
|
718
|
-
const nextAnimElement = requiredStepContainer.querySelector(config.selectors.nextAnim);
|
|
719
|
-
if (nextAnimElement) {
|
|
720
|
-
nextAnimElement.click();
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
const handleInputChange = (event) => {
|
|
726
|
-
const input = event.target;
|
|
727
|
-
if (activeErrors.has(input)) {
|
|
728
|
-
removeError(input);
|
|
729
|
-
}
|
|
730
|
-
};
|
|
731
|
-
|
|
732
|
-
const handleClickOutside = (event) => {
|
|
733
|
-
const clickedElement = event.target;
|
|
734
|
-
|
|
735
|
-
// Don't process click outside during validation
|
|
736
|
-
if (isValidating) {
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Don't remove errors if clicking on next button
|
|
741
|
-
if (clickedElement.closest(config.selectors.nextButton)) {
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Don't remove errors immediately after they're created
|
|
746
|
-
setTimeout(() => {
|
|
747
|
-
// Double check validation flag hasn't been set during timeout
|
|
748
|
-
if (isValidating) {
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
activeErrors.forEach((errorElement, input) => {
|
|
753
|
-
if (input !== clickedElement && !input.contains(clickedElement) &&
|
|
754
|
-
errorElement !== clickedElement && !errorElement.contains(clickedElement)) {
|
|
755
|
-
removeError(input);
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
}, 100); // Small delay to prevent immediate removal
|
|
759
|
-
};
|
|
760
|
-
|
|
761
|
-
const handleScroll = () => {
|
|
762
|
-
activeErrors.forEach((errorElement, input) => {
|
|
763
|
-
const inputRect = input.getBoundingClientRect();
|
|
764
|
-
const isVisible = inputRect.top >= 0 && inputRect.bottom <= window.innerHeight;
|
|
765
|
-
|
|
766
|
-
if (isVisible) {
|
|
767
|
-
positionError(errorElement, input);
|
|
768
|
-
} else {
|
|
769
|
-
errorElement.style.display = 'none';
|
|
770
|
-
}
|
|
771
|
-
});
|
|
772
|
-
};
|
|
773
|
-
|
|
774
|
-
const handleResize = () => {
|
|
775
|
-
activeErrors.forEach((errorElement, input) => {
|
|
776
|
-
positionError(errorElement, input);
|
|
777
|
-
});
|
|
778
|
-
};
|
|
779
|
-
|
|
780
|
-
const setupEventListeners = () => {
|
|
781
|
-
// Global invalid event prevention - this catches ALL invalid events
|
|
782
|
-
document.addEventListener('invalid', (e) => {
|
|
783
|
-
e.preventDefault();
|
|
784
|
-
e.stopPropagation();
|
|
785
|
-
e.stopImmediatePropagation();
|
|
786
|
-
return false;
|
|
787
|
-
}, true);
|
|
788
|
-
|
|
789
|
-
// Form submit listeners - use capture phase to ensure we catch it first
|
|
790
|
-
document.addEventListener('submit', handleFormSubmit, true);
|
|
791
|
-
|
|
792
|
-
// Next button listeners
|
|
793
|
-
document.addEventListener('click', (event) => {
|
|
794
|
-
if (event.target.closest(config.selectors.nextButton)) {
|
|
795
|
-
handleNextButtonClick(event);
|
|
796
|
-
}
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
// Input change listeners
|
|
800
|
-
document.addEventListener('input', handleInputChange);
|
|
801
|
-
|
|
802
|
-
// Click outside listeners
|
|
803
|
-
document.addEventListener('click', handleClickOutside);
|
|
804
|
-
|
|
805
|
-
// Scroll and resize listeners
|
|
806
|
-
window.addEventListener('scroll', handleScroll);
|
|
807
|
-
window.addEventListener('resize', handleResize);
|
|
808
|
-
};
|
|
809
|
-
|
|
810
|
-
const destroy = () => {
|
|
811
|
-
// Clean up all active errors
|
|
812
|
-
activeErrors.forEach((_, input) => {
|
|
813
|
-
removeError(input);
|
|
814
|
-
});
|
|
815
|
-
|
|
816
|
-
// Remove event listeners
|
|
817
|
-
document.removeEventListener('invalid', (e) => {
|
|
818
|
-
e.preventDefault();
|
|
819
|
-
e.stopPropagation();
|
|
820
|
-
e.stopImmediatePropagation();
|
|
821
|
-
return false;
|
|
822
|
-
}, true);
|
|
823
|
-
document.removeEventListener('submit', handleFormSubmit, true);
|
|
824
|
-
document.removeEventListener('input', handleInputChange);
|
|
825
|
-
document.removeEventListener('click', handleClickOutside);
|
|
826
|
-
window.removeEventListener('scroll', handleScroll);
|
|
827
|
-
window.removeEventListener('resize', handleResize);
|
|
828
|
-
};
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
// Initialize the form validation system
|
|
832
|
-
const initializeFormValidation = () => {
|
|
833
|
-
try {
|
|
834
|
-
initializeForms();
|
|
835
|
-
setupEventListeners();
|
|
836
|
-
|
|
837
|
-
return {
|
|
838
|
-
result: "form initialized",
|
|
839
|
-
destroy
|
|
840
|
-
};
|
|
841
|
-
} catch (error) {
|
|
842
|
-
console.error('Form validation initialization failed:', error);
|
|
843
|
-
return {
|
|
844
|
-
result: "form initialization failed",
|
|
845
|
-
destroy
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
};
|
|
849
|
-
|
|
850
|
-
// Handle DOM ready state
|
|
851
|
-
if (document.readyState === "loading") {
|
|
852
|
-
document.addEventListener("DOMContentLoaded", initializeFormValidation);
|
|
853
|
-
} else {
|
|
854
|
-
return initializeFormValidation();
|
|
855
|
-
}
|
|
856
|
-
}
|
|
298
|
+
return { result: "form initialized" };
|
|
299
|
+
}
|