webspresso 0.0.112 → 0.0.113

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.
@@ -23,33 +23,48 @@ const { isArray, setElementBusyState } = require('./utils');
23
23
  * @param {string} target
24
24
  * @returns {boolean}
25
25
  */
26
- function isSafeRedirectUrl(target) {
27
- if (!target || typeof target !== 'string') return false;
26
+ function resolveSafeRedirectUrl(target) {
27
+ if (!target || typeof target !== 'string') return null;
28
28
  const trimmed = target.trim();
29
- if (!trimmed) return false;
29
+ if (!trimmed) return null;
30
30
 
31
31
  // Block control characters
32
- if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return false;
32
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return null;
33
33
 
34
34
  // Block protocol-relative URLs (//example.com) and backslash traversal (/\ or \\)
35
35
  if (trimmed.startsWith('//') || trimmed.startsWith('/\\') || trimmed.startsWith('\\')) {
36
- return false;
36
+ return null;
37
37
  }
38
38
 
39
39
  // Relative path starting with single slash (e.g. /dashboard)
40
40
  if (trimmed.startsWith('/')) {
41
- return true;
41
+ try {
42
+ const parsed = new URL(trimmed, 'http://localhost');
43
+ if (parsed.origin !== 'http://localhost' || !parsed.pathname.startsWith('/') || parsed.pathname.startsWith('//')) {
44
+ return null;
45
+ }
46
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
47
+ } catch {
48
+ return null;
49
+ }
42
50
  }
43
51
 
44
52
  // Absolute URL: MUST explicitly parse as http: or https:
45
53
  try {
46
54
  const parsed = new URL(trimmed);
47
- return parsed.protocol === 'http:' || parsed.protocol === 'https:';
55
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
56
+ return null;
57
+ }
58
+ return parsed.href;
48
59
  } catch {
49
- return false;
60
+ return null;
50
61
  }
51
62
  }
52
63
 
64
+ function isSafeRedirectUrl(target) {
65
+ return resolveSafeRedirectUrl(target) !== null;
66
+ }
67
+
53
68
  let errorIdSeq = 0;
54
69
 
