ultimate-jekyll-manager 0.0.119 → 0.0.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CLAUDE.md +102 -2
  2. package/README.md +171 -2
  3. package/TODO.md +10 -2
  4. package/_backup/form-manager.backup.js +1020 -0
  5. package/dist/assets/js/libs/auth/pages.js +64 -136
  6. package/dist/assets/js/libs/form-manager.js +643 -775
  7. package/dist/assets/js/pages/account/sections/api-keys.js +37 -52
  8. package/dist/assets/js/pages/account/sections/connections.js +37 -46
  9. package/dist/assets/js/pages/account/sections/delete.js +46 -66
  10. package/dist/assets/js/pages/account/sections/profile.js +37 -56
  11. package/dist/assets/js/pages/account/sections/security.js +100 -126
  12. package/dist/assets/js/pages/admin/notifications/new/index.js +72 -157
  13. package/dist/assets/js/pages/blog/index.js +29 -51
  14. package/dist/assets/js/pages/contact/index.js +110 -144
  15. package/dist/assets/js/pages/download/index.js +38 -86
  16. package/dist/assets/js/pages/oauth2/index.js +17 -17
  17. package/dist/assets/js/pages/payment/checkout/index.js +23 -36
  18. package/dist/assets/js/pages/test/libraries/form-manager/index.js +194 -0
  19. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signin.html +2 -2
  20. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html +2 -2
  21. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/contact.html +10 -37
  22. package/dist/defaults/dist/pages/test/libraries/form-manager.html +181 -0
  23. package/dist/gulp/tasks/serve.js +18 -0
  24. package/dist/lib/logger.js +1 -1
  25. package/firebase-debug.log +392 -0
  26. package/package.json +6 -6
  27. package/.playwright-mcp/page-2025-10-22T19-11-27-666Z.png +0 -0
  28. package/.playwright-mcp/page-2025-10-22T19-11-57-357Z.png +0 -0
