ultimate-jekyll-manager 0.0.118 → 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 (51) hide show
  1. package/CLAUDE.md +409 -23
  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/core/auth.js +5 -4
  6. package/dist/assets/js/core/cookieconsent.js +24 -17
  7. package/dist/assets/js/core/exit-popup.js +15 -12
  8. package/dist/assets/js/core/social-sharing.js +8 -4
  9. package/dist/assets/js/libs/auth/pages.js +78 -149
  10. package/dist/assets/js/libs/dev.js +192 -129
  11. package/dist/assets/js/libs/form-manager.js +643 -775
  12. package/dist/assets/js/pages/account/index.js +3 -2
  13. package/dist/assets/js/pages/account/sections/api-keys.js +37 -52
  14. package/dist/assets/js/pages/account/sections/connections.js +37 -46
  15. package/dist/assets/js/pages/account/sections/delete.js +57 -78
  16. package/dist/assets/js/pages/account/sections/profile.js +37 -56
  17. package/dist/assets/js/pages/account/sections/security.js +102 -125
  18. package/dist/assets/js/pages/admin/notifications/new/index.js +73 -151
  19. package/dist/assets/js/pages/blog/index.js +33 -53
  20. package/dist/assets/js/pages/contact/index.js +112 -173
  21. package/dist/assets/js/pages/download/index.js +39 -86
  22. package/dist/assets/js/pages/oauth2/index.js +17 -17
  23. package/dist/assets/js/pages/payment/checkout/index.js +23 -36
  24. package/dist/assets/js/pages/pricing/index.js +5 -2
  25. package/dist/assets/js/pages/test/libraries/form-manager/index.js +194 -0
  26. package/dist/assets/themes/classy/css/components/_cards.scss +2 -2
  27. package/dist/defaults/_.env +6 -0
  28. package/dist/defaults/_.gitignore +7 -1
  29. package/dist/defaults/dist/_includes/core/body.html +5 -13
  30. package/dist/defaults/dist/_includes/core/foot.html +1 -0
  31. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/nav.html +51 -36
  32. package/dist/defaults/dist/_layouts/blueprint/admin/notifications/new.html +13 -2
  33. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/about.html +84 -42
  34. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/account/index.html +26 -21
  35. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signin.html +2 -2
  36. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html +2 -2
  37. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/blog/index.html +72 -58
  38. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/blog/post.html +46 -29
  39. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/contact.html +46 -53
  40. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/download.html +111 -73
  41. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/index.html +111 -56
  42. package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/pricing.html +127 -81
  43. package/dist/defaults/dist/pages/test/libraries/form-manager.html +181 -0
  44. package/dist/defaults/dist/pages/test/libraries/lazy-loading.html +1 -1
  45. package/dist/gulp/tasks/defaults.js +210 -1
  46. package/dist/gulp/tasks/serve.js +18 -0
  47. package/dist/lib/logger.js +1 -1
  48. package/firebase-debug.log +770 -0
  49. package/package.json +6 -6
  50. package/.playwright-mcp/page-2025-10-22T19-11-27-666Z.png +0 -0
  51. package/.playwright-mcp/page-2025-10-22T19-11-57-357Z.png +0 -0
@@ -1,1020 +1,888 @@
1
1
  /**
2
- * Form Manager Library
3
- * A comprehensive form management system that handles state, validation, submission, and UI updates
2
+ * FormManager - Lightweight form state management
3
+ *
4
+ * States: initializing → ready ⇄ submitting → ready (or submitted)
5
+ *
6
+ * Usage:
7
+ * const formManager = new FormManager('#my-form', { options });
8
+ * formManager.on('submit', async (data) => {
9
+ * const response = await fetch('/api', { body: JSON.stringify(data) });
10
+ * if (!response.ok) throw new Error('Failed');
11
+ * });
4
12
  */
5
13
 