55
70
  /**
@@ -435,6 +450,7 @@ function createFormHandle(formEl, options = {}) {
435
450
  let baselineValues = parseFormElement(formEl).data;
436
451
  let pending = false;
437
452
  let dirty = false;
453
+ let submitted = false;
438
454
  let inputRevision = 0;
439
455
  let fieldErrors = {};
440
456
  let formError = null;
@@ -443,6 +459,83 @@ function createFormHandle(formEl, options = {}) {
443
459
  let draftDebounceTimer = null;
444
460
  let resetTimer = null;
445
461
 
462
+ const touchedFields = new Set();
463
+ const errorDisplayMode =
464
+ options.errorDisplay === 'touched' ||
465
+ (formEl && formEl.getAttribute && formEl.getAttribute('data-w-error-mode') === 'touched')
466
+ ? 'touched'
467
+ : 'always';
468
+
469
+ const validationSeqByField = new Map();
470
+ const validationTimersByField = new Map();
471
+ const validationAbortByField = new Map();
472
+
473
+ let activeSubmitAbort = null;
474
+ let uploadEntries = [];
475
+ const previewUrlsByField = new Map();
476
+
477
+ function cancelFieldValidation(fieldName) {
478
+ if (fieldName) {
479
+ const t = validationTimersByField.get(fieldName);
480
+ if (t) {
481
+ clearTimeout(t);
482
+ validationTimersByField.delete(fieldName);
483
+ }
484
+ const ac = validationAbortByField.get(fieldName);
485
+ if (ac) {
486
+ try {
487
+ ac.abort();
488
+ } catch (_e) {}
489
+ validationAbortByField.delete(fieldName);
490
+ }
491
+ return;
492
+ }
493
+ for (const t of validationTimersByField.values()) clearTimeout(t);
494
+ validationTimersByField.clear();
495
+ for (const ac of validationAbortByField.values()) {
496
+ try {
497
+ ac.abort();
498
+ } catch (_e) {}
499
+ }
500
+ validationAbortByField.clear();
501
+ }
502
+
503
+ function revokePreviewUrls(fieldName) {
504
+ const revokeUrl = (u) => {
505
+ if (typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function') {
506
+ try {
507
+ URL.revokeObjectURL(u);
508
+ } catch (_e) {}
509
+ }
510
+ };
511
+ if (fieldName) {
512
+ const set = previewUrlsByField.get(fieldName);
513
+ if (set) {
514
+ for (const u of set) revokeUrl(u);
515
+ set.clear();
516
+ previewUrlsByField.delete(fieldName);
517
+ }
518
+ return;
519
+ }
520
+ for (const set of previewUrlsByField.values()) {
521
+ for (const u of set) revokeUrl(u);
522
+ set.clear();
523
+ }
524
+ previewUrlsByField.clear();
525
+ }
526
+
527
+ function createPreviewUrl(file, fieldName = '_default') {
528
+ if (!file || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return null;
529
+ const url = URL.createObjectURL(file);
530
+ let set = previewUrlsByField.get(fieldName);
531
+ if (!set) {
532
+ set = new Set();
533
+ previewUrlsByField.set(fieldName, set);
534
+ }
535
+ set.add(url);
536
+ return url;
537
+ }
538
+
446
539
  const beforeSubmitListeners = new Set();
447
540
  const successListeners = new Set();
448
541
  const errorListeners = new Set();
@@ -591,6 +684,12 @@ function createFormHandle(formEl, options = {}) {
591
684
  }
592
685
  }
593
686
 
687
+ function isFieldErrorVisible(fieldName) {
688
+ if (errorDisplayMode !== 'touched') return true;
689
+ if (submitted) return true;
690
+ return touchedFields.has(fieldName);
691
+ }
692
+
594
693
  /**
595
694
  * Render field errors and form-level error into the DOM with accessible ARIA bindings.
596
695
  * Optionally focuses the first invalid control.
@@ -603,7 +702,11 @@ function createFormHandle(formEl, options = {}) {
603
702
  for (let i = 0; i < errorSpans.length; i++) {
604
703
  const span = errorSpans[i];
605
704
  const fieldName = span.getAttribute('data-w-error');
606
- const msg = fieldName && fieldErrors[fieldName] ? String(fieldErrors[fieldName]) : '';
705
+ const normName = fieldName && fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
706
+ const rawMsg = fieldName && (fieldErrors[fieldName] || (normName && fieldErrors[normName]))
707
+ ? String(fieldErrors[fieldName] || fieldErrors[normName])
708
+ : '';
709
+ const msg = rawMsg && (isFieldErrorVisible(fieldName) || (normName && isFieldErrorVisible(normName))) ? rawMsg : '';
607
710
  span.textContent = msg;
608
711
  if (msg) {
609
712
  span.removeAttribute('hidden');
@@ -626,7 +729,8 @@ function createFormHandle(formEl, options = {}) {
626
729
  const rawName = ctrl.getAttribute('name');
627
730
  if (!rawName) continue;
628
731
  const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
629
- const errMsg = fieldErrors[normName] || fieldErrors[rawName];
732
+ const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
733
+ const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
630
734
 
631
735
  if (errMsg) {
632
736
  ctrl.setAttribute('aria-invalid', 'true');
@@ -711,6 +815,8 @@ function createFormHandle(formEl, options = {}) {
711
815
  */
