@omega.js/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,1577 @@
1
+ /**
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
+ * });
12
+ */
13
+
14
+ // Libraries
15
+ import { ready as domReady } from './dom.js';
16
+ import omega from '../index.js';
17
+ import { createLogger } from './logger.js';
18
+
19
+ const logger = createLogger('form-manager');
20
+
21
+ // Constants
22
+ const HONEYPOT_SELECTOR = '[data-honey], [name="honey"]';
23
+
24
+ // Shared beforeunload handler (registered once, checks all instances)
25
+ const _instances = new Set();
26
+ let _beforeUnloadRegistered = false;
27
+
28
+ function _sharedBeforeUnloadHandler(e) {
29
+ for (const instance of _instances) {
30
+ // A form torn out of the DOM (view swap, modal teardown) must not keep
31
+ // blocking navigation with a stale dirty flag
32
+ if (!instance.$form.isConnected) {
33
+ continue;
34
+ }
35
+ if (instance.config.warnOnUnsavedChanges && instance._isDirty) {
36
+ e.preventDefault();
37
+ e.returnValue = '';
38
+ return;
39
+ }
40
+ }
41
+ }
42
+
43
+ export class FormManager {
44
+ constructor(selector, options = {}) {
45
+ // Get form element
46
+ this.$form = typeof selector === 'string'
47
+ ? document.querySelector(selector)
48
+ : selector;
49
+
50
+ if (!this.$form) {
51
+ throw new Error(`FormManager: Form not found: ${selector}`);
52
+ }
53
+
54
+ // Configuration
55
+ this.config = {
56
+ autoReady: true, // Auto-transition to initialState when DOM is ready
57
+ initialState: 'ready', // State to transition to when autoReady fires
58
+ allowResubmit: true, // Allow resubmission after success (false = go to 'submitted' state)
59
+ resetOnSuccess: false, // Clear form fields after successful submission
60
+ warnOnUnsavedChanges: true, // Warn user before leaving page with unsaved changes
61
+ submittingText: 'Processing...', // Text shown on submit button during submission
62
+ submittedText: 'Processed!', // Text shown on submit button after submission (when allowResubmit: false)
63
+ inputGroup: null, // Filter getData() to only include fields with matching data-input-group (null = all fields)
64
+ ...options,
65
+ };
66
+
67
+ // State
68
+ this.state = 'initializing';
69
+ this._isDirty = false;
70
+ this._permanentlyDisabled = new Set();
71
+
72
+ // Gates the form must wait on before ready() may arm it (see addGate)
73
+ this._gates = new Set();
74
+ this._readyPending = false;
75
+
76
+ // Event listeners
77
+ this._listeners = {
78
+ change: [],
79
+ validation: [],
80
+ submit: [],
81
+ statechange: [],
82
+ honeypot: [],
83
+ };
84
+
85
+ // Field errors (populated during validation)
86
+ this._fieldErrors = {};
87
+
88
+ // Track this instance for shared beforeunload handler
89
+ _instances.add(this);
90
+
91
+ /* @dev-only:start */
92
+ {
93
+ logger.log('Initialized', {
94
+ selector: typeof selector === 'string' ? selector : this.$form.id || this.$form,
95
+ config: this.config,
96
+ });
97
+ }
98
+ /* @dev-only:end */
99
+
100
+ // Initialize
101
+ this._init();
102
+ }
103
+
104
+ /**
105
+ * Initialize the form manager
106
+ */
107
+ _init() {
108
+ // Set the form state attribute for debugging and consumer CSS hooks.
109
+ this.$form.setAttribute('data-form-state', this.state);
110
+
111
+ // Snapshot elements that are disabled in HTML markup. These are
112
+ // business-logic disabled (e.g. "coming soon" options) and must stay
113
+ // disabled through every state transition. Submit buttons are excluded
114
+ // — disabled submit buttons in HTML are loading guards that FM takes over.
115
+ this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
116
+ if ($el.disabled && $el.type !== 'submit') {
117
+ this._permanentlyDisabled.add($el);
118
+ }
119
+ });
120
+
121
+ // Attach submit handler
122
+ this.$form.addEventListener('submit', (e) => this._handleSubmit(e));
123
+
124
+ // Attach change handlers
125
+ this.$form.addEventListener('input', (e) => this._handleChange(e));
126
+ this.$form.addEventListener('change', (e) => this._handleChange(e));
127
+
128
+ // Register shared beforeunload handler once (covers all instances)
129
+ if (!_beforeUnloadRegistered) {
130
+ _beforeUnloadRegistered = true;
131
+ window.addEventListener('beforeunload', _sharedBeforeUnloadHandler);
132
+ }
133
+
134
+ // Handle page restored from bfcache (e.g., back button after OAuth redirect)
135
+ this._pageShowHandler = (e) => this._handlePageShow(e);
136
+ window.addEventListener('pageshow', this._pageShowHandler);
137
+
138
+ // Initialize file drop zones
139
+ this._initFileDropZones();
140
+
141
+ // Warn about fields missing name attributes (they will be invisible to validation and getData)
142
+ /* @dev-only:start */
143
+ {
144
+ this.$form.querySelectorAll('input, select, textarea').forEach(($field) => {
145
+ if (!$field.name && !$field.matches(HONEYPOT_SELECTOR) && $field.type !== 'hidden') {
146
+ logger.warn('Field missing "name" attribute — will be skipped by validation and getData():', $field);
147
+ }
148
+ });
149
+ }
150
+ /* @dev-only:end */
151
+
152
+ // Auto-populate form fields from URL query parameters
153
+ this._populateFromQueryParams();
154
+
155
+ // Auto-transition to initialState when DOM is ready
156
+ if (this.config.autoReady) {
157
+ domReady().then(() => this._setInitialState());
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Register event listener
163
+ */
164
+ on(event, callback) {
165
+ if (!this._listeners[event]) {
166
+ this._listeners[event] = [];
167
+ }
168
+ this._listeners[event].push(callback);
169
+ return this; // Allow chaining
170
+ }
171
+
172
+ /**
173
+ * Emit event to all listeners
174
+ */
175
+ async _emit(event, data) {
176
+ const listeners = this._listeners[event] || [];
177
+ for (const callback of listeners) {
178
+ await callback(data);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Auto-populate form fields from URL query parameters
184
+ * Matches query param keys to field names (supports dot notation)
185
+ */
186
+ _populateFromQueryParams() {
187
+ const params = new URLSearchParams(window.location.search);
188
+
189
+ if (params.size === 0) {
190
+ return;
191
+ }
192
+
193
+ const data = {};
194
+
195
+ for (const [key, value] of params) {
196
+ // Skip tracking/UTM params and common non-form params
197
+ if (
198
+ key.startsWith('utm_')
199
+ || key.startsWith('itm_')
200
+ || key === 'cb'
201
+ || key === 'fbclid'
202
+ || key === 'gclid'
203
+ ) {
204
+ continue;
205
+ }
206
+
207
+ // Only populate if a matching field exists in the form
208
+ const $field = this.$form.querySelector(`[name="${key}"]`);
209
+ if (!$field) {
210
+ continue;
211
+ }
212
+
213
+ data[key] = value;
214
+ }
215
+
216
+ if (Object.keys(data).length > 0) {
217
+ this.setData(data);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Set initial state based on config
223
+ */
224
+ _setInitialState() {
225
+ const state = this.config.initialState;
226
+
227
+ /* @dev-only:start */
228
+ {
229
+ logger.log('DOM ready, setting initial state:', state);
230
+ }
231
+ /* @dev-only:end */
232
+
233
+ if (state === 'ready') {
234
+ this.ready();
235
+ } else {
236
+ this._setState(state);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Hold ready() open until a named answer lands. A page whose submit control
242
+ * must not arm until an async answer is in (checkout waits on trial
243
+ * eligibility and reCAPTCHA) registers one gate per answer BEFORE it calls
244
+ * ready(): the form stays `initializing` with its submit controls disabled,
245
+ * and the LAST gate to resolve is what runs the ready() the page asked for.
246
+ *
247
+ * Gates belong to the initializing window only. Adding one to a form the
248
+ * user can already submit would disable a live control behind their back,
249
+ * so it fails loudly instead.
250
+ */
251
+ addGate(name) {
252
+ if (this.state !== 'initializing') {
253
+ throw new Error(`FormManager: addGate("${name}") after the form left initializing — gates are registered before ready() (state: ${this.state})`);
254
+ }
255
+
256
+ this._gates.add(name);
257
+ this._setSubmitDisabled(true);
258
+
259
+ /* @dev-only:start */
260
+ {
261
+ logger.log('Gate added:', name, [...this._gates]);
262
+ }
263
+ /* @dev-only:end */
264
+
265
+ return this;
266
+ }
267
+
268
+ /**
269
+ * Mark a gate answered. When it is the last one and ready() was already
270
+ * asked for, the form arms now.
271
+ *
272
+ * A name that opened no gate is a programmer error, not a no-op: a typo
273
+ * would silently leave the form gated forever, and a second resolve of the
274
+ * same gate means two callers each believe they own it.
275
+ */
276
+ resolveGate(name) {
277
+ if (!this._gates.delete(name)) {
278
+ throw new Error(`FormManager: resolveGate("${name}") names no open gate — it was never added, or it already resolved`);
279
+ }
280
+
281
+ /* @dev-only:start */
282
+ {
283
+ logger.log('Gate resolved:', name, 'remaining:', [...this._gates]);
284
+ }
285
+ /* @dev-only:end */
286
+
287
+ if (this._gates.size === 0 && this._readyPending) {
288
+ this._readyPending = false;
289
+ this.ready();
290
+ }
291
+
292
+ return this;
293
+ }
294
+
295
+ /**
296
+ * Transition to ready state
297
+ */
298
+ ready() {
299
+ /* @dev-only:start */
300
+ {
301
+ logger.log('ready() called');
302
+ }
303
+ /* @dev-only:end */
304
+
305
+ // A gate still open holds the form where it is — resolveGate() calls this
306
+ // again once the last answer lands.
307
+ if (this._gates.size > 0) {
308
+ this._readyPending = true;
309
+
310
+ /* @dev-only:start */
311
+ {
312
+ logger.log('ready() held by gates:', [...this._gates]);
313
+ }
314
+ /* @dev-only:end */
315
+
316
+ return;
317
+ }
318
+
319
+ this._setState('ready');
320
+ this._setDisabled(false);
321
+
322
+ // Focus the field with autofocus attribute if it exists (desktop only)
323
+ const $autofocusField = this.$form.querySelector('[autofocus]');
324
+ if ($autofocusField && !$autofocusField.disabled && omega.utilities().getDevice() === 'desktop') {
325
+ this._focusField($autofocusField);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Handle form submission
331
+ */
332
+ async _handleSubmit(e) {
333
+ // Always prevent default - this is the whole point
334
+ e.preventDefault();
335
+
336
+ // Ignore if not ready
337
+ if (this.state !== 'ready') {
338
+ /* @dev-only:start */
339
+ {
340
+ logger.log('Submit ignored, not ready. Current state:', this.state);
341
+ }
342
+ /* @dev-only:end */
343
+ return;
344
+ }
345
+
346
+ // Get the submit button that was clicked (native browser API)
347
+ const $submitButton = e.submitter;
348
+
349
+ // Collect form data BEFORE disabling (disabled elements aren't in FormData)
350
+ const data = this.getData();
351
+
352
+ // Clear previous field errors
353
+ this.clearFieldErrors();
354
+
355
+ // Run validation BEFORE transitioning to submitting state
356
+ const validationPassed = await this._runValidation(data, $submitButton);
357
+ if (!validationPassed) {
358
+ return;
359
+ }
360
+
361
+ // Transition to submitting
362
+ this._setState('submitting');
363
+ this._setDisabled(true);
364
+ this._showSpinner(true);
365
+
366
+ /* @dev-only:start */
367
+ {
368
+ logger.log('Submitting', {
369
+ data,
370
+ submitButton: $submitButton?.name ? `${$submitButton.name}=${$submitButton.value}` : null,
371
+ });
372
+ }
373
+ /* @dev-only:end */
374
+
375
+ try {
376
+ // Let consumers handle the submission
377
+ await this._emit('submit', { data, $submitButton });
378
+
379
+ /* @dev-only:start */
380
+ {
381
+ logger.log('Submit success', {
382
+ resetOnSuccess: this.config.resetOnSuccess,
383
+ allowResubmit: this.config.allowResubmit,
384
+ });
385
+ }
386
+ /* @dev-only:end */
387
+
388
+ // Success - clear dirty state
389
+ this.setDirty(false);
390
+ this._showSpinner(false);
391
+
392
+ if (this.config.resetOnSuccess) {
393
+ this.$form.reset();
394
+ }
395
+
396
+ if (this.config.allowResubmit) {
397
+ this._setState('ready');
398
+ this._setDisabled(false);
399
+ } else {
400
+ this._setState('submitted');
401
+ this._showSubmittedText();
402
+ // Stay disabled - no more submissions allowed
403
+ }
404
+ } catch (error) {
405
+ /* @dev-only:start */
406
+ {
407
+ logger.log('Submit error:', error.message);
408
+ }
409
+ /* @dev-only:end */
410
+
411
+ // Error - go back to ready and show error
412
+ this._setState('ready');
413
+ this._setDisabled(false);
414
+ this._showSpinner(false);
415
+ this.showError(error.message || 'An error occurred');
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Handle input changes
421
+ */
422
+ _handleChange(e) {
423
+ // Mark form as dirty
424
+ this.setDirty(true);
425
+
426
+ const data = this.getData();
427
+
428
+ /* @dev-only:start */
429
+ {
430
+ logger.log('Change', {
431
+ name: e.target.name,
432
+ value: e.target.value,
433
+ data,
434
+ });
435
+ }
436
+ /* @dev-only:end */
437
+
438
+ this._emit('change', {
439
+ field: e.target,
440
+ name: e.target.name,
441
+ value: e.target.value,
442
+ data,
443
+ });
444
+
445
+ // Clear field error when user types in that field
446
+ if (this._fieldErrors[e.target.name]) {
447
+ this._clearFieldError(e.target.name);
448
+ }
449
+
450
+ // Clear file drop error when the file input inside a drop zone changes
451
+ const $zone = e.target.closest('[data-file-drop]');
452
+ if ($zone) {
453
+ $zone.classList.remove('file-drop-error');
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Run validation (HTML5 + custom validation event)
459
+ * Returns true if validation passed, false if there are errors
460
+ */
461
+ async _runValidation(data, $submitButton) {
462
+ /* @dev-only:start */
463
+ {
464
+ logger.log('Running validation');
465
+ }
466
+ /* @dev-only:end */
467
+
468
+ // 0. Check honeypot fields first (bot detection)
469
+ if (this._isHoneypotFilled()) {
470
+ /* @dev-only:start */
471
+ {
472
+ logger.log('Honeypot triggered - rejecting submission');
473
+ }
474
+ /* @dev-only:end */
475
+
476
+ // Emit honeypot event for tracking
477
+ this._emit('honeypot', { data });
478
+
479
+ this.showError('Something went wrong. Please try again.');
480
+ return false;
481
+ }
482
+
483
+ // Create setError helper for custom validation
484
+ const setError = (fieldName, message) => {
485
+ this._fieldErrors[fieldName] = message;
486
+ };
487
+
488
+ // 1. Run automatic HTML5 validation
489
+ this._runHTML5Validation(setError);
490
+
491
+ // 2. Run custom validation listeners
492
+ await this._emit('validation', { data, setError, $submitButton });
493
+
494
+ // 3. Check if there are any errors
495
+ const errorCount = Object.keys(this._fieldErrors).length;
496
+ if (errorCount > 0) {
497
+ /* @dev-only:start */
498
+ {
499
+ logger.log('Validation failed:', this._fieldErrors);
500
+ }
501
+ /* @dev-only:end */
502
+
503
+ // Display all field errors
504
+ this._displayFieldErrors();
505
+
506
+ // Focus first error field
507
+ this._focusFirstError();
508
+
509
+ return false;
510
+ }
511
+
512
+ /* @dev-only:start */
513
+ {
514
+ logger.log('Validation passed');
515
+ }
516
+ /* @dev-only:end */
517
+
518
+ return true;
519
+ }
520
+
521
+ /**
522
+ * Run HTML5 constraint validation on all form fields
523
+ */
524
+ _runHTML5Validation(setError) {
525
+ const $fields = this.$form.querySelectorAll('input, select, textarea');
526
+
527
+ $fields.forEach(($field) => {
528
+ const name = $field.name;
529
+ if (!name) {
530
+ return;
531
+ }
532
+
533
+ // Skip if field is not in current input group (respects setInputGroup filter)
534
+ if (!this._isFieldInGroup($field)) {
535
+ return;
536
+ }
537
+
538
+ // Skip if already has an error (from previous validation)
539
+ if (this._fieldErrors[name]) {
540
+ return;
541
+ }
542
+
543
+ const value = $field.value;
544
+ const type = $field.type;
545
+
546
+ // Required validation
547
+ if ($field.hasAttribute('required')) {
548
+ if (type === 'checkbox' && !$field.checked) {
549
+ setError(name, 'This field is required');
550
+ return;
551
+ }
552
+ if (type === 'radio') {
553
+ // Radio groups: check if any radio in the group is checked
554
+ const $checked = this.$form.querySelector(`input[name="${name}"]:checked`);
555
+ if (!$checked) {
556
+ setError(name, 'This field is required');
557
+ }
558
+ return;
559
+ }
560
+ if (!value || !value.trim()) {
561
+ setError(name, 'This field is required');
562
+ return;
563
+ }
564
+ }
565
+
566
+ // Skip further validation if empty and not required
567
+ if (!value) {
568
+ return;
569
+ }
570
+
571
+ // Email validation
572
+ if (type === 'email') {
573
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
574
+ if (!emailPattern.test(value)) {
575
+ setError(name, 'Please enter a valid email address');
576
+ return;
577
+ }
578
+ }
579
+
580
+ // URL validation
581
+ if (type === 'url') {
582
+ try {
583
+ new URL(value);
584
+ } catch {
585
+ setError(name, 'Please enter a valid URL');
586
+ return;
587
+ }
588
+ }
589
+
590
+ // Min length validation
591
+ if ($field.hasAttribute('minlength')) {
592
+ const minLength = parseInt($field.getAttribute('minlength'), 10);
593
+ if (value.length < minLength) {
594
+ setError(name, `Must be at least ${minLength} characters`);
595
+ return;
596
+ }
597
+ }
598
+
599
+ // Max length validation
600
+ if ($field.hasAttribute('maxlength')) {
601
+ const maxLength = parseInt($field.getAttribute('maxlength'), 10);
602
+ if (value.length > maxLength) {
603
+ setError(name, `Must be no more than ${maxLength} characters`);
604
+ return;
605
+ }
606
+ }
607
+
608
+ // Min value validation (for number, range, date, etc.)
609
+ if ($field.hasAttribute('min')) {
610
+ const min = $field.getAttribute('min');
611
+ if (type === 'number' || type === 'range') {
612
+ if (parseFloat(value) < parseFloat(min)) {
613
+ setError(name, `Must be at least ${min}`);
614
+ return;
615
+ }
616
+ } else if (type === 'date' || type === 'datetime-local') {
617
+ if (new Date(value) < new Date(min)) {
618
+ setError(name, `Must be on or after ${min}`);
619
+ return;
620
+ }
621
+ }
622
+ }
623
+
624
+ // Max value validation
625
+ if ($field.hasAttribute('max')) {
626
+ const max = $field.getAttribute('max');
627
+ if (type === 'number' || type === 'range') {
628
+ if (parseFloat(value) > parseFloat(max)) {
629
+ setError(name, `Must be no more than ${max}`);
630
+ return;
631
+ }
632
+ } else if (type === 'date' || type === 'datetime-local') {
633
+ if (new Date(value) > new Date(max)) {
634
+ setError(name, `Must be on or before ${max}`);
635
+ return;
636
+ }
637
+ }
638
+ }
639
+
640
+ // Pattern validation
641
+ if ($field.hasAttribute('pattern')) {
642
+ const pattern = new RegExp(`^${$field.getAttribute('pattern')}$`);
643
+ if (!pattern.test(value)) {
644
+ const title = $field.getAttribute('title') || 'Please match the requested format';
645
+ setError(name, title);
646
+ return;
647
+ }
648
+ }
649
+ });
650
+ }
651
+
652
+ /**
653
+ * Display all field errors in the DOM
654
+ */
655
+ _displayFieldErrors() {
656
+ for (const [fieldName, message] of Object.entries(this._fieldErrors)) {
657
+ this._showFieldError(fieldName, message);
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Show error on a specific field
663
+ */
664
+ _showFieldError(fieldName, message) {
665
+ const $field = this.$form.querySelector(`[name="${fieldName}"]`);
666
+ if (!$field) {
667
+ return;
668
+ }
669
+
670
+ // Radio groups: show error text under the last radio without highlighting
671
+ if ($field.type === 'radio') {
672
+ const $radios = this.$form.querySelectorAll(`[name="${fieldName}"]`);
673
+ const $last = $radios[$radios.length - 1];
674
+
675
+ const $parent = $last.closest('.form-check') || $last.parentElement;
676
+ let $feedback = $parent.querySelector('.invalid-feedback');
677
+ if (!$feedback) {
678
+ $feedback = document.createElement('div');
679
+ $feedback.className = 'invalid-feedback';
680
+ $parent.appendChild($feedback);
681
+ }
682
+
683
+ $feedback.textContent = message;
684
+ $feedback.style.display = 'block';
685
+ return;
686
+ }
687
+
688
+ // Add invalid class to field
689
+ $field.classList.add('is-invalid');
690
+
691
+ // Bootstrap requires `.has-validation` on the wrapping `.input-group` so the
692
+ // trailing element (e.g. a password-visibility toggle button) keeps its
693
+ // border-radius once a sibling `.invalid-feedback` is rendered. Without
694
+ // this, the appended feedback makes the trailing button no longer
695
+ // `:last-child` and Bootstrap strips its right corners to 0.
696
+ const $inputGroup = $field.closest('.input-group');
697
+ if ($inputGroup) {
698
+ $inputGroup.classList.add('has-validation');
699
+ }
700
+
701
+ // Find or create feedback element
702
+ let $feedback = $field.parentElement.querySelector('.invalid-feedback');
703
+ if (!$feedback) {
704
+ $feedback = document.createElement('div');
705
+ $feedback.className = 'invalid-feedback';
706
+
707
+ // Insert after the field (or after the label for checkboxes)
708
+ if ($field.type === 'checkbox') {
709
+ const $parent = $field.closest('.form-check') || $field.parentElement;
710
+ $parent.appendChild($feedback);
711
+ } else {
712
+ $field.parentElement.appendChild($feedback);
713
+ }
714
+ }
715
+
716
+ $feedback.textContent = message;
717
+ $feedback.style.display = 'block';
718
+ }
719
+
720
+ /**
721
+ * Clear error on a specific field
722
+ */
723
+ _clearFieldError(fieldName) {
724
+ delete this._fieldErrors[fieldName];
725
+
726
+ const $field = this.$form.querySelector(`[name="${fieldName}"]`);
727
+ if (!$field) {
728
+ return;
729
+ }
730
+
731
+ // Radio groups: clear the error text under the last radio
732
+ if ($field.type === 'radio') {
733
+ const $radios = this.$form.querySelectorAll(`[name="${fieldName}"]`);
734
+ const $last = $radios[$radios.length - 1];
735
+
736
+ const $parent = $last.closest('.form-check') || $last.parentElement;
737
+ const $feedback = $parent.querySelector('.invalid-feedback');
738
+ if ($feedback) {
739
+ $feedback.style.display = 'none';
740
+ }
741
+ return;
742
+ }
743
+
744
+ $field.classList.remove('is-invalid');
745
+
746
+ const $feedback = $field.parentElement.querySelector('.invalid-feedback');
747
+ if ($feedback) {
748
+ $feedback.style.display = 'none';
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Clear all field errors
754
+ */
755
+ clearFieldErrors() {
756
+ for (const fieldName of Object.keys(this._fieldErrors)) {
757
+ this._clearFieldError(fieldName);
758
+ }
759
+ this._fieldErrors = {};
760
+
761
+ // Clear file drop error states
762
+ this.$form.querySelectorAll('[data-file-drop].file-drop-error').forEach(($zone) => {
763
+ $zone.classList.remove('file-drop-error');
764
+ });
765
+ }
766
+
767
+ /**
768
+ * Focus the first field with an error
769
+ */
770
+ _focusFirstError() {
771
+ const firstFieldName = Object.keys(this._fieldErrors)[0];
772
+ if (!firstFieldName) {
773
+ return;
774
+ }
775
+
776
+ this._focusField(firstFieldName);
777
+ }
778
+
779
+ /**
780
+ * Scroll to and focus a field if it exists
781
+ * @param {HTMLElement|string} field - Field element or field name
782
+ */
783
+ _focusField(field) {
784
+ // Resolve field element from name if string provided
785
+ const $field = typeof field === 'string'
786
+ ? this.$form.querySelector(`[name="${field}"]`)
787
+ : field;
788
+
789
+ if (!$field) {
790
+ return;
791
+ }
792
+
793
+ $field.scrollIntoView({ behavior: 'smooth', block: 'center' });
794
+ $field.focus();
795
+
796
+ // Move cursor to end of input if it has existing text
797
+ // Disabled because throws errors on some inputs (eg email)
798
+ // if (typeof $autofocusField.setSelectionRange === 'function') {
799
+ // const len = $autofocusField.value.length;
800
+ // $autofocusField.setSelectionRange(len, len);
801
+ // }
802
+ }
803
+
804
+
805
+
806
+ /**
807
+ * Programmatically set field errors and display them (for use in submit handler)
808
+ */
809
+ throwFieldErrors(errors) {
810
+ for (const [fieldName, message] of Object.entries(errors)) {
811
+ this._fieldErrors[fieldName] = message;
812
+ }
813
+ this._displayFieldErrors();
814
+ this._focusFirstError();
815
+ throw new Error('Validation failed');
816
+ }
817
+
818
+ /**
819
+ * Handle pageshow event (bfcache restoration)
820
+ */
821
+ _handlePageShow(e) {
822
+ // Only handle if page was restored from bfcache
823
+ if (!e.persisted) {
824
+ return;
825
+ }
826
+
827
+ /* @dev-only:start */
828
+ {
829
+ logger.log('Page restored from bfcache, current state:', this.state);
830
+ }
831
+ /* @dev-only:end */
832
+
833
+ // Reset form to ready if it was stuck in submitting state
834
+ if (this.state === 'submitting') {
835
+ this._showSpinner(false);
836
+ this.ready();
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Set dirty state
842
+ */
843
+ setDirty(dirty) {
844
+ if (this._isDirty === dirty) {
845
+ return;
846
+ }
847
+
848
+ this._isDirty = dirty;
849
+
850
+ /* @dev-only:start */
851
+ {
852
+ logger.log('Dirty state:', dirty);
853
+ }
854
+ /* @dev-only:end */
855
+ }
856
+
857
+ /**
858
+ * Set form state
859
+ */
860
+ _setState(newState) {
861
+ const previousState = this.state;
862
+ this.state = newState;
863
+ this.$form.setAttribute('data-form-state', newState);
864
+
865
+ /* @dev-only:start */
866
+ {
867
+ logger.log('State change', {
868
+ from: previousState,
869
+ to: newState,
870
+ });
871
+ }
872
+ /* @dev-only:end */
873
+
874
+ this._emit('statechange', { state: newState, previousState });
875
+ }
876
+
877
+ /**
878
+ * Enable/disable form controls. Elements snapshotted as permanently
879
+ * disabled during _init() are never re-enabled.
880
+ */
881
+ _setDisabled(disabled) {
882
+ /* @dev-only:start */
883
+ {
884
+ logger.log('Set disabled:', disabled);
885
+ }
886
+ /* @dev-only:end */
887
+
888
+ this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
889
+ if (this._permanentlyDisabled.has($el)) {
890
+ $el.disabled = true;
891
+ return;
892
+ }
893
+ $el.disabled = disabled;
894
+ });
895
+ }
896
+
897
+ /**
898
+ * Disable/enable the submit controls alone — what a gate guards. The rest of
899
+ * the form stays live so the page can still be read and its options changed
900
+ * while the answer the gate waits for is in flight.
901
+ */
902
+ _setSubmitDisabled(disabled) {
903
+ this._getSubmitButtons().forEach(($btn) => {
904
+ $btn.disabled = disabled;
905
+ });
906
+ }
907
+
908
+ /**
909
+ * Get all submit buttons in the form
910
+ * Note: Uses button.type property instead of [type="submit"] selector
911
+ * because HTML minifiers may strip the attribute (it's the default)
912
+ */
913
+ _getSubmitButtons() {
914
+ return Array.from(this.$form.querySelectorAll('button')).filter($btn => $btn.type === 'submit');
915
+ }
916
+
917
+ /**
918
+ * Show/hide spinner on submit buttons
919
+ */
920
+ _showSpinner(show) {
921
+ this._getSubmitButtons().forEach(($btn) => {
922
+ if (show) {
923
+ // Store original content
924
+ $btn._originalHTML = $btn.innerHTML;
925
+ const text = this.config.submittingText;
926
+ $btn.innerHTML = text
927
+ ? `<span class="spinner-border spinner-border-sm me-2"></span>${omega.utilities().escapeHTML(text)}`
928
+ : '<span class="spinner-border spinner-border-sm"></span>';
929
+ } else if ($btn._originalHTML) {
930
+ $btn.innerHTML = $btn._originalHTML;
931
+ }
932
+ });
933
+ }
934
+
935
+ /**
936
+ * Show submitted text on submit buttons (when allowResubmit: false)
937
+ */
938
+ _showSubmittedText() {
939
+ this._getSubmitButtons().forEach(($btn) => {
940
+ const $buttonText = $btn.querySelector('.button-text');
941
+ if ($buttonText) {
942
+ $buttonText.textContent = this.config.submittedText;
943
+ } else {
944
+ $btn.textContent = this.config.submittedText;
945
+ }
946
+ });
947
+ }
948
+
949
+ /**
950
+ * Set nested value using dot notation (e.g., "user.address.city")
951
+ */
952
+ _setNested(obj, path, value) {
953
+ const keys = path.split('.');
954
+ const lastKey = keys.pop();
955
+ let current = obj;
956
+
957
+ for (const key of keys) {
958
+ if (!current[key] || typeof current[key] !== 'object') {
959
+ current[key] = {};
960
+ }
961
+ current = current[key];
962
+ }
963
+
964
+ // Handle multiple values (e.g., checkboxes with same name)
965
+ if (current[lastKey] !== undefined) {
966
+ if (!Array.isArray(current[lastKey])) {
967
+ current[lastKey] = [current[lastKey]];
968
+ }
969
+ current[lastKey].push(value);
970
+ } else {
971
+ current[lastKey] = value;
972
+ }
973
+ }
974
+
975
+ /**
976
+ * Get nested value using dot notation
977
+ */
978
+ _getNested(obj, path) {
979
+ return path.split('.').reduce((current, key) => {
980
+ return current && current[key] !== undefined ? current[key] : undefined;
981
+ }, obj);
982
+ }
983
+
984
+ /**
985
+ * Collect form data as plain object (supports dot notation for nested fields)
986
+ * Respects inputGroup filter when set - only includes fields matching the group
987
+ */
988
+ getData() {
989
+ const data = {};
990
+
991
+ // Get all form fields
992
+ const $fields = this.$form.querySelectorAll('input, select, textarea');
993
+
994
+ // Count checkboxes per name to detect groups vs single (only for fields in group)
995
+ const checkboxCounts = {};
996
+ $fields.forEach(($field) => {
997
+ if ($field.type === 'checkbox' && this._isFieldInGroup($field)) {
998
+ checkboxCounts[$field.name] = (checkboxCounts[$field.name] || 0) + 1;
999
+ }
1000
+ });
1001
+
1002
+ // Process non-checkbox fields
1003
+ $fields.forEach(($field) => {
1004
+ const name = $field.name;
1005
+
1006
+ // Skip fields without name
1007
+ if (!name) {
1008
+ return;
1009
+ }
1010
+
1011
+ // Skip if field is not in current input group
1012
+ if (!this._isFieldInGroup($field)) {
1013
+ return;
1014
+ }
1015
+
1016
+ // Skip honeypot fields (should never be in form data)
1017
+ if ($field.matches(HONEYPOT_SELECTOR)) {
1018
+ return;
1019
+ }
1020
+
1021
+ // Skip checkboxes - we handle them separately
1022
+ if ($field.type === 'checkbox') {
1023
+ return;
1024
+ }
1025
+
1026
+ // Skip radio buttons that aren't checked
1027
+ if ($field.type === 'radio' && !$field.checked) {
1028
+ return;
1029
+ }
1030
+
1031
+ this._setNested(data, name, $field.value);
1032
+ });
1033
+
1034
+ // Handle checkboxes
1035
+ const processedGroups = new Set();
1036
+ $fields.forEach(($cb) => {
1037
+ if ($cb.type !== 'checkbox') {
1038
+ return;
1039
+ }
1040
+
1041
+ const name = $cb.name;
1042
+
1043
+ // Skip if field is not in current input group
1044
+ if (!this._isFieldInGroup($cb)) {
1045
+ return;
1046
+ }
1047
+
1048
+ // Single checkbox: true/false
1049
+ if (checkboxCounts[name] === 1) {
1050
+ this._setNested(data, name, $cb.checked);
1051
+ return;
1052
+ }
1053
+
1054
+ // Checkbox group: object with value: true/false (only process once per group)
1055
+ if (processedGroups.has(name)) {
1056
+ return;
1057
+ }
1058
+ processedGroups.add(name);
1059
+
1060
+ const values = {};
1061
+ this.$form.querySelectorAll(`input[type="checkbox"][name="${name}"]`).forEach(($groupCb) => {
1062
+ // Only include checkboxes that are in the group
1063
+ if (this._isFieldInGroup($groupCb)) {
1064
+ values[$groupCb.value] = $groupCb.checked;
1065
+ }
1066
+ });
1067
+ this._setNested(data, name, values);
1068
+ });
1069
+
1070
+ return data;
1071
+ }
1072
+
1073
+ /**
1074
+ * Show success message
1075
+ */
1076
+ showSuccess(message) {
1077
+ /* @dev-only:start */
1078
+ {
1079
+ logger.log('Show success:', message);
1080
+ }
1081
+ /* @dev-only:end */
1082
+
1083
+ omega.utilities().showNotification(message, { type: 'success' });
1084
+ }
1085
+
1086
+ /**
1087
+ * Show error message
1088
+ */
1089
+ showError(message) {
1090
+ /* @dev-only:start */
1091
+ {
1092
+ logger.log('Show error:', message);
1093
+ }
1094
+ /* @dev-only:end */
1095
+
1096
+ omega.utilities().showNotification(message, { type: 'danger' });
1097
+ }
1098
+
1099
+ /**
1100
+ * Reset the form
1101
+ */
1102
+ reset() {
1103
+ /* @dev-only:start */
1104
+ {
1105
+ logger.log('reset() called');
1106
+ }
1107
+ /* @dev-only:end */
1108
+
1109
+ this.setDirty(false);
1110
+ this.$form.reset();
1111
+ this._setState('ready');
1112
+ }
1113
+
1114
+ /**
1115
+ * Programmatically trigger form submission
1116
+ * Fires the native submit event so FormManager's _handleSubmit() processes it
1117
+ */
1118
+ submit() {
1119
+ this.$form.requestSubmit();
1120
+ }
1121
+
1122
+ /**
1123
+ * Check if form has unsaved changes
1124
+ */
1125
+ isDirty() {
1126
+ return this._isDirty;
1127
+ }
1128
+
1129
+ /**
1130
+ * Tear down this instance: leave the shared beforeunload registry and
1131
+ * detach window listeners. Call when the form leaves the DOM for good
1132
+ * (view swap, modal teardown) — otherwise the instance accumulates in
1133
+ * the module-level Set and a stale dirty flag can block navigation.
1134
+ * Form-element listeners die with the element; only window-level ones
1135
+ * need explicit removal.
1136
+ */
1137
+ destroy() {
1138
+ _instances.delete(this);
1139
+ window.removeEventListener('pageshow', this._pageShowHandler);
1140
+ }
1141
+
1142
+ /**
1143
+ * Set the input group filter for getData()
1144
+ * When set, getData() only returns fields matching the group (via data-input-group attribute)
1145
+ * Fields without data-input-group or with empty value are considered "global" and always included
1146
+ *
1147
+ * @param {string|string[]|null} group - Group name(s) to filter by (e.g., 'url', ['url', 'wifi']), or null to disable filtering
1148
+ * @returns {FormManager} - Returns this for chaining
1149
+ */
1150
+ setInputGroup(group) {
1151
+ // Normalize to array or null
1152
+ if (group === null || group === undefined || group === '') {
1153
+ this.config.inputGroup = null;
1154
+ } else if (Array.isArray(group)) {
1155
+ this.config.inputGroup = group.map((g) => g.toLowerCase());
1156
+ } else {
1157
+ this.config.inputGroup = [group.toLowerCase()];
1158
+ }
1159
+
1160
+ /* @dev-only:start */
1161
+ {
1162
+ logger.log('setInputGroup:', this.config.inputGroup);
1163
+ }
1164
+ /* @dev-only:end */
1165
+
1166
+ return this;
1167
+ }
1168
+
1169
+ /**
1170
+ * Get the current input group filter
1171
+ * @returns {string[]|null}
1172
+ */
1173
+ getInputGroup() {
1174
+ return this.config.inputGroup;
1175
+ }
1176
+
1177
+ /**
1178
+ * Check if any honeypot field has been filled (bot detection)
1179
+ * Honeypot fields are hidden from users but bots fill them automatically
1180
+ * @returns {boolean} - true if a honeypot field has a value (bot detected)
1181
+ */
1182
+ _isHoneypotFilled() {
1183
+ const $honeypots = this.$form.querySelectorAll(HONEYPOT_SELECTOR);
1184
+
1185
+ for (const $field of $honeypots) {
1186
+ if ($field.value && $field.value.trim() !== '') {
1187
+ return true;
1188
+ }
1189
+ }
1190
+
1191
+ return false;
1192
+ }
1193
+
1194
+ /**
1195
+ * Check if a field should be included based on input group filter
1196
+ * @param {HTMLElement} $field - The field element to check
1197
+ * @returns {boolean}
1198
+ */
1199
+ _isFieldInGroup($field) {
1200
+ const allowedGroups = this.config.inputGroup;
1201
+
1202
+ // No filter set - include all fields
1203
+ if (!allowedGroups) {
1204
+ return true;
1205
+ }
1206
+
1207
+ // Get field's group attribute
1208
+ const fieldGroup = $field.getAttribute('data-input-group');
1209
+
1210
+ // No group attribute or empty = global field, always include
1211
+ if (!fieldGroup || fieldGroup.trim() === '') {
1212
+ return true;
1213
+ }
1214
+
1215
+ // Check if field's group is in allowed groups
1216
+ return allowedGroups.includes(fieldGroup.toLowerCase());
1217
+ }
1218
+
1219
+ /**
1220
+ * Initialize file drop zones within the form
1221
+ * Scans for [data-file-drop] containers and attaches drag-and-drop behavior
1222
+ */
1223
+ _initFileDropZones() {
1224
+ const $zones = this.$form.querySelectorAll('[data-file-drop]');
1225
+
1226
+ /* @dev-only:start */
1227
+ {
1228
+ logger.log('_initFileDropZones found', $zones.length, 'zones');
1229
+ }
1230
+ /* @dev-only:end */
1231
+
1232
+ $zones.forEach(($zone) => {
1233
+ this._setupFileDropZone($zone);
1234
+ });
1235
+ }
1236
+
1237
+ /**
1238
+ * Set up a single file drop zone
1239
+ * @param {HTMLElement} $zone - The container with data-file-drop attribute
1240
+ */
1241
+ _setupFileDropZone($zone) {
1242
+ const $input = $zone.querySelector('input[type="file"]');
1243
+ if (!$input) {
1244
+ return;
1245
+ }
1246
+
1247
+ const mode = ($zone.getAttribute('data-file-drop') || '').toLowerCase();
1248
+ const isPageMode = mode === 'page';
1249
+
1250
+ /* @dev-only:start */
1251
+ {
1252
+ logger.log('Setting up file drop zone', {
1253
+ mode: isPageMode ? 'page' : 'local',
1254
+ input: $input.name || $input.id,
1255
+ });
1256
+ }
1257
+ /* @dev-only:end */
1258
+
1259
+ // Track drag enter/leave depth for reliable active state
1260
+ let dragDepth = 0;
1261
+
1262
+ // Determine the drop target (zone or entire page)
1263
+ const $dropTarget = isPageMode ? document.body : $zone;
1264
+
1265
+ // Helper: check if event landed on a different local drop zone (page mode only)
1266
+ // If so, let that zone handle it instead
1267
+ const isOverOtherZone = (e) => {
1268
+ if (!isPageMode) {
1269
+ return false;
1270
+ }
1271
+
1272
+ const $closest = e.target.closest('[data-file-drop]');
1273
+ return $closest && $closest !== $zone;
1274
+ };
1275
+
1276
+ // Prevent default on dragover to allow drop
1277
+ $dropTarget.addEventListener('dragover', (e) => {
1278
+ if (isOverOtherZone(e)) {
1279
+ return;
1280
+ }
1281
+
1282
+ e.preventDefault();
1283
+ });
1284
+
1285
+ // Track drag enter for active state
1286
+ $dropTarget.addEventListener('dragenter', (e) => {
1287
+ if (isOverOtherZone(e)) {
1288
+ return;
1289
+ }
1290
+
1291
+ e.preventDefault();
1292
+ dragDepth++;
1293
+
1294
+ if (dragDepth === 1) {
1295
+ $zone.classList.add('file-drop-active');
1296
+ }
1297
+ });
1298
+
1299
+ // Track drag leave for active state
1300
+ $dropTarget.addEventListener('dragleave', (e) => {
1301
+ if (isOverOtherZone(e)) {
1302
+ return;
1303
+ }
1304
+
1305
+ e.preventDefault();
1306
+ dragDepth--;
1307
+
1308
+ if (dragDepth === 0) {
1309
+ $zone.classList.remove('file-drop-active');
1310
+ }
1311
+ });
1312
+
1313
+ // Handle drop
1314
+ $dropTarget.addEventListener('drop', (e) => {
1315
+ if (isOverOtherZone(e)) {
1316
+ return;
1317
+ }
1318
+
1319
+ e.preventDefault();
1320
+ dragDepth = 0;
1321
+ $zone.classList.remove('file-drop-active');
1322
+
1323
+ this._handleFileDrop(e, $input, $zone);
1324
+ });
1325
+
1326
+ // Click-to-browse: click anywhere on the zone opens the file picker
1327
+ $zone.addEventListener('click', (e) => {
1328
+ // Skip if clicking the input itself (avoid double-open)
1329
+ if (e.target === $input) {
1330
+ return;
1331
+ }
1332
+
1333
+ $input.click();
1334
+ });
1335
+
1336
+ // Update file name display when file is selected via browse dialog
1337
+ $input.addEventListener('change', () => {
1338
+ this._updateFileDropName($input, $zone);
1339
+ });
1340
+ }
1341
+
1342
+ /**
1343
+ * Handle a file drop event
1344
+ * @param {DragEvent} e - The drop event
1345
+ * @param {HTMLInputElement} $input - The file input to assign files to
1346
+ * @param {HTMLElement} $zone - The drop zone container
1347
+ */
1348
+ _handleFileDrop(e, $input, $zone) {
1349
+ const files = e.dataTransfer?.files;
1350
+ if (!files || files.length === 0) {
1351
+ return;
1352
+ }
1353
+
1354
+ // Assign files to the input using DataTransfer
1355
+ const dt = new DataTransfer();
1356
+ const acceptAttr = $input.getAttribute('accept');
1357
+ const isMultiple = $input.hasAttribute('multiple');
1358
+
1359
+ for (const file of files) {
1360
+ // Filter by accept attribute if present
1361
+ if (acceptAttr && !this._fileMatchesAccept(file, acceptAttr)) {
1362
+ continue;
1363
+ }
1364
+
1365
+ dt.items.add(file);
1366
+
1367
+ // Only take the first file if input is not multiple
1368
+ if (!isMultiple) {
1369
+ break;
1370
+ }
1371
+ }
1372
+
1373
+ // Show error if no valid files after filtering
1374
+ if (dt.files.length === 0) {
1375
+ $zone.classList.add('file-drop-error');
1376
+ this.showError(`File type not accepted. Accepted: ${acceptAttr}`);
1377
+ return;
1378
+ }
1379
+
1380
+ $input.files = dt.files;
1381
+
1382
+ // Dispatch change event so existing handlers pick it up
1383
+ $input.dispatchEvent(new Event('change', { bubbles: true }));
1384
+
1385
+ // Update file name display
1386
+ this._updateFileDropName($input, $zone);
1387
+
1388
+ /* @dev-only:start */
1389
+ {
1390
+ logger.log('File dropped', {
1391
+ files: Array.from(dt.files).map((f) => f.name),
1392
+ input: $input.name || $input.id,
1393
+ });
1394
+ }
1395
+ /* @dev-only:end */
1396
+ }
1397
+
1398
+ /**
1399
+ * Update the file name display element in a drop zone
1400
+ * @param {HTMLInputElement} $input - The file input
1401
+ * @param {HTMLElement} $zone - The drop zone container
1402
+ */
1403
+ _updateFileDropName($input, $zone) {
1404
+ const $name = $zone.querySelector('[data-file-drop-name]');
1405
+ if (!$name) {
1406
+ return;
1407
+ }
1408
+
1409
+ const files = $input.files;
1410
+ if (!files || files.length === 0) {
1411
+ $name.textContent = 'No file selected';
1412
+ $zone.classList.remove('file-drop-has-file');
1413
+ return;
1414
+ }
1415
+
1416
+ if (files.length === 1) {
1417
+ $name.textContent = files[0].name;
1418
+ } else {
1419
+ $name.textContent = `${files.length} files selected`;
1420
+ }
1421
+
1422
+ $zone.classList.add('file-drop-has-file');
1423
+ $zone.classList.remove('file-drop-error');
1424
+ }
1425
+
1426
+ /**
1427
+ * Check if a file matches an accept attribute value
1428
+ * @param {File} file - The file to check
1429
+ * @param {string} accept - The accept attribute value (e.g., "image/*,.pdf")
1430
+ * @returns {boolean}
1431
+ */
1432
+ _fileMatchesAccept(file, accept) {
1433
+ const types = accept.split(',').map((t) => t.trim().toLowerCase());
1434
+ const fileName = file.name.toLowerCase();
1435
+ const fileType = (file.type || '').toLowerCase();
1436
+
1437
+ // Common extension-to-MIME-category map for when browser doesn't provide file.type
1438
+ const extToCategory = {
1439
+ '.jpg': 'image/', '.jpeg': 'image/', '.png': 'image/', '.gif': 'image/',
1440
+ '.webp': 'image/', '.svg': 'image/', '.bmp': 'image/', '.ico': 'image/',
1441
+ '.pdf': 'application/pdf',
1442
+ };
1443
+
1444
+ for (const type of types) {
1445
+ // Extension match (e.g., ".pdf")
1446
+ if (type.startsWith('.')) {
1447
+ if (fileName.endsWith(type)) {
1448
+ return true;
1449
+ }
1450
+ continue;
1451
+ }
1452
+
1453
+ // Wildcard MIME match (e.g., "image/*")
1454
+ if (type.endsWith('/*')) {
1455
+ const prefix = `${type.slice(0, -2)}/`;
1456
+
1457
+ // Check actual MIME type
1458
+ if (fileType && fileType.startsWith(prefix)) {
1459
+ return true;
1460
+ }
1461
+
1462
+ // Fallback: check extension when browser doesn't provide MIME type
1463
+ if (!fileType) {
1464
+ const ext = `.${fileName.split('.').pop()}`;
1465
+ const guessedCategory = extToCategory[ext] || '';
1466
+ if (guessedCategory.startsWith(prefix)) {
1467
+ return true;
1468
+ }
1469
+ }
1470
+ continue;
1471
+ }
1472
+
1473
+ // Exact MIME match (e.g., "application/pdf")
1474
+ if (fileType === type) {
1475
+ return true;
1476
+ }
1477
+ }
1478
+
1479
+ return false;
1480
+ }
1481
+
1482
+ /**
1483
+ * Set form data from a nested object (supports dot notation field names)
1484
+ */
1485
+ setData(data) {
1486
+ /* @dev-only:start */
1487
+ {
1488
+ logger.log('setData() called', data);
1489
+ }
1490
+ /* @dev-only:end */
1491
+
1492
+ // Flatten nested object to dot notation paths
1493
+ const flatData = this._flattenObject(data);
1494
+
1495
+ // Set each field value
1496
+ for (const [path, value] of Object.entries(flatData)) {
1497
+ this._setFieldValue(path, value);
1498
+ }
1499
+ }
1500
+
1501
+ /**
1502
+ * Flatten a nested object to dot notation paths
1503
+ */
1504
+ _flattenObject(obj, prefix = '') {
1505
+ const result = {};
1506
+
1507
+ for (const [key, value] of Object.entries(obj)) {
1508
+ const path = prefix ? `${prefix}.${key}` : key;
1509
+
1510
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
1511
+ // Check if this is a checkbox group (object with boolean values)
1512
+ const isCheckboxGroup = Object.values(value).every((v) => typeof v === 'boolean');
1513
+
1514
+ if (isCheckboxGroup) {
1515
+ // Keep as object for checkbox group handling
1516
+ result[path] = value;
1517
+ } else {
1518
+ // Recurse into nested object
1519
+ Object.assign(result, this._flattenObject(value, path));
1520
+ }
1521
+ } else {
1522
+ result[path] = value;
1523
+ }
1524
+ }
1525
+
1526
+ return result;
1527
+ }
1528
+
1529
+ /**
1530
+ * Set a single field value by name (supports dot notation)
1531
+ */
1532
+ _setFieldValue(name, value) {
1533
+ const $fields = this.$form.querySelectorAll(`[name="${name}"]`);
1534
+
1535
+ if ($fields.length === 0) {
1536
+ /* @dev-only:start */
1537
+ {
1538
+ logger.log('setData: field not found:', name);
1539
+ }
1540
+ /* @dev-only:end */
1541
+ return;
1542
+ }
1543
+
1544
+ const $field = $fields[0];
1545
+ const type = $field.type;
1546
+
1547
+ // Handle different input types
1548
+ if (type === 'checkbox') {
1549
+ if ($fields.length === 1) {
1550
+ // Single checkbox: boolean value
1551
+ $field.checked = !!value;
1552
+ } else if (typeof value === 'object') {
1553
+ // Checkbox group: object with value: boolean
1554
+ $fields.forEach(($cb) => {
1555
+ $cb.checked = !!value[$cb.value];
1556
+ });
1557
+ }
1558
+ } else if (type === 'radio') {
1559
+ // Radio group: set the one with matching value
1560
+ $fields.forEach(($radio) => {
1561
+ $radio.checked = $radio.value === value;
1562
+ });
1563
+ } else if ($field.tagName === 'SELECT') {
1564
+ // Select: set value
1565
+ $field.value = value;
1566
+ } else {
1567
+ // Text, email, textarea, etc.
1568
+ $field.value = value;
1569
+ }
1570
+
1571
+ /* @dev-only:start */
1572
+ {
1573
+ logger.log('setData: set field', { name, value, type });
1574
+ }
1575
+ /* @dev-only:end */
1576
+ }
1577
+ }