6
- export class FormManager extends EventTarget {
14
+ // Libraries
15
+ import { ready as domReady } from 'web-manager/modules/dom.js';
16
+ import { showNotification } from 'web-manager/modules/utilities.js';
17
+
18
+ export class FormManager {
7
19
  constructor(selector, options = {}) {
8
- super();
20
+ // Get form element
21
+ this.$form = typeof selector === 'string'
22
+ ? document.querySelector(selector)
23
+ : selector;
9
24
 
10
- // Store whether initialState was explicitly provided
11
- this.hasCustomInitialState = options.hasOwnProperty('initialState');
25
+ if (!this.$form) {
26
+ throw new Error(`FormManager: Form not found: ${selector}`);
27
+ }
12
28
 
13
- // Configuration with defaults
29
+ // Configuration
14
30
  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
31
+ autoReady: true, // Auto-transition to initialState when DOM is ready
32
+ initialState: 'ready', // State to transition to when autoReady fires
33
+ allowResubmit: true, // Allow resubmission after success (false = go to 'submitted' state)
34
+ resetOnSuccess: false, // Clear form fields after successful submission
35
+ warnOnUnsavedChanges: false, // Warn user before leaving page with unsaved changes
36
+ submittingText: 'Processing...', // Text shown on submit button during submission
37
+ submittedText: 'Processed!', // Text shown on submit button after submission (when allowResubmit: false)
38
+ ...options,
29
39
  };
30
40
 
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
41
+ // State
42
+ this.state = 'initializing';
43
+ this._isDirty = false;
44
+
45
+ // Event listeners
46
+ this._listeners = {
47
+ change: [],
48
+ validation: [],
49
+ submit: [],
50
+ statechange: [],
44
51
  };
45
52
 
46
- // Store original button states
47
- this.originalButtonStates = new Map();
53
+ // Field errors (populated during validation)
54
+ this._fieldErrors = {};
55
+
56
+ // Bind beforeunload handler so we can remove it later
57
+ this._beforeUnloadHandler = (e) => this._handleBeforeUnload(e);
58
+
59
+ /* @dev-only:start */
60
+ {
61
+ console.log('[Form-manager] Initialized', {
62
+ selector: typeof selector === 'string' ? selector : this.$form.id || this.$form,
63
+ config: this.config,
64
+ });
65
+ }
66
+ /* @dev-only:end */
48
67
 
49
68
  // Initialize
50
- this.init();
69
+ this._init();
51
70
  }
52
71
 
53
72
  /**
54
73
  * Initialize the form manager
55
74
  */
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);
75
+ _init() {
76
+ // Disable form during initialization
77
+ this._setDisabled(true);
64
78
 
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;
79
+ // Attach submit handler
80
+ this.$form.addEventListener('submit', (e) => this._handleSubmit(e));
74
81
 
75
- // Store if this is the first initialization
76
- this.isInitializing = true;
82
+ // Attach change handlers
83
+ this.$form.addEventListener('input', (e) => this._handleChange(e));
84
+ this.$form.addEventListener('change', (e) => this._handleChange(e));
77
85
 
78
- // Set initial state
79
- this.setFormState(this.config.initialState);
86
+ // Attach beforeunload handler if configured
87
+ if (this.config.warnOnUnsavedChanges) {
88
+ window.addEventListener('beforeunload', this._beforeUnloadHandler);
89
+ }
80
90
 
81
- // Attach event listeners
82
- this.attachEventListeners();
91
+ // Handle page restored from bfcache (e.g., back button after OAuth redirect)
92
+ window.addEventListener('pageshow', (e) => this._handlePageShow(e));
83
93
 
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)
94
+ // Auto-transition to initialState when DOM is ready
95
+ if (this.config.autoReady) {
96
+ domReady().then(() => this._setInitialState());
97
+ }
98
+ }
87
99
 
88
- // Log
89
- console.log('[FormManager] pageshow event', event);
100
+ /**
101
+ * Register event listener
102
+ */
103
+ on(event, callback) {
104
+ if (!this._listeners[event]) {
105
+ this._listeners[event] = [];
106
+ }
107
+ this._listeners[event].push(callback);
108
+ return this; // Allow chaining
109
+ }
90
110
 
91
- // Quit if not persisted
92
- if (!event.persisted) {
93
- return;
94
- }
111
+ /**
112
+ * Emit event to all listeners
113
+ */
114
+ async _emit(event, data) {
115
+ const listeners = this._listeners[event] || [];
116
+ for (const callback of listeners) {
117
+ await callback(data);
118
+ }
119
+ }
95
120
 
96
- // Log
97
- console.log('[FormManager] Page restored from cache, resetting form to ready state');
121
+ /**
122
+ * Set initial state based on config
123
+ */
124
+ _setInitialState() {
125
+ const state = this.config.initialState;
98
126
 
99
- // Reset form to ready state if it was in submitting state
100
- if (this.state.status === 'submitting') {
101
- this.setFormState('ready');
102
- }
103
- });
127
+ /* @dev-only:start */
128
+ {
129
+ console.log('[Form-manager] DOM ready, setting initial state:', state);
130
+ }
131
+ /* @dev-only:end */
104
132
 
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
- }
133
+ if (state === 'ready') {
134
+ this.ready();
121
135
  } else {
122
- // If custom initial state was provided, we're done initializing
123
- this.isInitializing = false;
136
+ this._setState(state);
124
137
  }
125
138
  }
126
139
 
127
140
  /**
128
- * Attach all event listeners
141
+ * Transition to ready state
129
142
  */
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
- });
143
+ ready() {
144
+ /* @dev-only:start */
145
+ {
146
+ console.log('[Form-manager] ready() called');
147
+ }
148
+ /* @dev-only:end */
144
149
 
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
- });
150
+ this._setState('ready');
151
+ this._setDisabled(false);
149
152
 
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
- });
153
+ // Focus the field with autofocus attribute if it exists
154
+ const $autofocusField = this.$form.querySelector('[autofocus]');
155
+ if ($autofocusField && !$autofocusField.disabled) {
156
+ $autofocusField.focus();
157
+ }
156
158
  }
157
159
 
158
160
  /**
159
161
  * Handle form submission
160
162
  */