712
816
  function reset() {
713
817
  cancelDraftDebounce();
818
+ cancelFieldValidation();
819
+ revokePreviewUrls();
714
820
  if (!formEl) return;
715
821
  if (typeof formEl.reset === 'function') {
716
822
  formEl.reset();
@@ -730,6 +836,8 @@ function createFormHandle(formEl, options = {}) {
730
836
  }
731
837
  }
732
838
  dirty = false;
839
+ submitted = false;
840
+ touchedFields.clear();
733
841
  fieldErrors = {};
734
842
  formError = null;
735
843
  syncErrorsToDom(false);
@@ -741,6 +849,8 @@ function createFormHandle(formEl, options = {}) {
741
849
  */
742
850
  function clear() {
743
851
  cancelDraftDebounce();
852
+ cancelFieldValidation();
853
+ revokePreviewUrls();
744
854
  if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
745
855
  const controls = formEl.querySelectorAll('input, select, textarea');
746
856
  for (let i = 0; i < controls.length; i++) {
@@ -762,6 +872,8 @@ function createFormHandle(formEl, options = {}) {
762
872
  }
763
873
  }
764
874
  dirty = computeFormDirty(formEl, initialSnapshot);
875
+ submitted = false;
876
+ touchedFields.clear();
765
877
  fieldErrors = {};
766
878
  formError = null;
767
879
  syncErrorsToDom(false);
@@ -829,9 +941,12 @@ function createFormHandle(formEl, options = {}) {
829
941
  }
830
942
 
831
943
  if (isBaseline) {
944
+ cancelFieldValidation();
832
945
  initialSnapshot = captureInitialFormSnapshot(formEl);
833
946
  baselineValues = parseFormElement(formEl).data;
834
947
  dirty = false;
948
+ submitted = false;
949
+ touchedFields.clear();
835
950
  fieldErrors = {};
836
951
  formError = null;
837
952
  lastError = null;
@@ -950,13 +1065,47 @@ function createFormHandle(formEl, options = {}) {
950
1065
  }
951
1066
  }
952
1067
 
953
- // Clear previous errors before request starts
1068
+ // Clear previous errors before request starts, mark submitted = true
1069
+ cancelFieldValidation();
1070
+ submitted = true;
954
1071
  fieldErrors = {};
955
1072
  formError = null;
956
1073
  lastError = null;
957
1074
  syncErrorsToDom(false);
1075
+ const submitRevision = inputRevision;
1076
+ const submittedSnapshot = captureInitialFormSnapshot(formEl);
958
1077
  setPendingState(true, submitter);
959
1078
 
1079
+ // Track per-file upload state if multipart
1080
+ const submitAbortController = new AbortController();
1081
+ activeSubmitAbort = submitAbortController;
1082
+ if (isMultipart && formEl && typeof formEl.querySelectorAll === 'function') {
1083
+ const nextUploads = [];
1084
+ const fileInputs = formEl.querySelectorAll('input[type="file"]');
1085
+ for (let i = 0; i < fileInputs.length; i++) {
1086
+ const fi = fileInputs[i];
1087
+ const fName = fi.getAttribute('name') || 'file';
1088
+ const files = fi.files || [];
1089
+ for (let j = 0; j < files.length; j++) {
1090
+ const f = files[j];
1091
+ nextUploads.push({
1092
+ field: fName,
1093
+ name: f.name || 'file',
1094
+ size: typeof f.size === 'number' ? f.size : 0,
1095
+ status: 'uploading',
1096
+ loaded: 0,
1097
+ total: typeof f.size === 'number' ? f.size : 0,
1098
+ percent: null,
1099
+ error: null,
1100
+ });
1101
+ }
1102
+ }
1103
+ uploadEntries = nextUploads;
1104
+ if (uploadEntries.length > 0) notifyStateChange();
1105
+ } else {
1106
+ uploadEntries = [];
1107
+ }
1108
+
960
1109
  try {
961
1110
  let responseData;
962
1111
  const reqKey = `form:${formName}:${method}:${actionUrl}`;
@@ -964,17 +1113,39 @@ function createFormHandle(formEl, options = {}) {
964
1113
  responseData = await api.get(actionUrl, payloadData, {
965
1114
  key: reqKey,
966
1115
  policy: 'latest',
1116
+ signal: submitAbortController.signal,
967
1117
  });
968
1118
  } else {
969
1119
  const bodyToSend = isMultipart && formData ? formData : payloadData;
970
- responseData = await api.request(method, actionUrl, bodyToSend, {
1120
+ const reqOpts = {
971
1121
  key: reqKey,
972
1122
  policy: 'drop',
973
- });
1123
+ signal: submitAbortController.signal,
1124
+ };
1125
+ if (isMultipart && uploadEntries.length > 0) {
1126
+ reqOpts.onUploadProgress = (prog) => {
1127
+ for (let i = 0; i < uploadEntries.length; i++) {
1128
+ uploadEntries[i].loaded = prog.loaded;
1129
+ uploadEntries[i].total = prog.total || uploadEntries[i].total;
1130
+ uploadEntries[i].percent = prog.percent;
1131
+ }
1132
+ if (typeof submitOptions.onUploadProgress === 'function') {
1133
+ submitOptions.onUploadProgress(prog, uploadEntries);
1134
+ }
1135
+ notifyStateChange();
1136
+ };
1137
+ }
1138
+ responseData = await api.request(method, actionUrl, bodyToSend, reqOpts);
1139
+ }
1140
+
1141
+ if (uploadEntries.length > 0) {
1142
+ for (let i = 0; i < uploadEntries.length; i++) {
1143
+ uploadEntries[i].status = 'done';
1144
+ if (uploadEntries[i].percent !== null) uploadEntries[i].percent = 100;
1145
+ }
974
1146
  }
975
1147
 
976
1148
  lastData = responseData;
977
- dirty = false;
978
1149
  fieldErrors = {};
979
1150
  formError = null;
980
1151
  syncErrorsToDom(false);
@@ -995,18 +1166,51 @@ function createFormHandle(formEl, options = {}) {
995
1166
  clearAttr === 'true' ||
996
1167
  clearAttr === '1';
997
1168
 
998
- if (shouldClear) {
999
- clear();
1000
- } else if (shouldReset) {
1001
- reset();
1002
- } else {
1003
- // Current state becomes the new clean baseline after successful save
1004
- initialSnapshot = captureInitialFormSnapshot(formEl);
1005
- baselineValues = parseFormElement(formEl).data;
1006
- }
1169
+ const editedDuringSubmit = inputRevision !== submitRevision;
1170
+
1171
+ if (!editedDuringSubmit) {
1172
+ dirty = false;
1173
+ if (shouldClear) {
1174
+ clear();
1175
+ } else if (shouldReset) {
1176
+ reset();
1177
+ } else {
1178
+ // Current state becomes the new clean baseline after successful save
1179
+ initialSnapshot = captureInitialFormSnapshot(formEl);
1180
+ baselineValues = parseFormElement(formEl).data;
1181
+ }
1007
1182
 
1008
- if (isDraftEnabled) {
1009
- clearDraft();
1183
+ if (isDraftEnabled) {
1184
+ clearDraft();
1185
+ }
1186
+ } else {
1187
+ // User edited form controls while submit was in flight:
1188
+ // Never wipe out the new unsubmitted edits via reset() or clear().
1189
+ // Update baseline to what was actually committed on the server (or empty baseline if clearOnSuccess).
1190
+ if (shouldClear) {
1191
+ const clearedSnapshot = new Map();
1192
+ for (const [ctrl, snap] of submittedSnapshot.entries()) {
1193
+ clearedSnapshot.set(ctrl, {
1194
+ ...snap,
1195
+ checked: false,
1196
+ value: '',
1197
+ selectedValues: [],
1198
+ });
1199
+ }
1200
+ initialSnapshot = clearedSnapshot;
1201
+ baselineValues = {};
1202
+ } else if (!shouldReset) {
1203
+ initialSnapshot = submittedSnapshot;
1204
+ baselineValues = payloadData && typeof payloadData === 'object' ? { ...payloadData } : {};
1205
+ }
1206
+ dirty = computeFormDirty(formEl, initialSnapshot);
1207
+ if (isDraftEnabled) {
1208
+ if (dirty) {
1209
+ saveDraft();
1210
+ } else {
1211
+ clearDraft();
1212
+ }
1213
+ }
1010
1214
  }
1011
1215
 
1012
1216
  // Invoke registered success hooks. Note: Any background fragment refresh failure
@@ -1025,11 +1229,12 @@ function createFormHandle(formEl, options = {}) {
1025
1229
  }
1026
1230
 
1027
1231
  // Explicit redirect behavior
1028
- const redirectTarget =
1232
+ const rawRedirectTarget =
1029
1233
  submitOptions.redirectOnSuccess ||
1030
1234
  (formEl && formEl.getAttribute('data-w-redirect')) ||
1031
1235
  null;
1032
- if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
1236
+ const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
1237
+ if (redirectTarget) {
1033
1238
  if (typeof options.navigate === 'function') {
1034
1239
  options.navigate(redirectTarget);
1035
1240
  } else if (typeof window !== 'undefined' && window.location) {
@@ -1050,6 +1255,15 @@ function createFormHandle(formEl, options = {}) {
1050
1255
  return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
1051
1256
  } catch (err) {
1052
1257
  lastError = err || null;
1258
+ if (uploadEntries.length > 0) {
1259
+ const isCanceled = Boolean(err && err.isCanceled);
1260
+ for (let i = 0; i < uploadEntries.length; i++) {
1261
+ if (uploadEntries[i].status === 'uploading') {
1262
+ uploadEntries[i].status = isCanceled ? 'canceled' : 'error';
1263
+ uploadEntries[i].error = isCanceled ? null : (err && err.message ? err.message : 'Upload failed');
1264
+ }
1265
+ }
1266
+ }
1053
1267
  if (err && err.kind === 'validation') {
1054
1268
  fieldErrors = { ...(err.fieldErrors || {}) };
1055
1269
  formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
@@ -1072,26 +1286,181 @@ function createFormHandle(formEl, options = {}) {
1072
1286
  if (submitOptions.throwOnError) {
1073
1287
  throw err;
1074
1288
  }
1075
- return { ok: false, submitted: false, error: err };
1289
+ return {
1290
+ ok: false,
1291
+ submitted: false,
1292
+ outcomeUnknown: Boolean(err && err.outcomeUnknown),
1293
+ outcome: (err && err.outcome) || 'failed',
1294
+ error: err,
1295
+ };
1076
1296
  } finally {
1297
+ if (activeSubmitAbort === submitAbortController) {
1298
+ activeSubmitAbort = null;
1299
+ }
1077
1300
  setPendingState(false, submitter);
1078
1301
  }
1079
1302
  }
1080
1303
 
1081
- // Listen to native submit and input/change events on `formEl`
1304
+ function parseFieldValidateConfig(ctrl) {
1305
+ const ctrlAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate') : null;
1306
+ const formAttr = formEl && formEl.getAttribute ? formEl.getAttribute('data-w-validate') : null;
1307
+ const raw = ctrlAttr !== null ? ctrlAttr : formAttr;
1308
+ if (!raw || raw === 'false') return null;
1309
+ const parts = String(raw).split(/[\s,|]+/).map((s) => s.trim().toLowerCase()).filter(Boolean);
1310
+ const onBlur = parts.includes('blur') || parts.includes('true') || raw === '';
1311
+ let debounceMs = null;
1312
+ for (const p of parts) {
1313
+ if (p.startsWith('debounce:')) {
1314
+ const ms = Number(p.slice('debounce:'.length).replace(/ms$/i, ''));
1315
+ debounceMs = Number.isFinite(ms) && ms >= 0 ? ms : 300;
1316
+ } else if (p === 'debounce') {
1317
+ debounceMs = 300;
1318
+ }
1319
+ }
1320
+ return { onBlur, debounceMs };
1321
+ }
1322
+
1323
+ async function validateField(fieldName, vOpts = {}) {
1324
+ if (!formEl || !api || !fieldName) return { ok: true, valid: true, errors: {} };
1325
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1326
+
1327
+ const seq = (validationSeqByField.get(norm) || 0) + 1;
1328
+ validationSeqByField.set(norm, seq);
1329
+
1330
+ const prevAbort = validationAbortByField.get(norm);
1331
+ if (prevAbort) {
1332
+ try {
1333
+ prevAbort.abort();
1334
+ } catch (_e) {}
1335
+ }
1336
+ const ac = new AbortController();
1337
+ validationAbortByField.set(norm, ac);
1338
+
1339
+ const validateUrl =
1340
+ vOpts.url ||
1341
+ (formEl.getAttribute && (formEl.getAttribute('data-w-validate-url') || formEl.getAttribute('action'))) ||
1342
+ (typeof window !== 'undefined' && window.location ? window.location.pathname : '/');
1343
+ const method = String(vOpts.method || (formEl.getAttribute && formEl.getAttribute('method')) || 'POST').toUpperCase();
1344
+
1345
+ const { data: allValues } = parseFormElement(formEl);
1346
+ const ctrl = formEl.querySelector ? (formEl.querySelector(`[name="${norm}"]`) || formEl.querySelector(`[name="${fieldName}"]`)) : null;
1347
+ const contextAttr = ctrl && ctrl.getAttribute ? ctrl.getAttribute('data-w-validate-context') : null;
1348
+
1349
+ let payload = { [norm]: allValues[norm] };
1350
+ if (vOpts.contextFields === '*' || contextAttr === '*') {
1351
+ payload = { ...allValues };
1352
+ } else {
1353
+ const ctxList = isArray(vOpts.contextFields)
1354
+ ? vOpts.contextFields
1355
+ : typeof contextAttr === 'string' && contextAttr.trim()
1356
+ ? contextAttr.split(',').map((s) => s.trim()).filter(Boolean)
1357
+ : [];
1358
+ for (const cf of ctxList) {
1359
+ if (Object.prototype.hasOwnProperty.call(allValues, cf)) {
1360
+ payload[cf] = allValues[cf];
1361
+ }
1362
+ }
1363
+ }
1364
+
1365
+ try {
1366
+ const resp = await api.request(method, validateUrl, payload, {
1367
+ headers: { 'X-Webspresso-Validate-Fields': norm },
1368
+ signal: ac.signal,
1369
+ });
1370
+
1371
+ if (seq !== validationSeqByField.get(norm)) {
1372
+ return { ok: true, stale: true, valid: true, errors: {} };
1373
+ }
1374
+
1375
+ const respErrors = resp && resp.errors && typeof resp.errors === 'object' ? resp.errors : {};
1376
+ if (respErrors[norm]) {
1377
+ fieldErrors[norm] = String(respErrors[norm]);
1378
+ } else {
1379
+ delete fieldErrors[norm];
1380
+ }
1381
+ syncErrorsToDom(false);
1382
+ notifyStateChange();
1383
+ return {
1384
+ ok: true,
1385
+ valid: !fieldErrors[norm],
1386
+ errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {},
1387
+ };
1388
+ } catch (err) {
1389
+ if (seq !== validationSeqByField.get(norm) || (err && err.isCanceled)) {
1390
+ return { ok: true, stale: true, canceled: Boolean(err && err.isCanceled), errors: {} };
1391
+ }
1392
+ // If server returned a 400/422 validation envelope, extract only this field's error
1393
+ if (err && err.kind === 'validation' && err.fieldErrors) {
1394
+ if (err.fieldErrors[norm]) {
1395
+ fieldErrors[norm] = String(err.fieldErrors[norm]);
1396
+ } else {
1397
+ delete fieldErrors[norm];
1398
+ }
1399
+ syncErrorsToDom(false);
1400
+ notifyStateChange();
1401
+ return { ok: true, valid: !fieldErrors[norm], errors: fieldErrors[norm] ? { [norm]: fieldErrors[norm] } : {} };
1402
+ }
1403
+ // Network or 5xx error: NEVER mark the field as invalid!
1404
+ if (typeof formEl.dispatchEvent === 'function' && typeof CustomEvent !== 'undefined') {
1405
+ formEl.dispatchEvent(
1406
+ new CustomEvent('w-form:validation-error', {
1407
+ bubbles: true,
1408
+ detail: { field: norm, error: err },
1409
+ })
1410
+ );
1411
+ }
1412
+ return { ok: false, networkError: true, error: err };
1413
+ } finally {
1414
+ if (validationAbortByField.get(norm) === ac) {
1415
+ validationAbortByField.delete(norm);
1416
+ }
1417
+ }
1418
+ }
1419
+
1420
+ function cancelUpload() {
1421
+ if (activeSubmitAbort) {
1422
+ try {
1423
+ activeSubmitAbort.abort();
1424
+ } catch (_e) {}
1425
+ activeSubmitAbort = null;
1426
+ return true;
1427
+ }
1428
+ return false;
1429
+ }
1430
+
1431
+ // Listen to native submit, focusout, and input/change events on `formEl`
1082
1432
  const onDomInput = (evt) => {
1083
1433
  inputRevision += 1;
1084
1434
  dirty = computeFormDirty(formEl, initialSnapshot);
1085
1435
  const target = evt && evt.target;
1086
1436
  if (target && target.getAttribute) {
1437
+ const type = (target.type || '').toLowerCase();
1087
1438
  const rawName = target.getAttribute('name');
1439
+ if (type === 'file' && evt.type === 'change' && rawName) {
1440
+ revokePreviewUrls(rawName);
1441
+ }
1088
1442
  if (rawName) {
1089
1443
  const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
1444
+ if (evt.type === 'change' && (type === 'checkbox' || type === 'radio' || target.tagName === 'SELECT')) {
1445
+ touchedFields.add(norm);
1446
+ }
1090
1447
  if (fieldErrors[norm] || fieldErrors[rawName]) {
1091
1448
  delete fieldErrors[norm];
1092
1449
  delete fieldErrors[rawName];
1093
1450
  syncErrorsToDom(false);
1094
1451
  }
1452
+ if (evt.type === 'input') {
1453
+ const vCfg = parseFieldValidateConfig(target);
1454
+ if (vCfg && vCfg.debounceMs !== null) {
1455
+ const prevTimer = validationTimersByField.get(norm);
1456
+ if (prevTimer) clearTimeout(prevTimer);
1457
+ const t = setTimeout(() => {
1458
+ validationTimersByField.delete(norm);
1459
+ validateField(norm).catch(() => {});
1460
+ }, vCfg.debounceMs);
1461
+ validationTimersByField.set(norm, t);
1462
+ }
1463
+ }
1095
1464
  }
1096
1465
  }
1097
1466
  if (isDraftEnabled) {
@@ -1104,16 +1473,40 @@ function createFormHandle(formEl, options = {}) {
1104
1473
  notifyStateChange();
1105
1474
  };
1106
1475
 
1476
+ const onDomFocusOut = (evt) => {
1477
+ const target = evt && evt.target;
1478
+ if (!target || !target.getAttribute) return;
1479
+ const rawName = target.getAttribute('name');
1480
+ if (!rawName) return;
1481
+ const type = (target.type || '').toLowerCase();
1482
+ if (type === 'submit' || type === 'button' || type === 'reset' || type === 'hidden') return;
1483
+ const norm = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
1484
+ const wasTouched = touchedFields.has(norm);
1485
+ touchedFields.add(norm);
1486
+ if (!wasTouched) {
1487
+ syncErrorsToDom(false);
1488
+ notifyStateChange();
1489
+ }
1490
+ const vCfg = parseFieldValidateConfig(target);
1491
+ if (vCfg && vCfg.onBlur) {
1492
+ validateField(norm).catch(() => {});
1493
+ }
1494
+ };
1495
+
1107
1496
  const onDomSubmit = (evt) => {
1108
1497
  submit(evt);
1109
1498
  };
1110
1499
 
1111
1500
  const onDomReset = () => {
1112
1501
  cancelDraftDebounce();
1502
+ cancelFieldValidation();
1503
+ revokePreviewUrls();
1113
1504
  if (resetTimer) clearTimeout(resetTimer);
1114
1505
  resetTimer = setTimeout(() => {
1115
1506
  resetTimer = null;
1116
1507
  dirty = false;
1508
+ submitted = false;
1509
+ touchedFields.clear();
1117
1510
  fieldErrors = {};
1118
1511
  formError = null;
1119
1512
  syncErrorsToDom(false);
@@ -1124,6 +1517,7 @@ function createFormHandle(formEl, options = {}) {
1124
1517
  if (formEl && typeof formEl.addEventListener === 'function') {
1125
1518
  formEl.addEventListener('input', onDomInput);
1126
1519
  formEl.addEventListener('change', onDomInput);
1520
+ formEl.addEventListener('focusout', onDomFocusOut);
1127
1521
  formEl.addEventListener('submit', onDomSubmit);
1128
1522
  formEl.addEventListener('reset', onDomReset);
1129
1523
  }
@@ -1149,6 +1543,54 @@ function createFormHandle(formEl, options = {}) {
1149
1543
  dirty = computeFormDirty(formEl, initialSnapshot);
1150
1544
  return dirty;
1151
1545
  },
1546
+ get touched() {
1547
+ return touchedFields.size > 0;
1548
+ },
1549
+ get touchedFields() {
1550
+ return Array.from(touchedFields);
1551
+ },
1552
+ get submitted() {
1553
+ return submitted;
1554
+ },
1555
+ isTouched(fieldName) {
1556
+ if (!fieldName) return touchedFields.size > 0;
1557
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1558
+ return touchedFields.has(norm) || touchedFields.has(fieldName);
1559
+ },
1560
+ touch(fieldName) {
1561
+ if (fieldName) {
1562
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1563
+ touchedFields.add(norm);
1564
+ } else if (formEl && typeof formEl.querySelectorAll === 'function') {
1565
+ const controls = formEl.querySelectorAll('input, select, textarea');
1566
+ for (let i = 0; i < controls.length; i++) {
1567
+ const n = controls[i].getAttribute && controls[i].getAttribute('name');
1568
+ if (n) touchedFields.add(n.endsWith('[]') ? n.slice(0, -2) : n);
1569
+ }
1570
+ }
1571
+ syncErrorsToDom(false);
1572
+ notifyStateChange();
1573
+ return handle;
1574
+ },
1575
+ untouch(fieldName) {
1576
+ if (fieldName) {
1577
+ const norm = fieldName.endsWith('[]') ? fieldName.slice(0, -2) : fieldName;
1578
+ touchedFields.delete(norm);
1579
+ touchedFields.delete(fieldName);
1580
+ } else {
1581
+ touchedFields.clear();
1582
+ }
1583
+ syncErrorsToDom(false);
1584
+ notifyStateChange();
1585
+ return handle;
1586
+ },
1587
+ validateField,
1588
+ get uploads() {
1589
+ return uploadEntries.map((u) => ({ ...u }));
1590
+ },
1591
+ cancelUpload,
1592
+ createPreviewUrl,
1593
+ revokePreviewUrls,
1152
1594
  get inputRevision() {
1153
1595
  return inputRevision;
1154
1596
  },
@@ -1249,6 +1691,9 @@ function createFormHandle(formEl, options = {}) {
1249
1691
  },
1250
1692
  destroy() {
1251
1693
  cancelDraftDebounce();
1694
+ cancelFieldValidation();
1695
+ cancelUpload();
1696
+ revokePreviewUrls();
1252
1697
  if (resetTimer) {
1253
1698
  clearTimeout(resetTimer);
1254
1699
  resetTimer = null;
@@ -1256,6 +1701,7 @@ function createFormHandle(formEl, options = {}) {
1256
1701
  if (formEl && typeof formEl.removeEventListener === 'function') {
1257
1702
  formEl.removeEventListener('input', onDomInput);
1258
1703
  formEl.removeEventListener('change', onDomInput);
1704
+ formEl.removeEventListener('focusout', onDomFocusOut);
1259
1705
  formEl.removeEventListener('submit', onDomSubmit);
1260
1706
  formEl.removeEventListener('reset', onDomReset);
1261
1707
  }