@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.
- package/LICENSE +98 -0
- package/README.md +874 -0
- package/dist/index.js +999 -0
- package/dist/modules/analytics.js +584 -0
- package/dist/modules/auth.js +469 -0
- package/dist/modules/bindings.js +319 -0
- package/dist/modules/device.js +282 -0
- package/dist/modules/dom.js +96 -0
- package/dist/modules/features.js +30 -0
- package/dist/modules/firestore.js +313 -0
- package/dist/modules/form-manager.js +1577 -0
- package/dist/modules/icon-core.js +226 -0
- package/dist/modules/icon-renderer.js +149 -0
- package/dist/modules/live-page.js +235 -0
- package/dist/modules/logger.js +36 -0
- package/dist/modules/motion.js +853 -0
- package/dist/modules/notifications.js +433 -0
- package/dist/modules/path-prefix.js +22 -0
- package/dist/modules/request.js +223 -0
- package/dist/modules/sentry.js +108 -0
- package/dist/modules/service-worker.js +237 -0
- package/dist/modules/storage.js +133 -0
- package/dist/modules/triggers.js +117 -0
- package/dist/modules/utilities.js +479 -0
- package/dist/modules/vert-document.js +354 -0
- package/dist/modules/verts.js +1133 -0
- package/dist/vendor/account/engine.js +182 -0
- package/dist/vendor/account/features.js +220 -0
- package/dist/vendor/account/index.js +53 -0
- package/dist/vendor/account/schema.js +272 -0
- package/dist/vendor/account/subscription.js +38 -0
- package/dist/vendor/analytics/adapters/ga4.js +26 -0
- package/dist/vendor/analytics/adapters/meta.js +26 -0
- package/dist/vendor/analytics/adapters/resolve.js +130 -0
- package/dist/vendor/analytics/adapters/tiktok.js +27 -0
- package/dist/vendor/analytics/catalog.js +908 -0
- package/dist/vendor/analytics/consent.js +49 -0
- package/dist/vendor/analytics/core.js +141 -0
- package/dist/vendor/analytics/identity.js +136 -0
- package/dist/vendor/analytics/index.js +170 -0
- package/dist/vendor/analytics/logger.js +40 -0
- package/dist/vendor/analytics/transports/browser.js +110 -0
- package/dist/vendor/monitoring/browser.js +207 -0
- package/dist/vendor/monitoring/core.js +180 -0
- package/dist/vendor/monitoring/logger.js +39 -0
- package/docs/architecture.md +59 -0
- package/docs/bindings.md +235 -0
- package/docs/build-system.md +32 -0
- package/docs/cdp-debugging.md +29 -0
- package/docs/code-patterns.md +96 -0
- package/docs/common-tasks.md +36 -0
- package/docs/dependencies.md +19 -0
- package/docs/index.md +159 -0
- package/docs/modules.md +180 -0
- package/docs/shared/agent-docs.md +89 -0
- package/docs/shared/analytics.md +612 -0
- package/docs/shared/brands.md +51 -0
- package/docs/shared/breaking-changes.md +497 -0
- package/docs/shared/config.md +1387 -0
- package/docs/shared/deploys.md +215 -0
- package/docs/shared/icons.md +201 -0
- package/docs/shared/local-dev.md +147 -0
- package/docs/shared/logging.md +202 -0
- package/docs/shared/monitoring.md +153 -0
- package/docs/shared/publishing.md +183 -0
- package/docs/shared/rulings.md +34 -0
- package/docs/shared/testing.md +147 -0
- package/docs/shared/theming.md +604 -0
- package/docs/shared/translation.md +291 -0
- package/docs/shared/updates.md +61 -0
- package/docs/testing.md +9 -0
- 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
|
+
}
|