161
- async handleSubmit(e) {
163
+ async _handleSubmit(e) {
164
+ // Always prevent default - this is the whole point
162
165
  e.preventDefault();
163
166
 
164
- // Check if already submitting
165
- if (this.state.status === 'submitting' && !this.config.allowMultipleSubmissions) {
167
+ // Ignore if not ready
168
+ if (this.state !== 'ready') {
169
+ /* @dev-only:start */
170
+ {
171
+ console.log('[Form-manager] Submit ignored, not ready. Current state:', this.state);
172
+ }
173
+ /* @dev-only:end */
166
174
  return;
167
175
  }
168
176
 
169
- // Clear any existing errors
170
- this.clearErrors();
177
+ // Get the submit button that was clicked (native browser API)
178
+ const $submitButton = e.submitter;
171
179
 
172
- // Collect form data
173
- const formData = this.collectFormData();
180
+ // Collect form data BEFORE disabling (disabled elements aren't in FormData)
181
+ const data = this.getData();
174
182
 
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
- });
183
+ // Clear previous field errors
184
+ this.clearFieldErrors();
185
+
186
+ // Run validation BEFORE transitioning to submitting state
187
+ const validationPassed = await this._runValidation(data, $submitButton);
188
+ if (!validationPassed) {
189
+ return;
190
+ }
191
+
192
+ // Transition to submitting
193
+ this._setState('submitting');
194
+ this._setDisabled(true);
195
+ this._showSpinner(true);
184
196
 
185
197
  /* @dev-only:start */
186
198
  {
187
- console.log(`[FormManager] Submit event triggered on ${this.form.id}`, formData, submitEvent.detail.submitButton);
199
+ console.log('[Form-manager] Submitting', {
200
+ data,
201
+ submitButton: $submitButton?.name ? `${$submitButton.name}=${$submitButton.value}` : null,
202
+ });
188
203
  }
189
204
  /* @dev-only:end */
190
205
 
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
- }));
206
+ try {
207
+ // Let consumers handle the submission
208
+ await this._emit('submit', { data, $submitButton });
234
209
 
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
- }));
210
+ /* @dev-only:start */
211
+ {
212
+ console.log('[Form-manager] Submit success', {
213
+ resetOnSuccess: this.config.resetOnSuccess,
214
+ allowResubmit: this.config.allowResubmit,
215
+ });
246
216
  }
247
- }
248
- }
217
+ /* @dev-only:end */
249
218
 
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
- }
219
+ // Success - clear dirty state
220
+ this.setDirty(false);
221
+ this._showSpinner(false);
259
222
 
260
- /**
261
- * Handle input changes with debouncing
262
- */
263
- handleChange(e) {
264
- const field = e.target;
223
+ if (this.config.resetOnSuccess) {
224
+ this.$form.reset();
225
+ }
265
226
 
266
- // Clear field error immediately when user starts typing
267
- if (field.classList.contains(this.config.fieldErrorClass)) {
268
- this.clearFieldError(field);
269
- }
227
+ if (this.config.allowResubmit) {
228
+ this._setState('ready');
229
+ this._setDisabled(false);
230
+ } else {
231
+ this._setState('submitted');
232
+ this._showSubmittedText();
233
+ // Stay disabled - no more submissions allowed
234
+ }
235
+ } catch (error) {
236
+ /* @dev-only:start */
237
+ {
238
+ console.log('[Form-manager] Submit error:', error.message);
239
+ }
240
+ /* @dev-only:end */
270
241
 
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();
242
+ // Error - go back to ready and show error
243
+ this._setState('ready');
244
+ this._setDisabled(false);
245
+ this._showSpinner(false);
246
+ this.showError(error.message || 'An error occurred');
276
247
  }
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
248
  }
303
249
 
304
250
  /**
305
- * Handle non-submit button clicks
251
+ * Handle input changes
306
252
  */
307
- handleButtonClick(e) {
308
- const button = e.currentTarget;
309
- const action = button.getAttribute('data-action') || button.id;
253
+ _handleChange(e) {
254
+ // Mark form as dirty
255
+ this.setDirty(true);
310
256
 
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;
257
+ const data = this.getData();
326
258
 
327
259
  /* @dev-only:start */
328
260
  {
329
- console.log(`[FormManager] ${this.form.id || 'form'}: ${previousStatus} --> ${status}`);
261
+ console.log('[Form-manager] Change', {
262
+ name: e.target.name,
263
+ value: e.target.value,
264
+ data,
265
+ });
330
266
  }
331
267
  /* @dev-only:end */
332
268
 
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;
269
+ this._emit('change', {
270
+ field: e.target,
271
+ name: e.target.name,
272
+ value: e.target.value,
273
+ data,
274
+ });
350
275
 
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;
276
+ // Clear field error when user types in that field
277
+ if (this._fieldErrors[e.target.name]) {
278
+ this._clearFieldError(e.target.name);
362
279
  }
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
280
  }
384
281
 
385
282
  /**
386
- * Enable all form controls
283
+ * Run validation (HTML5 + custom validation event)
284
+ * Returns true if validation passed, false if there are errors
387
285
  */
388
- enableForm() {
389
- if (!this.config.autoDisable) return;
390
-
286
+ async _runValidation(data, $submitButton) {
391
287
  /* @dev-only:start */
392
288
  {
393
- const count = this.form.querySelectorAll('input, select, textarea, button').length;
394
- console.log(`[FormManager] Enabling ${count} controls in ${this.form.id || 'form'}`);
289
+ console.log('[Form-manager] Running validation');
395
290
  }
396
291
  /* @dev-only:end */
397
292
 
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);
293
+ // Create setError helper for custom validation
294
+ const setError = (fieldName, message) => {
295
+ this._fieldErrors[fieldName] = message;
296
+ };
297
+
298
+ // 1. Run automatic HTML5 validation
299
+ this._runHTML5Validation(setError);
402
300
 
403
- // Always enable submit buttons regardless of original state
404
- const isSubmitButton = element.type === 'submit';
301
+ // 2. Run custom validation listeners
302
+ await this._emit('validation', { data, setError, $submitButton });
405
303
 
304
+ // 3. Check if there are any errors
305
+ const errorCount = Object.keys(this._fieldErrors).length;
306
+ if (errorCount > 0) {
406
307
  /* @dev-only:start */
407
308
  {
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
- }
309
+ console.log('[Form-manager] Validation failed:', this._fieldErrors);
412
310
  }
413
311
  /* @dev-only:end */
414
312
 
415
- if (isSubmitButton || !originalState || !originalState.disabled) {
416
- element.disabled = false;
417
- }
418
- });
313
+ // Display all field errors
314
+ this._displayFieldErrors();
419
315
 
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
- }
316
+ // Focus first error field
317
+ this._focusFirstError();
426
318
 