@@ -0,0 +1,1020 @@
1
+ /**
2
+ * Form Manager Library
3
+ * A comprehensive form management system that handles state, validation, submission, and UI updates
4
+ */
5
+
6
+ export class FormManager extends EventTarget {
7
+ constructor(selector, options = {}) {
8
+ super();
9
+
10
+ // Store whether initialState was explicitly provided
11
+ this.hasCustomInitialState = options.hasOwnProperty('initialState');
12
+
13
+ // Configuration with defaults
14
+ this.config = {
15
+ autoDisable: true, // Auto disable/enable form controls
16
+ showSpinner: true, // Show spinner on submit buttons
17
+ validateOnSubmit: true, // Validate before submission
18
+ allowMultipleSubmissions: true, // Allow multiple submissions
19
+ resetOnSuccess: false, // Reset form after successful submission
20
+ spinnerHTML: '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>', // Spinner HTML
21
+ submitButtonLoadingText: 'Processing...',
22
+ submitButtonSuccessText: null, // Text to show on button after successful submission (when allowMultipleSubmissions: false)
23
+ fieldErrorClass: 'is-invalid',
24
+ fieldSuccessClass: 'is-valid',
25
+ initialState: 'loading', // Initial state: loading, ready, submitting, submitted
26
+ toastPosition: 'top-center', // Toast position: top-center, top-end, bottom-center, bottom-end, middle-center
27
+ toastDuration: 5000, // Toast duration in milliseconds
28
+ ...options
29
+ };
30
+
31
+ // Get form element
32
+ this.form = typeof selector === 'string' ? document.querySelector(selector) : selector;
33
+ if (!this.form) {
34
+ throw new Error(`Form not found: ${selector}`);
35
+ }
36
+
37
+ // State management
38
+ this.state = {
39
+ status: 'loading', // loading, ready, submitting, submitted
40
+ isValid: true,
41
+ errors: {},
42
+ data: {},
43
+ isDirty: false
44
+ };
45
+
46
+ // Store original button states
47
+ this.originalButtonStates = new Map();
48
+
49
+ // Initialize
50
+ this.init();
51
+ }
52
+
53
+ /**
54
+ * Initialize the form manager
55
+ */
56
+ init() {
57
+ // Store original button states BEFORE setting loading state
58
+ this.form.querySelectorAll('button').forEach(button => {
59
+ const state = {
60
+ innerHTML: button.innerHTML,
61
+ disabled: button.disabled
62
+ };
63
+ this.originalButtonStates.set(button, state);
64
+
65
+ /* @dev-only:start */
66
+ {
67
+ console.log(`[FormManager] Storing button "${button.textContent.trim()}": disabled=${state.disabled}`);
68
+ }
69
+ /* @dev-only:end */
70
+ });
71
+
72
+ // Track which submit button was clicked
73
+ this.clickedSubmitButton = null;
74
+
75
+ // Store if this is the first initialization
76
+ this.isInitializing = true;
77
+
78
+ // Set initial state
79
+ this.setFormState(this.config.initialState);
80
+
81
+ // Attach event listeners
82
+ this.attachEventListeners();
83
+
84
+ // Handle page show event (when navigating back from OAuth redirect or other navigation)
85
+ window.addEventListener('pageshow', (event) => {
86
+ // Check if page was restored from cache (persisted)
87
+
88
+ // Log
89
+ console.log('[FormManager] pageshow event', event);
90
+
91
+ // Quit if not persisted
92
+ if (!event.persisted) {
93
+ return;
94
+ }
95
+
96
+ // Log
97
+ console.log('[FormManager] Page restored from cache, resetting form to ready state');
98
+
99
+ // Reset form to ready state if it was in submitting state
100
+ if (this.state.status === 'submitting') {
101
+ this.setFormState('ready');
102
+ }
103
+ });
104
+
105
+ // Only auto-transition to ready if initialState wasn't explicitly set
106
+ // (meaning it's using the default 'loading' value)
107
+ if (!this.hasCustomInitialState) {
108
+ // Set ready state when DOM is ready
109
+ if (document.readyState === 'loading') {
110
+ document.addEventListener('DOMContentLoaded', () => {
111
+ this.isInitializing = false;
112
+ this.setFormState('ready');
113
+ });
114
+ } else {
115
+ // Use setTimeout to ensure any parent initialization completes
116
+ setTimeout(() => {
117
+ this.isInitializing = false;
118
+ this.setFormState('ready');
119
+ }, 0);
120
+ }
121
+ } else {
122
+ // If custom initial state was provided, we're done initializing
123
+ this.isInitializing = false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Attach all event listeners
129
+ */
130
+ attachEventListeners() {
131
+ // Form submit
132
+ this.form.addEventListener('submit', (e) => this.handleSubmit(e));
133
+
134
+ // Unified input changes - capture all user interactions
135
+ const inputEvents = ['keyup', 'change', 'paste', 'cut'];
136
+ const changeHandler = (e) => this.handleChange(e);
137
+
138
+ // Attach to all form inputs
139
+ this.form.querySelectorAll('input, select, textarea').forEach(element => {
140
+ inputEvents.forEach(eventType => {
141
+ element.addEventListener(eventType, changeHandler);
142
+ });
143
+ });
144
+
145
+ // Button clicks (for non-submit buttons)
146
+ this.form.querySelectorAll('button[type="button"]').forEach(button => {
147
+ button.addEventListener('click', (e) => this.handleButtonClick(e));
148
+ });
149
+
150
+ // Track submit button clicks
151
+ this.form.querySelectorAll('button[type="submit"]').forEach(button => {
152
+ button.addEventListener('click', (e) => {
153
+ this.clickedSubmitButton = e.currentTarget;
154
+ });
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Handle form submission
160
+ */
161
+ async handleSubmit(e) {
162
+ e.preventDefault();
163
+
164
+ // Check if already submitting
165
+ if (this.state.status === 'submitting' && !this.config.allowMultipleSubmissions) {
166
+ return;
167
+ }
168
+
169
+ // Clear any existing errors
170
+ this.clearErrors();
171
+
172
+ // Collect form data
173
+ const formData = this.collectFormData();
174
+
175
+ // Build the submit event detail
176
+ const submitEvent = new CustomEvent('submit', {
177
+ detail: {
178
+ data: formData,
179
+ form: this.form,
180
+ submitButton: this.clickedSubmitButton
181
+ },
182
+ cancelable: true
183
+ });
184
+
185
+ /* @dev-only:start */
186
+ {
187
+ console.log(`[FormManager] Submit event triggered on ${this.form.id}`, formData, submitEvent.detail.submitButton);
188
+ }
189
+ /* @dev-only:end */
190
+
191
+ // Validate if enabled
192
+ console.log('-----1');
193
+ if (this.config.validateOnSubmit) {
194
+ console.log('-----2');
195
+ const validation = this.validate(formData);
196
+ if (!validation.isValid) {
197
+ console.log('-----3');
198
+ this.showErrors(validation.errors);
199
+ // Show a summary notification for validation errors
200
+ const errorCount = Object.keys(validation.errors).length;
201
+ const message = errorCount === 1
202
+ ? 'Please correct the error below'
203
+ : `Please correct the ${errorCount} errors below`;
204
+ this.showNotification(message, 'danger');
205
+ return;
206
+ }
207
+ }
208
+
209
+ // Set submitting state
210
+ this.setFormState('submitting');
211
+
212
+ // Emit submit event with the clicked submit button
213
+ this.dispatchEvent(submitEvent);
214
+
215
+ // Reset clicked button after dispatching event
216
+ this.clickedSubmitButton = null;
217
+
218
+ // If event was not cancelled, handle default submission
219
+ if (!submitEvent.defaultPrevented) {
220
+ try {
221
+ // Default submission (can be overridden by listening to submit event)
222
+ await this.defaultSubmitHandler(formData);
223
+
224
+ // Success - set state based on allowMultipleSubmissions
225
+ if (this.config.allowMultipleSubmissions) {
226
+ this.setFormState('ready');
227
+ } else {
228
+ this.setFormState('submitted');
229
+ }
230
+
231
+ this.dispatchEvent(new CustomEvent('success', {
232
+ detail: { data: formData }
233
+ }));
234
+
235
+ // Reset form if configured
236
+ if (this.config.resetOnSuccess) {
237
+ this.reset();
238
+ }
239
+ } catch (error) {
240
+ // Error - always go back to ready state
241
+ this.setFormState('ready');
242
+ this.showError(error.message);
243
+ this.dispatchEvent(new CustomEvent('error', {
244
+ detail: { error, data: formData }
245
+ }));
246
+ }
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Default submit handler (can be overridden)
252
+ */
253
+ async defaultSubmitHandler(data) {
254
+ // Default implementation - just log
255
+ console.log('Form submitted with data:', data);
256
+ // Simulate async operation
257
+ await new Promise(resolve => setTimeout(resolve, 1000));
258
+ }
259
+
260
+ /**
261
+ * Handle input changes with debouncing
262
+ */
263
+ handleChange(e) {
264
+ const field = e.target;
265
+
266
+ // Clear field error immediately when user starts typing
267
+ if (field.classList.contains(this.config.fieldErrorClass)) {
268
+ this.clearFieldError(field);
269
+ }
270
+
271
+ // Clear any existing timeout for this field
272
+ if (this.changeTimeouts) {
273
+ clearTimeout(this.changeTimeouts.get(field));
274
+ } else {
275
+ this.changeTimeouts = new Map();
276
+ }
277
+
278
+ // Set a new timeout to capture the final value
279
+ this.changeTimeouts.set(field, setTimeout(() => {
280
+ this.state.isDirty = true;
281
+
282
+ // Collect all form data after the change
283
+ const data = this.collectFormData();
284
+
285
+ // Get the specific field value
286
+ const fieldValue = this.getFieldValue(field);
287
+
288
+ // Emit the unified change event
289
+ this.dispatchEvent(new CustomEvent('change', {
290
+ detail: {
291
+ field: field,
292
+ fieldName: field.name,
293
+ fieldValue: fieldValue,
294
+ data: data,
295
+ event: e
296
+ }
297
+ }));
298
+
299
+ // Clean up the timeout reference
300
+ this.changeTimeouts.delete(field);
301
+ }, 100)); // 100ms delay to ensure we capture the final value
302
+ }
303
+
304
+ /**
305
+ * Handle non-submit button clicks
306
+ */
307
+ handleButtonClick(e) {
308
+ const button = e.currentTarget;
309
+ const action = button.getAttribute('data-action') || button.id;
310
+
311
+ this.dispatchEvent(new CustomEvent('button', {
312
+ detail: {
313
+ button,
314
+ action,
315
+ data: this.collectFormData()
316
+ }
317
+ }));
318
+ }
319
+
320
+ /**
321
+ * Set form state and update UI accordingly
322
+ */
323
+ setFormState(status) {
324
+ const previousStatus = this.state.status;
325
+ this.state.status = status;
326
+
327
+ /* @dev-only:start */
328
+ {
329
+ console.log(`[FormManager] ${this.form.id || 'form'}: ${previousStatus} --> ${status}`);
330
+ }
331
+ /* @dev-only:end */
332
+
333
+ // Update form data attribute
334
+ this.form.setAttribute('data-form-state', status);
335
+
336
+ switch (status) {
337
+ case 'loading':
338
+ this.disableForm();
339
+ break;
340
+
341
+ case 'ready':
342
+ this.enableForm();
343
+ this.hideSubmittingState();
344
+ break;
345
+
346
+ case 'submitting':
347
+ this.disableForm();
348
+ this.showSubmittingState();
349
+ break;
350
+
351
+ case 'submitted':
352
+ // Keep form disabled after submission by default
353
+ if (!this.config.resetOnSuccess) {
354
+ this.disableForm();
355
+ }
356
+ this.hideSubmittingState();
357
+ // Update button text if submitButtonSuccessText is configured
358
+ if (this.config.submitButtonSuccessText && !this.config.allowMultipleSubmissions) {
359
+ this.showSuccessButtonText();
360
+ }
361
+ break;
362
+ }
363
+
364
+ // Emit state change event
365
+ this.dispatchEvent(new CustomEvent('statechange', {
366
+ detail: {
367
+ status,
368
+ previousStatus
369
+ }
370
+ }));
371
+ }
372
+
373
+ /**
374
+ * Disable all form controls
375
+ */
376
+ disableForm() {
377
+ if (!this.config.autoDisable) return;
378
+
379
+ // Disable all inputs, selects, textareas, and buttons
380
+ this.form.querySelectorAll('input, select, textarea, button').forEach(element => {
381
+ element.disabled = true;
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Enable all form controls
387
+ */
388
+ enableForm() {
389
+ if (!this.config.autoDisable) return;
390
+
391
+ /* @dev-only:start */
392
+ {
393
+ const count = this.form.querySelectorAll('input, select, textarea, button').length;
394
+ console.log(`[FormManager] Enabling ${count} controls in ${this.form.id || 'form'}`);
395
+ }
396
+ /* @dev-only:end */
397
+
398
+ // Enable all inputs, selects, textareas, and buttons
399
+ this.form.querySelectorAll('input, select, textarea, button').forEach(element => {
400
+ // Only enable if it wasn't originally disabled
401
+ const originalState = this.originalButtonStates.get(element);
402
+
403
+ // Always enable submit buttons regardless of original state
404
+ const isSubmitButton = element.type === 'submit';
405
+
406
+ /* @dev-only:start */
407
+ {
408
+ if (element.tagName === 'BUTTON') {
409
+ const willEnable = isSubmitButton || !originalState || !originalState.disabled;
410
+ console.log(`[FormManager] Button "${element.textContent.trim()}": originally ${originalState?.disabled ? 'disabled' : 'enabled'} --> ${willEnable ? 'enabling' : 'keeping disabled'}`);
411
+ }
412
+ }
413
+ /* @dev-only:end */
414
+
415
+ if (isSubmitButton || !originalState || !originalState.disabled) {
416
+ element.disabled = false;
417
+ }
418
+ });
419
+
420
+ // Focus the field with autofocus attribute if it exists
421
+ const autofocusField = this.form.querySelector('[autofocus]');
422
+ if (autofocusField && !autofocusField.disabled) {
423
+ autofocusField.focus();
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Show submitting state on buttons
429
+ */
430
+ showSubmittingState() {
431
+ if (!this.config.showSpinner) return;
432
+
433
+ // Update submit buttons
434
+ this.form.querySelectorAll('button[type="submit"]').forEach(button => {
435
+ const originalState = this.originalButtonStates.get(button);
436
+ if (originalState) {
437
+ button.innerHTML = this.config.spinnerHTML + this.config.submitButtonLoadingText;
438
+ }
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Hide submitting state on buttons
444
+ */
445
+ hideSubmittingState() {
446
+ // Restore original button content
447
+ this.form.querySelectorAll('button[type="submit"]').forEach(button => {
448
+ const originalState = this.originalButtonStates.get(button);
449
+ if (originalState) {
450
+ button.innerHTML = originalState.innerHTML;
451
+ }
452
+ });
453
+ }
454
+
455
+ /**
456
+ * Show success button text after successful submission
457
+ */
458
+ showSuccessButtonText() {
459
+ if (!this.config.submitButtonSuccessText) return;
460
+
461
+ // Update submit buttons with success text
462
+ this.form.querySelectorAll('button[type="submit"]').forEach(button => {
463
+ // Find the button-text span if it exists
464
+ const buttonTextSpan = button.querySelector('.button-text');
465
+ if (buttonTextSpan) {
466
+ buttonTextSpan.textContent = this.config.submitButtonSuccessText;
467
+ } else {
468
+ // If no button-text span, update the entire button content
469
+ button.textContent = this.config.submitButtonSuccessText;
470
+ }
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Set nested property value using dot notation
476
+ */
477
+ setNestedValue(obj, path, value) {
478
+ // Check if path contains dots
479
+ if (!path.includes('.')) {
480
+ obj[path] = value;
481
+ return;
482
+ }
483
+
484
+ const keys = path.split('.');
485
+ const lastKey = keys.pop();
486
+
487
+ // Create nested structure if it doesn't exist
488
+ let current = obj;
489
+ for (const key of keys) {
490
+ if (!current[key] || typeof current[key] !== 'object') {
491
+ current[key] = {};
492
+ }
493
+ current = current[key];
494
+ }
495
+
496
+ // Set the value
497
+ current[lastKey] = value;
498
+ }
499
+
500
+ /**
501
+ * Get nested property value using dot notation
502
+ */
503
+ getNestedValue(obj, path) {
504
+ // Check if path contains dots
505
+ if (!path.includes('.')) {
506
+ return obj[path];
507
+ }
508
+
509
+ const keys = path.split('.');
510
+ let current = obj;
511
+
512
+ for (const key of keys) {
513
+ if (current == null || typeof current !== 'object') {
514
+ return undefined;
515
+ }
516
+ current = current[key];
517
+ }
518
+
519
+ return current;
520
+ }
521
+
522
+ /**
523
+ * Collect all form data
524
+ */
525
+ collectFormData() {
526
+ const formData = new FormData(this.form);
527
+ const data = {};
528
+
529
+
530
+ // Convert FormData to plain object with support for dot notation
531
+ for (const [key, value] of formData.entries()) {
532
+ // Handle both nested and flat structure
533
+ const existingValue = this.getNestedValue(data, key);
534
+ if (existingValue !== undefined) {
535
+ // Handle multiple values
536
+ if (Array.isArray(existingValue)) {
537
+ existingValue.push(value);
538
+ } else {
539
+ this.setNestedValue(data, key, [existingValue, value]);
540
+ }
541
+ } else {
542
+ this.setNestedValue(data, key, value);
543
+ }
544
+ }
545
+
546
+ // Handle checkboxes that might not be in FormData when unchecked
547
+ this.form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
548
+ const name = checkbox.name;
549
+ if (!checkbox.checked) {
550
+ // Check if value exists
551
+ const value = this.getNestedValue(data, name);
552
+ if (value === undefined) {
553
+ // For single checkboxes, set to false
554
+ if (!this.form.querySelectorAll(`input[type="checkbox"][name="${name}"]`)[1]) {
555
+ this.setNestedValue(data, name, false);
556
+ }
557
+ }
558
+ }
559
+ });
560
+
561
+ // Handle radio buttons
562
+ this.form.querySelectorAll('input[type="radio"]:checked').forEach(radio => {
563
+ this.setNestedValue(data, radio.name, radio.value);
564
+ });
565
+
566
+ this.state.data = data;
567
+ return data;
568
+ }
569
+
570
+ /**
571
+ * Get field value by element or name
572
+ */
573
+ getFieldValue(fieldOrName) {
574
+ const field = typeof fieldOrName === 'string'
575
+ ? this.form.querySelector(`[name="${fieldOrName}"]`)
576
+ : fieldOrName;
577
+
578
+ if (!field) return undefined;
579
+
580
+ if (field.type === 'checkbox') {
581
+ // For multiple checkboxes with same name, return array of checked values
582
+ const checkboxes = this.form.querySelectorAll(`input[type="checkbox"][name="${field.name}"]`);
583
+ if (checkboxes.length > 1) {
584
+ return Array.from(checkboxes)
585
+ .filter(cb => cb.checked)
586
+ .map(cb => cb.value);
587
+ }
588
+ return field.checked;
589
+ } else if (field.type === 'radio') {
590
+ return this.form.querySelector(`input[name="${field.name}"]:checked`)?.value;
591
+ } else {
592
+ return field.value;
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Validate form data
598
+ */
599
+ validate(data) {
600
+ const errors = {};
601
+ let isValid = true;
602
+
603
+ // Log
604
+ /* @dev-only:start */
605
+ {
606
+ console.log(`[FormManager] Validating form ${this.form.id || 'form'}`, data);
607
+ }
608
+ /* @dev-only:end */
609
+
610
+ // Check required fields
611
+ this.form.querySelectorAll('[required]').forEach(field => {
612
+ const value = this.getNestedValue(data, field.name);
613
+
614
+ if (!value || (typeof value === 'string' && !value.trim())) {
615
+ errors[field.name] = `${this.getFieldLabel(field)} is required`;
616
+ isValid = false;
617
+ }
618
+ });
619
+
620
+ // Check email fields
621
+ this.form.querySelectorAll('input[type="email"]').forEach(field => {
622
+ const value = this.getNestedValue(data, field.name);
623
+
624
+ if (value && !this.isValidEmail(value)) {
625
+ errors[field.name] = 'Please enter a valid email address';
626
+ isValid = false;
627
+ }
628
+ });
629
+
630
+ // Check pattern validation
631
+ this.form.querySelectorAll('[pattern]').forEach(field => {
632
+ const value = this.getNestedValue(data, field.name);
633
+
634
+ const pattern = new RegExp(field.pattern);
635
+ if (value && !pattern.test(value)) {
636
+ errors[field.name] = field.title || 'Invalid format';
637
+ isValid = false;
638
+ }
639
+ });
640
+
641
+ // Check minlength validation
642
+ this.form.querySelectorAll('[minlength]').forEach(field => {
643
+ const value = this.getNestedValue(data, field.name);
644
+
645
+ const minLength = parseInt(field.minLength);
646
+ if (value && value.length < minLength) {
647
+ errors[field.name] = `${this.getFieldLabel(field)} must be at least ${minLength} characters`;
648
+ isValid = false;
649
+ }
650
+ });
651
+
652
+ // Check maxlength validation
653
+ this.form.querySelectorAll('[maxlength]').forEach(field => {
654
+ const value = this.getNestedValue(data, field.name);
655
+
656
+ const maxLength = parseInt(field.maxLength);
657
+ if (value && value.length > maxLength) {
658
+ errors[field.name] = `${this.getFieldLabel(field)} must be no more than ${maxLength} characters`;
659
+ isValid = false;
660
+ }
661
+ });
662
+
663
+ // Check min validation for number inputs
664
+ this.form.querySelectorAll('input[type="number"][min]').forEach(field => {
665
+ const value = this.getNestedValue(data, field.name);
666
+
667
+ const min = parseFloat(field.min);
668
+ const numValue = parseFloat(value);
669
+ if (!isNaN(numValue) && numValue < min) {
670
+ errors[field.name] = `${this.getFieldLabel(field)} must be at least ${min}`;
671
+ isValid = false;
672
+ }
673
+ });
674
+
675
+ // Check max validation for number inputs
676
+ this.form.querySelectorAll('input[type="number"][max]').forEach(field => {
677
+ const value = this.getNestedValue(data, field.name);
678
+
679
+ const max = parseFloat(field.max);
680
+ const numValue = parseFloat(value);
681
+ if (!isNaN(numValue) && numValue > max) {
682
+ errors[field.name] = `${this.getFieldLabel(field)} must be no more than ${max}`;
683
+ isValid = false;
684
+ }
685
+ });
686
+
687
+ // Custom validation
688
+ const customValidation = new CustomEvent('validate', {
689
+ detail: { data, errors },
690
+ cancelable: true
691
+ });
692
+ this.dispatchEvent(customValidation);
693
+
694
+ // Update isValid if custom validation added errors
695
+ this.state.isValid = isValid;
696
+ this.state.errors = errors;
697
+
698
+ // Log
699
+ /* @dev-only:start */
700
+ {
701
+ console.log(`[FormManager] Validation result for ${this.form.id || 'form'}`, { isValid, errors });
702
+ }
703
+ /* @dev-only:end */
704
+
705
+ return { isValid, errors };
706
+ }
707
+
708
+ /**
709
+ * Get field label
710
+ */
711
+ getFieldLabel(field) {
712
+ // Try to find associated label
713
+ const label = this.form.querySelector(`label[for="${field.id}"]`);
714
+ if (label) {
715
+ return label.textContent.replace('*', '').trim();
716
+ }
717
+
718
+ // Fallback to name attribute
719
+ return field.name.replace(/[_-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
720
+ }
721
+
722
+ /**
723
+ * Email validation helper
724
+ */
725
+ isValidEmail(email) {
726
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
727
+ }
728
+
729
+ /**
730
+ * Show errors
731
+ */
732
+ showErrors(errors) {
733
+ // Clear previous errors
734
+ this.clearErrors();
735
+
736
+ let firstErrorField = null;
737
+
738
+ // Show field-specific errors
739
+ Object.entries(errors).forEach(([fieldName, error]) => {
740
+ const field = this.form.querySelector(`[name="${fieldName}"]`);
741
+ if (field) {
742
+ field.classList.add(this.config.fieldErrorClass);
743
+
744
+ // Track first error field for focus
745
+ if (!firstErrorField) {
746
+ firstErrorField = field;
747
+ }
748
+
749
+ // Show error message
750
+ const errorElement = document.createElement('div');
751
+ errorElement.className = 'invalid-feedback';
752
+ errorElement.textContent = error;
753
+
754
+ // Insert after field or field group
755
+ const insertAfter = field.closest('.input-group') || field;
756
+ insertAfter.parentNode.insertBefore(errorElement, insertAfter.nextSibling);
757
+ }
758
+ });
759
+
760
+ // Focus the first field with an error
761
+ if (firstErrorField) {
762
+ firstErrorField.focus();
763
+
764
+ // Scroll into view if needed
765
+ firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
766
+ }
767
+
768
+ // If there are errors that couldn't be attached to fields, show them in a notification
769
+ const unattachedErrors = Object.entries(errors).filter(([fieldName]) => {
770
+ return !this.form.querySelector(`[name="${fieldName}"]`);
771
+ });
772
+
773
+ if (unattachedErrors.length > 0) {
774
+ const errorMessage = unattachedErrors.map(([_, error]) => error).join(', ');
775
+ this.showNotification(errorMessage, 'danger');
776
+ }
777
+ }
778
+
779
+ /**
780
+ * Show notification (toast-style)
781
+ */
782
+ showNotification(message, type = 'info') {
783
+ const $notification = document.createElement('div');
784
+ $notification.className = `alert alert-${type} alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-5 animation-slide-down`;
785
+ $notification.style.zIndex = '9999';
786
+ $notification.innerHTML = `
787
+ ${message}
788
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
789
+ `;
790
+
791
+ document.body.appendChild($notification);
792
+
793
+ setTimeout(() => {
794
+ $notification.remove();
795
+ }, 5000);
796
+ }
797
+
798
+ /**
799
+ * Show single error message
800
+ */
801
+ showError(messageOrError) {
802
+ // Handle Error objects and strings
803
+ let message;
804
+
805
+ if (messageOrError instanceof Error) {
806
+ message = messageOrError.message;
807
+ console.error('FormManager Error:', messageOrError);
808
+ } else {
809
+ message = messageOrError;
810
+ console.error('FormManager Error:', message);
811
+ }
812
+
813
+ // Always use notification system
814
+ this.showNotification(message, 'danger');
815
+ }
816
+
817
+ /**
818
+ * Clear all errors
819
+ */
820
+ clearErrors() {
821
+ // Remove field error classes
822
+ this.form.querySelectorAll(`.${this.config.fieldErrorClass}`).forEach(field => {
823
+ field.classList.remove(this.config.fieldErrorClass);
824
+ });
825
+
826
+ // Remove error messages
827
+ this.form.querySelectorAll('.invalid-feedback').forEach(el => el.remove());
828
+ }
829
+
830
+ /**
831
+ * Clear error for a specific field
832
+ */
833
+ clearFieldError(field) {
834
+ // Remove error class from field
835
+ field.classList.remove(this.config.fieldErrorClass);
836
+
837
+ // Remove error message for this field
838
+ const errorElement = field.parentElement.querySelector('.invalid-feedback') ||
839
+ field.closest('.input-group')?.parentElement.querySelector('.invalid-feedback');
840
+ if (errorElement) {
841
+ errorElement.remove();
842
+ }
843
+
844
+ // Remove field from state errors
845
+ if (this.state.errors && field.name) {
846
+ delete this.state.errors[field.name];
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Show success message
852
+ */
853
+ showSuccess(message) {
854
+ // Always use notification system
855
+ this.showNotification(message, 'success');
856
+ }
857
+
858
+ /**
859
+ * Reset form
860
+ */
861
+ reset() {
862
+ this.form.reset();
863
+ this.state.isDirty = false;
864
+ this.state.data = {};
865
+ this.state.errors = {};
866
+ this.clearErrors();
867
+ this.setFormState('ready');
868
+ }
869
+
870
+ /**
871
+ * Set field value programmatically
872
+ */
873
+ setFieldValue(fieldName, value) {
874
+ const field = this.form.querySelector(`[name="${fieldName}"]`);
875
+ if (!field) {
876
+ return;
877
+ }
878
+
879
+ if (field.type === 'checkbox') {
880
+ field.checked = !!value;
881
+ } else if (field.type === 'radio') {
882
+ const radio = this.form.querySelector(`[name="${fieldName}"][value="${value}"]`);
883
+ if (radio) {
884
+ radio.checked = true;
885
+ }
886
+ } else {
887
+ field.value = value;
888
+ }
889
+
890
+ // Update internal state data with nested field support
891
+ this.setNestedValue(this.state.data, fieldName, value);
892
+
893
+ // Trigger change event
894
+ field.dispatchEvent(new Event('change', { bubbles: true }));
895
+ }
896
+
897
+ /**
898
+ * Get field value from state data
899
+ */
900
+ getValue(fieldName) {
901
+ return this.getNestedValue(this.state.data, fieldName);
902
+ }
903
+
904
+ /**
905
+ * Disable specific field
906
+ */
907
+ disableField(fieldName) {
908
+ const field = this.form.querySelector(`[name="${fieldName}"]`);
909
+ if (field) field.disabled = true;
910
+ }
911
+
912
+ /**
913
+ * Enable specific field
914
+ */
915
+ enableField(fieldName) {
916
+ const field = this.form.querySelector(`[name="${fieldName}"]`);
917
+ if (field) field.disabled = false;
918
+ }
919
+
920
+ /**
921
+ * Get current form data
922
+ */
923
+ getData() {
924
+ return this.collectFormData();
925
+ }
926
+
927
+ /**
928
+ * Flatten nested object to dot notation
929
+ */
930
+ flattenObject(obj, prefix = '') {
931
+ const flattened = {};
932
+
933
+ for (const [key, value] of Object.entries(obj)) {
934
+ const newKey = prefix ? `${prefix}.${key}` : key;
935
+
936
+ if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
937
+ // Recursively flatten nested objects
938
+ Object.assign(flattened, this.flattenObject(value, newKey));
939
+ } else {
940
+ flattened[newKey] = value;
941
+ }
942
+ }
943
+
944
+ return flattened;
945
+ }
946
+
947
+ /**
948
+ * Set form values programmatically
949
+ * @param {Object} values - Object with field names as keys and values to set (supports nested objects)
950
+ */
951
+ setValues(values) {
952
+ if (!values || typeof values !== 'object') return;
953
+
954
+ // Flatten nested objects to dot notation
955
+ const flatValues = this.flattenObject(values);
956
+
957
+ Object.entries(flatValues).forEach(([name, value]) => {
958
+ // Find form elements by name or id
959
+ const element = this.form.querySelector(`[name="${name}"]`) ||
960
+ this.form.querySelector(`#${name}`) ||
961
+ this.form.querySelector(`#${name}-input`) ||
962
+ this.form.querySelector(`#${name}-select`);
963
+
964
+ if (!element) return;
965
+
966
+ // Handle different element types
967
+ if (element.type === 'checkbox') {
968
+ element.checked = !!value;
969
+ } else if (element.type === 'radio') {
970
+ // For radio buttons, find the one with matching value
971
+ const radioGroup = this.form.querySelectorAll(`[name="${name}"]`);
972
+ radioGroup.forEach(radio => {
973
+ radio.checked = radio.value === value;
974
+ });
975
+ } else if (element.tagName === 'SELECT') {
976
+ // For select elements, set the value
977
+ element.value = value;
978
+ // If value doesn't exist in options, try to find by text
979
+ if (!element.value && value) {
980
+ const option = Array.from(element.options).find(opt =>
981
+ opt.text.toLowerCase() === value.toLowerCase()
982
+ );
983
+ if (option) element.value = option.value;
984
+ }
985
+ } else {
986
+ // For text inputs, textareas, etc.
987
+ element.value = value || '';
988
+ }
989
+
990
+ // Trigger change event to update form state
991
+ const event = new Event('change', { bubbles: true });
992
+ element.dispatchEvent(event);
993
+ });
994
+
995
+ // Update form state
996
+ this.state.data = this.collectFormData();
997
+ }
998
+
999
+ /**
1000
+ * Get form state
1001
+ */
1002
+ getState() {
1003
+ return { ...this.state };
1004
+ }
1005
+
1006
+ /**
1007
+ * Check if form is dirty
1008
+ */
1009
+ isDirty() {
1010
+ return this.state.isDirty;
1011
+ }
1012
+
1013
+ /**
1014
+ * Check if form is valid
1015
+ */
1016
+ isValid() {
1017
+ const validation = this.validate(this.collectFormData());
1018
+ return validation.isValid;
1019
+ }
1020
+ }