427
- /**
428
- * Show submitting state on buttons
429
- */
430
- showSubmittingState() {
431
- if (!this.config.showSpinner) return;
319
+ return false;
320
+ }
432
321
 
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
- }
322
+ /* @dev-only:start */
323
+ {
324
+ console.log('[Form-manager] Validation passed');
325
+ }
326
+ /* @dev-only:end */
441
327
 
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
- });
328
+ return true;
453
329
  }
454
330
 
455
331
  /**
456
- * Show success button text after successful submission
332
+ * Run HTML5 constraint validation on all form fields
457
333
  */
458
- showSuccessButtonText() {
459
- if (!this.config.submitButtonSuccessText) return;
334
+ _runHTML5Validation(setError) {
335
+ const $fields = this.$form.querySelectorAll('input, select, textarea');
460
336
 
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;
337
+ $fields.forEach(($field) => {
338
+ const name = $field.name;
339
+ if (!name) {
340
+ return;
470
341
  }
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
342
 
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] = {};
343
+ // Skip if already has an error (from previous validation)
344
+ if (this._fieldErrors[name]) {
345
+ return;
492
346
  }
493
- current = current[key];
494
- }
495
347
 
496
- // Set the value
497
- current[lastKey] = value;
498
- }
348
+ const value = $field.value;
349
+ const type = $field.type;
499
350
 
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
- }
351
+ // Required validation
352
+ if ($field.hasAttribute('required')) {
353
+ if (type === 'checkbox' && !$field.checked) {
354
+ setError(name, 'This field is required');
355
+ return;
356
+ }
357
+ if (!value || !value.trim()) {
358
+ setError(name, 'This field is required');
359
+ return;
360
+ }
361
+ }
508
362
 
509
- const keys = path.split('.');
510
- let current = obj;
363
+ // Skip further validation if empty and not required
364
+ if (!value) {
365
+ return;
366
+ }
511
367
 
512
- for (const key of keys) {
513
- if (current == null || typeof current !== 'object') {
514
- return undefined;
368
+ // Email validation
369
+ if (type === 'email') {
370
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
371
+ if (!emailPattern.test(value)) {
372
+ setError(name, 'Please enter a valid email address');
373
+ return;
374
+ }
515
375
  }
516
- current = current[key];
517
- }
518
376
 
519
- return current;
520
- }
377
+ // URL validation
378
+ if (type === 'url') {
379
+ try {
380
+ new URL(value);
381
+ } catch {
382
+ setError(name, 'Please enter a valid URL');
383
+ return;
384
+ }
385
+ }
521
386
 
522
- /**
523
- * Collect all form data
524
- */
525
- collectFormData() {
526
- const formData = new FormData(this.form);
527
- const data = {};
387
+ // Min length validation
388
+ if ($field.hasAttribute('minlength')) {
389
+ const minLength = parseInt($field.getAttribute('minlength'), 10);
390
+ if (value.length < minLength) {
391
+ setError(name, `Must be at least ${minLength} characters`);
392
+ return;
393
+ }
394
+ }
528
395
 
396
+ // Max length validation
397
+ if ($field.hasAttribute('maxlength')) {
398
+ const maxLength = parseInt($field.getAttribute('maxlength'), 10);
399
+ if (value.length > maxLength) {
400
+ setError(name, `Must be no more than ${maxLength} characters`);
401
+ return;
402
+ }
403
+ }
529
404
 
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]);
405
+ // Min value validation (for number, range, date, etc.)
406
+ if ($field.hasAttribute('min')) {
407
+ const min = $field.getAttribute('min');
408
+ if (type === 'number' || type === 'range') {
409
+ if (parseFloat(value) < parseFloat(min)) {
410
+ setError(name, `Must be at least ${min}`);
411
+ return;
412
+ }
413
+ } else if (type === 'date' || type === 'datetime-local') {
414
+ if (new Date(value) < new Date(min)) {
415
+ setError(name, `Must be on or after ${min}`);
416
+ return;
417
+ }
540
418
  }
541
- } else {
542
- this.setNestedValue(data, key, value);
543
419
  }
544
- }
545
420
 
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);
421
+ // Max value validation
422
+ if ($field.hasAttribute('max')) {
423
+ const max = $field.getAttribute('max');
424
+ if (type === 'number' || type === 'range') {
425
+ if (parseFloat(value) > parseFloat(max)) {
426
+ setError(name, `Must be no more than ${max}`);
427
+ return;
428
+ }
429
+ } else if (type === 'date' || type === 'datetime-local') {
430
+ if (new Date(value) > new Date(max)) {
431
+ setError(name, `Must be on or before ${max}`);
432
+ return;
556
433
  }
557
434
  }
558
435
  }
559
- });
560
436
 
561
- // Handle radio buttons
562
- this.form.querySelectorAll('input[type="radio"]:checked').forEach(radio => {
563
- this.setNestedValue(data, radio.name, radio.value);
437
+ // Pattern validation
438
+ if ($field.hasAttribute('pattern')) {
439
+ const pattern = new RegExp(`^${$field.getAttribute('pattern')}$`);
440
+ if (!pattern.test(value)) {
441
+ const title = $field.getAttribute('title') || 'Please match the requested format';
442
+ setError(name, title);
443
+ return;
444
+ }
445
+ }
564
446
  });
565
-
566
- this.state.data = data;
567
- return data;
568
447
  }
569
448
 
570
449
  /**
571
- * Get field value by element or name
450
+ * Display all field errors in the DOM
572
451
  */
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;
452
+ _displayFieldErrors() {
453
+ for (const [fieldName, message] of Object.entries(this._fieldErrors)) {
454
+ this._showFieldError(fieldName, message);
593
455
  }
594
456
  }
595
457
 
596
458
  /**
597
- * Validate form data
459
+ * Show error on a specific field
598
460
  */
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);
461
+ _showFieldError(fieldName, message) {
462
+ const $field = this.$form.querySelector(`[name="${fieldName}"]`);
463
+ if (!$field) {
464
+ return;
607
465
  }
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
466
 
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
- });
467
+ // Add invalid class to field
468
+ $field.classList.add('is-invalid');
629
469
 
630
- // Check pattern validation
631
- this.form.querySelectorAll('[pattern]').forEach(field => {
632
- const value = this.getNestedValue(data, field.name);
470
+ // Find or create feedback element
471
+ let $feedback = $field.parentElement.querySelector('.invalid-feedback');
472
+ if (!$feedback) {
473
+ $feedback = document.createElement('div');
474
+ $feedback.className = 'invalid-feedback';
633
475
 
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;
476
+ // Insert after the field (or after the label for checkboxes)
477
+ if ($field.type === 'checkbox' || $field.type === 'radio') {
478
+ const $parent = $field.closest('.form-check') || $field.parentElement;
479
+ $parent.appendChild($feedback);
480
+ } else {
481
+ $field.parentElement.appendChild($feedback);
672
482
  }
673
- });
483
+ }
674
484
 
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);
485
+ $feedback.textContent = message;
486
+ $feedback.style.display = 'block';
487
+ }
678
488
 
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
- });
489
+ /**
490
+ * Clear error on a specific field
491
+ */
492
+ _clearFieldError(fieldName) {
493
+ delete this._fieldErrors[fieldName];
686
494
 
687
- // Custom validation
688
- const customValidation = new CustomEvent('validate', {
689
- detail: { data, errors },
690
- cancelable: true
691
- });
692
- this.dispatchEvent(customValidation);
495
+ const $field = this.$form.querySelector(`[name="${fieldName}"]`);
496
+ if (!$field) {
497
+ return;
498
+ }
693
499
 
694
- // Update isValid if custom validation added errors
695
- this.state.isValid = isValid;
696
- this.state.errors = errors;
500
+ $field.classList.remove('is-invalid');
697
501
 
698
- // Log
699
- /* @dev-only:start */
700
- {
701
- console.log(`[FormManager] Validation result for ${this.form.id || 'form'}`, { isValid, errors });
502
+ const $feedback = $field.parentElement.querySelector('.invalid-feedback');
503
+ if ($feedback) {
504
+ $feedback.style.display = 'none';
702
505
  }
703
- /* @dev-only:end */
506
+ }
704
507
 
705
- return { isValid, errors };
508
+ /**
509
+ * Clear all field errors
510
+ */
511
+ clearFieldErrors() {
512
+ for (const fieldName of Object.keys(this._fieldErrors)) {
513
+ this._clearFieldError(fieldName);
514
+ }
515
+ this._fieldErrors = {};
706
516
  }
707
517
 
708
518
  /**
709
- * Get field label
519
+ * Focus the first field with an error
710
520
  */
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();
521
+ _focusFirstError() {
522
+ const firstFieldName = Object.keys(this._fieldErrors)[0];
523
+ if (!firstFieldName) {
524
+ return;
716
525
  }
717
526
 
718
- // Fallback to name attribute
719
- return field.name.replace(/[_-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
527
+ const $field = this.$form.querySelector(`[name="${firstFieldName}"]`);
528
+ if ($field) {
529
+ $field.scrollIntoView({ behavior: 'smooth', block: 'center' });
530
+ $field.focus();
531
+ }
720
532
  }
721
533
 
722
534
  /**
723
- * Email validation helper
535
+ * Programmatically set field errors and display them (for use in submit handler)
724
536
  */
725
- isValidEmail(email) {
726
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
537
+ throwFieldErrors(errors) {
538
+ for (const [fieldName, message] of Object.entries(errors)) {
539
+ this._fieldErrors[fieldName] = message;
540
+ }
541
+ this._displayFieldErrors();
542
+ this._focusFirstError();
543
+ throw new Error('Validation failed');
727
544
  }
728
545
 
729
546
  /**
730
- * Show errors
547
+ * Handle beforeunload event
731
548
  */
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);
549
+ _handleBeforeUnload(e) {
550
+ if (!this._isDirty) {
551
+ return;
552
+ }
743
553
 
744
- // Track first error field for focus
745
- if (!firstErrorField) {
746
- firstErrorField = field;
747
- }
554
+ // Standard way to trigger browser's "unsaved changes" dialog
555
+ e.preventDefault();
556
+ e.returnValue = '';
557
+ }
748
558
 
749
- // Show error message
750
- const errorElement = document.createElement('div');
751
- errorElement.className = 'invalid-feedback';
752
- errorElement.textContent = error;
559
+ /**
560
+ * Handle pageshow event (bfcache restoration)
561
+ */
562
+ _handlePageShow(e) {
563
+ // Only handle if page was restored from bfcache
564
+ if (!e.persisted) {
565
+ return;
566
+ }
753
567
 
754
- // Insert after field or field group
755
- const insertAfter = field.closest('.input-group') || field;
756
- insertAfter.parentNode.insertBefore(errorElement, insertAfter.nextSibling);
757
- }
758
- });
568
+ /* @dev-only:start */
569
+ {
570
+ console.log('[Form-manager] Page restored from bfcache, current state:', this.state);
571
+ }
572
+ /* @dev-only:end */
759
573
 
760
- // Focus the first field with an error
761
- if (firstErrorField) {
762
- firstErrorField.focus();
574
+ // Reset form to ready if it was stuck in submitting state
575
+ if (this.state === 'submitting') {
576
+ this._showSpinner(false);
577
+ this.ready();
578
+ }
579
+ }
763
580
 
764
- // Scroll into view if needed
765
- firstErrorField.scrollIntoView({ behavior: 'smooth', block: 'center' });
581
+ /**
582
+ * Set dirty state
583
+ */
584
+ setDirty(dirty) {
585
+ if (this._isDirty === dirty) {
586
+ return;
766
587
  }
767
588
 
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
- });
589
+ this._isDirty = dirty;
772
590
 
773
- if (unattachedErrors.length > 0) {
774
- const errorMessage = unattachedErrors.map(([_, error]) => error).join(', ');
775
- this.showNotification(errorMessage, 'danger');
591
+ /* @dev-only:start */
592
+ {
593
+ console.log('[Form-manager] Dirty state:', dirty);
776
594
  }
595
+ /* @dev-only:end */
777
596
  }
778
597
 
779
598
  /**
780
- * Show notification (toast-style)
599
+ * Set form state
781
600
  */
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
- `;
601
+ _setState(newState) {
602
+ const previousState = this.state;
603
+ this.state = newState;
604
+ this.$form.setAttribute('data-form-state', newState);
790
605
 
791
- document.body.appendChild($notification);
606
+ /* @dev-only:start */
607
+ {
608
+ console.log('[Form-manager] State change', {
609
+ from: previousState,
610
+ to: newState,
611
+ });
612
+ }
613
+ /* @dev-only:end */
792
614
 
793
- setTimeout(() => {
794
- $notification.remove();
795
- }, 5000);
615
+ this._emit('statechange', { state: newState, previousState });
796
616
  }
797
617
 
798
618
  /**
799
- * Show single error message
619
+ * Enable/disable form controls
800
620
  */
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);
621
+ _setDisabled(disabled) {
622
+ /* @dev-only:start */
623
+ {
624
+ console.log('[Form-manager] Set disabled:', disabled);
811
625
  }
626
+ /* @dev-only:end */
812
627
 
813
- // Always use notification system
814
- this.showNotification(message, 'danger');
628
+ this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
629
+ $el.disabled = disabled;
630
+ });
815
631
  }
816
632
 
817
633
  /**
818
- * Clear all errors
634
+ * Show/hide spinner on submit buttons
819
635
  */
820
- clearErrors() {
821
- // Remove field error classes
822
- this.form.querySelectorAll(`.${this.config.fieldErrorClass}`).forEach(field => {
823
- field.classList.remove(this.config.fieldErrorClass);
636
+ _showSpinner(show) {
637
+ this.$form.querySelectorAll('button[type="submit"]').forEach(($btn) => {
638
+ if (show) {
639
+ // Store original content
640
+ $btn._originalHTML = $btn.innerHTML;
641
+ $btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${this.config.submittingText}`;
642
+ } else if ($btn._originalHTML) {
643
+ $btn.innerHTML = $btn._originalHTML;
644
+ }
824
645
  });
646
+ }
825
647
 
826
- // Remove error messages
827
- this.form.querySelectorAll('.invalid-feedback').forEach(el => el.remove());
648
+ /**
649
+ * Show submitted text on submit buttons (when allowResubmit: false)
650
+ */
651
+ _showSubmittedText() {
652
+ this.$form.querySelectorAll('button[type="submit"]').forEach(($btn) => {
653
+ const $buttonText = $btn.querySelector('.button-text');
654
+ if ($buttonText) {
655
+ $buttonText.textContent = this.config.submittedText;
656
+ } else {
657
+ $btn.textContent = this.config.submittedText;
658
+ }
659
+ });
828
660
  }
829
661
 
830
662
  /**
831
- * Clear error for a specific field
663
+ * Set nested value using dot notation (e.g., "user.address.city")
832
664
  */
833
- clearFieldError(field) {
834
- // Remove error class from field
835
- field.classList.remove(this.config.fieldErrorClass);
665
+ _setNested(obj, path, value) {
666
+ const keys = path.split('.');
667
+ const lastKey = keys.pop();
668
+ let current = obj;
836
669
 
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();
670
+ for (const key of keys) {
671
+ if (!current[key] || typeof current[key] !== 'object') {
672
+ current[key] = {};
673
+ }
674
+ current = current[key];
842
675
  }
843
676
 
844
- // Remove field from state errors
845
- if (this.state.errors && field.name) {
846
- delete this.state.errors[field.name];
677
+ // Handle multiple values (e.g., checkboxes with same name)
678
+ if (current[lastKey] !== undefined) {
679
+ if (!Array.isArray(current[lastKey])) {
680
+ current[lastKey] = [current[lastKey]];
681
+ }
682
+ current[lastKey].push(value);
683
+ } else {
684
+ current[lastKey] = value;
847
685
  }
848
686
  }
849
687
 
850
688
  /**
851
- * Show success message
689
+ * Get nested value using dot notation
852
690
  */
853
- showSuccess(message) {
854
- // Always use notification system
855
- this.showNotification(message, 'success');
691
+ _getNested(obj, path) {
692
+ return path.split('.').reduce((current, key) => {
693
+ return current && current[key] !== undefined ? current[key] : undefined;
694
+ }, obj);
856
695
  }
857
696
 
858
697
  /**
859
- * Reset form
698
+ * Collect form data as plain object (supports dot notation for nested fields)
860
699
  */
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
- }
700
+ getData() {
701
+ const formData = new FormData(this.$form);
702
+ const data = {};
869
703
 
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
- }
704
+ // Count checkboxes per name to detect groups vs single
705
+ const checkboxCounts = {};
706
+ this.$form.querySelectorAll('input[type="checkbox"]').forEach(($cb) => {
707
+ checkboxCounts[$cb.name] = (checkboxCounts[$cb.name] || 0) + 1;
708
+ });
878
709
 
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;
710
+ for (const [key, value] of formData.entries()) {
711
+ // Skip checkboxes - we handle them separately
712
+ if (checkboxCounts[key]) {
713
+ continue;
885
714
  }
886
- } else {
887
- field.value = value;
715
+ this._setNested(data, key, value);
888
716
  }
889
717
 
890
- // Update internal state data with nested field support
891
- this.setNestedValue(this.state.data, fieldName, value);
718
+ // Handle checkboxes
719
+ const processedGroups = new Set();
720
+ this.$form.querySelectorAll('input[type="checkbox"]').forEach(($cb) => {
721
+ const name = $cb.name;
722
+
723
+ // Single checkbox: true/false
724
+ if (checkboxCounts[name] === 1) {
725
+ this._setNested(data, name, $cb.checked);
726
+ return;
727
+ }
728
+
729
+ // Checkbox group: object with value: true/false (only process once per group)
730
+ if (processedGroups.has(name)) {
731
+ return;
732
+ }
733
+ processedGroups.add(name);
734
+
735
+ const values = {};
736
+ this.$form.querySelectorAll(`input[type="checkbox"][name="${name}"]`).forEach(($groupCb) => {
737
+ values[$groupCb.value] = $groupCb.checked;
738
+ });
739
+ this._setNested(data, name, values);
740
+ });
892
741
 
893
- // Trigger change event
894
- field.dispatchEvent(new Event('change', { bubbles: true }));
742
+ return data;
895
743
  }
896
744
 
897
745
  /**
898
- * Get field value from state data
746
+ * Show success message
899
747
  */
900
- getValue(fieldName) {
901
- return this.getNestedValue(this.state.data, fieldName);
748
+ showSuccess(message) {
749
+ /* @dev-only:start */
750
+ {
751
+ console.log('[Form-manager] Show success:', message);
752
+ }
753
+ /* @dev-only:end */
754
+
755
+ showNotification(message, { type: 'success' });
902
756
  }
903
757
 
904
758
  /**
905
- * Disable specific field
759
+ * Show error message
906
760
  */
907
- disableField(fieldName) {
908
- const field = this.form.querySelector(`[name="${fieldName}"]`);
909
- if (field) field.disabled = true;
761
+ showError(message) {
762
+ /* @dev-only:start */
763
+ {
764
+ console.log('[Form-manager] Show error:', message);
765
+ }
766
+ /* @dev-only:end */
767
+
768
+ showNotification(message, { type: 'danger' });
910
769
  }
911
770
 
912
771
  /**
913
- * Enable specific field
772
+ * Reset the form
914
773
  */
915
- enableField(fieldName) {
916
- const field = this.form.querySelector(`[name="${fieldName}"]`);
917
- if (field) field.disabled = false;
774
+ reset() {
775
+ /* @dev-only:start */
776
+ {
777
+ console.log('[Form-manager] reset() called');
778
+ }
779
+ /* @dev-only:end */
780
+
781
+ this.setDirty(false);
782
+ this.$form.reset();
783
+ this._setState('ready');
918
784
  }
919
785
 
920
786
  /**
921
- * Get current form data
787
+ * Check if form has unsaved changes
922
788
  */
923
- getData() {
924
- return this.collectFormData();
789
+ isDirty() {
790
+ return this._isDirty;
925
791
  }
926
792
 
927
793
  /**
928
- * Flatten nested object to dot notation
794
+ * Set form data from a nested object (supports dot notation field names)
929
795
  */
930
- flattenObject(obj, prefix = '') {
931
- const flattened = {};
796
+ setData(data) {
797
+ /* @dev-only:start */
798
+ {
799
+ console.log('[Form-manager] setData() called', data);
800
+ }
801
+ /* @dev-only:end */
932
802
 
933
- for (const [key, value] of Object.entries(obj)) {
934
- const newKey = prefix ? `${prefix}.${key}` : key;
803
+ // Flatten nested object to dot notation paths
804
+ const flatData = this._flattenObject(data);
935
805
 
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
- }
806
+ // Set each field value
807
+ for (const [path, value] of Object.entries(flatData)) {
808
+ this._setFieldValue(path, value);
942
809
  }
943
-
944
- return flattened;
945
810
  }
946
811
 
947
812
  /**
948
- * Set form values programmatically
949
- * @param {Object} values - Object with field names as keys and values to set (supports nested objects)
813
+ * Flatten a nested object to dot notation paths
950
814
  */
951
- setValues(values) {
952
- if (!values || typeof values !== 'object') return;
815
+ _flattenObject(obj, prefix = '') {
816
+ const result = {};
953
817
 
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`);
818
+ for (const [key, value] of Object.entries(obj)) {
819
+ const path = prefix ? `${prefix}.${key}` : key;
963
820
 
964
- if (!element) return;
821
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
822
+ // Check if this is a checkbox group (object with boolean values)
823
+ const isCheckboxGroup = Object.values(value).every((v) => typeof v === 'boolean');
965
824
 
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;
825
+ if (isCheckboxGroup) {
826
+ // Keep as object for checkbox group handling
827
+ result[path] = value;
828
+ } else {
829
+ // Recurse into nested object
830
+ Object.assign(result, this._flattenObject(value, path));
984
831
  }
985
832
  } else {
986
- // For text inputs, textareas, etc.
987
- element.value = value || '';
833
+ result[path] = value;
988
834
  }
835
+ }
989
836
 
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();
837
+ return result;
997
838
  }
998
839
 
999
840
  /**
1000
- * Get form state
841
+ * Set a single field value by name (supports dot notation)
1001
842
  */
1002
- getState() {
1003
- return { ...this.state };
1004
- }
843
+ _setFieldValue(name, value) {
844
+ const $fields = this.$form.querySelectorAll(`[name="${name}"]`);
1005
845
 
1006
- /**
1007
- * Check if form is dirty
1008
- */
1009
- isDirty() {
1010
- return this.state.isDirty;
1011
- }
846
+ if ($fields.length === 0) {
847
+ /* @dev-only:start */
848
+ {
849
+ console.log('[Form-manager] setData: field not found:', name);
850
+ }
851
+ /* @dev-only:end */
852
+ return;
853
+ }
1012
854
 
1013
- /**
1014
- * Check if form is valid
1015
- */
1016
- isValid() {
1017
- const validation = this.validate(this.collectFormData());
1018
- return validation.isValid;
855
+ const $field = $fields[0];
856
+ const type = $field.type;
857
+
858
+ // Handle different input types
859
+ if (type === 'checkbox') {
860
+ if ($fields.length === 1) {
861
+ // Single checkbox: boolean value
862
+ $field.checked = !!value;
863
+ } else if (typeof value === 'object') {
864
+ // Checkbox group: object with value: boolean
865
+ $fields.forEach(($cb) => {
866
+ $cb.checked = !!value[$cb.value];
867
+ });
868
+ }
869
+ } else if (type === 'radio') {
870
+ // Radio group: set the one with matching value
871
+ $fields.forEach(($radio) => {
872
+ $radio.checked = $radio.value === value;
873
+ });
874
+ } else if ($field.tagName === 'SELECT') {
875
+ // Select: set value
876
+ $field.value = value;
877
+ } else {
878
+ // Text, email, textarea, etc.
879
+ $field.value = value;
880
+ }
881
+
882
+ /* @dev-only:start */
883
+ {
884
+ console.log('[Form-manager] setData: set field', { name, value, type });
885
+ }
886
+ /* @dev-only:end */
1019
887
  }
1020
888
  }