webspresso 0.0.111 → 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
  /**
@@ -432,16 +447,107 @@ function createFormHandle(formEl, options = {}) {
432
447
  'form';
433
448
 
434
449
  let initialSnapshot = captureInitialFormSnapshot(formEl);
450
+ let baselineValues = parseFormElement(formEl).data;
435
451
  let pending = false;
436
452
  let dirty = false;
453
+ let submitted = false;
454
+ let inputRevision = 0;
437
455
  let fieldErrors = {};
438
456
  let formError = null;
457
+ let lastError = null;
439
458
  let lastData = null;
459
+ let draftDebounceTimer = null;
460
+ let resetTimer = null;
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
+ }
440
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
+
539
+ const beforeSubmitListeners = new Set();
441
540
  const successListeners = new Set();
442
541
  const errorListeners = new Set();
443
542
  const stateListeners = new Set();
444
543
 
544
+ function cancelDraftDebounce() {
545
+ if (draftDebounceTimer) {
546
+ clearTimeout(draftDebounceTimer);
547
+ draftDebounceTimer = null;
548
+ }
549
+ }
550
+
445
551
  // Form draft persistence setup (sessionStorage by default, or localStorage)
446
552
  const rawDraftAttr = formEl
447
553
  ? formEl.getAttribute('data-w-persist-draft') || formEl.getAttribute('data-w-draft')
@@ -458,11 +564,18 @@ function createFormHandle(formEl, options = {}) {
458
564
  draftStorageType = 'local';
459
565
  }
460
566
 
461
- const draftKey =
567
+ const draftScope =
568
+ options.draftScope ||
569
+ (formEl && formEl.getAttribute && formEl.getAttribute('data-w-draft-scope')) ||
570
+ '';
571
+
572
+ const baseDraftKey =
462
573
  options.draftKey ||
463
574
  (formEl && (formEl.getAttribute('data-w-draft-key') || formEl.getAttribute('data-w-persist-key'))) ||
464
575
  `form_draft:${formName || 'anon'}`;
465
576
 
577
+ let draftKey = draftScope ? `${baseDraftKey}:${draftScope}` : baseDraftKey;
578
+
466
579
  const draftStorage =
467
580
  (options.storage && options.storage[draftStorageType]) ||
468
581
  (draftStorageType === 'local' ? defaultStorage.local : defaultStorage.session);
@@ -485,7 +598,22 @@ function createFormHandle(formEl, options = {}) {
485
598
  return safeData;
486
599
  }
487
600
 
601
+ function sanitizeDraftObject(raw) {
602
+ if (!raw || typeof raw !== 'object' || isArray(raw)) return null;
603
+ const clean = {};
604
+ for (const [k, v] of Object.entries(raw)) {
605
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
606
+ if (v === null || v === undefined || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
607
+ clean[k] = v;
608
+ } else if (isArray(v) && v.every((item) => typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean')) {
609
+ clean[k] = v;
610
+ }
611
+ }
612
+ return Object.keys(clean).length > 0 ? clean : null;
613
+ }
614
+
488
615
  function saveDraft() {
616
+ cancelDraftDebounce();
489
617
  if (!formEl || !isDraftEnabled) return false;
490
618
  const draftPayload = extractDraftValues(formEl);
491
619
  return draftStorage.set(draftKey, draftPayload);
@@ -493,9 +621,15 @@ function createFormHandle(formEl, options = {}) {
493
621
 
494
622
  function restoreDraft() {
495
623
  if (!formEl || !isDraftEnabled) return false;
496
- const saved = draftStorage.get(draftKey, null);
497
- if (saved && typeof saved === 'object' && Object.keys(saved).length > 0) {
498
- fill(saved, { baseline: false });
624
+ let saved = null;
625
+ try {
626
+ saved = draftStorage.get(draftKey, null);
627
+ } catch (_e) {
628
+ saved = null;
629
+ }
630
+ const cleanDraft = sanitizeDraftObject(saved);
631
+ if (cleanDraft) {
632
+ fill(cleanDraft, { baseline: false });
499
633
  if (typeof formEl.dispatchEvent === 'function') {
500
634
  const CustomEventCtor =
501
635
  (formEl.ownerDocument && formEl.ownerDocument.defaultView && formEl.ownerDocument.defaultView.CustomEvent) ||
@@ -505,7 +639,7 @@ function createFormHandle(formEl, options = {}) {
505
639
  new CustomEventCtor('w-form:draft-restored', {
506
640
  bubbles: true,
507
641
  cancelable: true,
508
- detail: { form: handle, draft: saved, key: draftKey },
642
+ detail: { form: handle, draft: cleanDraft, key: draftKey },
509
643
  })
510
644
  );
511
645
  }
@@ -516,6 +650,7 @@ function createFormHandle(formEl, options = {}) {
516
650
  }
517
651
 
518
652
  function clearDraft() {
653
+ cancelDraftDebounce();
519
654
  if (!isDraftEnabled) return false;
520
655
  return draftStorage.remove(draftKey);
521
656
  }
@@ -525,6 +660,20 @@ function createFormHandle(formEl, options = {}) {
525
660
  return draftStorage.has(draftKey);
526
661
  }
527
662
 
663
+ function setDraftKey(nextKey, keyOpts = {}) {
664
+ cancelDraftDebounce();
665
+ if (typeof nextKey === 'string' && nextKey.trim()) {
666
+ draftKey = nextKey.trim();
667
+ if (formEl && typeof formEl.setAttribute === 'function') {
668
+ formEl.setAttribute('data-w-draft-key', draftKey);
669
+ }
670
+ if (keyOpts && keyOpts.restore === true && isDraftEnabled) {
671
+ restoreDraft();
672
+ }
673
+ }
674
+ return handle;
675
+ }
676
+
528
677
  function notifyStateChange() {
529
678
  for (const fn of stateListeners) {
530
679
  try {
@@ -535,6 +684,12 @@ function createFormHandle(formEl, options = {}) {
535
684
  }
536
685
  }
537
686
 
687
+ function isFieldErrorVisible(fieldName) {
688
+ if (errorDisplayMode !== 'touched') return true;
689
+ if (submitted) return true;
690
+ return touchedFields.has(fieldName);
691
+ }
692
+
538
693
  /**
539
694
  * Render field errors and form-level error into the DOM with accessible ARIA bindings.
540
695
  * Optionally focuses the first invalid control.
@@ -547,7 +702,11 @@ function createFormHandle(formEl, options = {}) {
547
702
  for (let i = 0; i < errorSpans.length; i++) {
548
703
  const span = errorSpans[i];
549
704
  const fieldName = span.getAttribute('data-w-error');
550
- 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 : '';
551
710
  span.textContent = msg;
552
711
  if (msg) {
553
712
  span.removeAttribute('hidden');
@@ -570,7 +729,8 @@ function createFormHandle(formEl, options = {}) {
570
729
  const rawName = ctrl.getAttribute('name');
571
730
  if (!rawName) continue;
572
731
  const normName = rawName.endsWith('[]') ? rawName.slice(0, -2) : rawName;
573
- const errMsg = fieldErrors[normName] || fieldErrors[rawName];
732
+ const rawErrMsg = fieldErrors[normName] || fieldErrors[rawName];
733
+ const errMsg = rawErrMsg && (isFieldErrorVisible(normName) || isFieldErrorVisible(rawName)) ? rawErrMsg : '';
574
734
 
575
735
  if (errMsg) {
576
736
  ctrl.setAttribute('aria-invalid', 'true');
@@ -654,6 +814,9 @@ function createFormHandle(formEl, options = {}) {
654
814
  * Reset form controls back to their initial default values (NOT emptying non-empty defaults).
655
815
  */
656
816
  function reset() {
817
+ cancelDraftDebounce();
818
+ cancelFieldValidation();
819
+ revokePreviewUrls();
657
820
  if (!formEl) return;
658
821
  if (typeof formEl.reset === 'function') {
659
822
  formEl.reset();
@@ -673,6 +836,8 @@ function createFormHandle(formEl, options = {}) {
673
836
  }
674
837
  }
675
838
  dirty = false;
839
+ submitted = false;
840
+ touchedFields.clear();
676
841
  fieldErrors = {};
677
842
  formError = null;
678
843
  syncErrorsToDom(false);
@@ -683,6 +848,9 @@ function createFormHandle(formEl, options = {}) {
683
848
  * Completely clear all user-editable controls to empty values (distinct from `reset()`).
684
849
  */
685
850
  function clear() {
851
+ cancelDraftDebounce();
852
+ cancelFieldValidation();
853
+ revokePreviewUrls();
686
854
  if (!formEl || typeof formEl.querySelectorAll !== 'function') return;
687
855
  const controls = formEl.querySelectorAll('input, select, textarea');
688
856
  for (let i = 0; i < controls.length; i++) {
@@ -704,6 +872,8 @@ function createFormHandle(formEl, options = {}) {
704
872
  }
705
873
  }
706
874
  dirty = computeFormDirty(formEl, initialSnapshot);
875
+ submitted = false;
876
+ touchedFields.clear();
707
877
  fieldErrors = {};
708
878
  formError = null;
709
879
  syncErrorsToDom(false);
@@ -719,11 +889,14 @@ function createFormHandle(formEl, options = {}) {
719
889
  * snapshot, clears `dirty`, and clears previous validation errors.
720
890
  */
721
891
  function fill(values = {}, options = {}) {
722
- if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object') {
892
+ if (!formEl || typeof formEl.querySelectorAll !== 'function' || !values || typeof values !== 'object' || isArray(values)) {
723
893
  return handle;
724
894
  }
725
895
 
726
896
  const isBaseline = Boolean(options.baseline);
897
+ if (isBaseline) {
898
+ cancelDraftDebounce();
899
+ }
727
900
 
728
901
  for (const [key, val] of Object.entries(values)) {
729
902
  if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
@@ -757,6 +930,7 @@ function createFormHandle(formEl, options = {}) {
757
930
  ctrl.value = val === null || val === undefined ? '' : String(val);
758
931
  }
759
932
  } else {
933
+ if (val !== null && typeof val === 'object') continue;
760
934
  ctrl.value = val === null || val === undefined ? '' : String(val);
761
935
  }
762
936
 
@@ -767,10 +941,15 @@ function createFormHandle(formEl, options = {}) {
767
941
  }
768
942
 
769
943
  if (isBaseline) {
944
+ cancelFieldValidation();
770
945
  initialSnapshot = captureInitialFormSnapshot(formEl);
946
+ baselineValues = parseFormElement(formEl).data;
771
947
  dirty = false;
948
+ submitted = false;
949
+ touchedFields.clear();
772
950
  fieldErrors = {};
773
951
  formError = null;
952
+ lastError = null;
774
953
  syncErrorsToDom(false);
775
954
  } else {
776
955
  dirty = computeFormDirty(formEl, initialSnapshot);
@@ -783,7 +962,9 @@ function createFormHandle(formEl, options = {}) {
783
962
 
784
963
  function setAction(url) {
785
964
  if (formEl && url) {
786
- formEl.setAttribute('action', String(url));
965
+ const nextAction = String(url);
966
+ formEl.setAttribute('action', nextAction);
967
+ formEl.__wCustomAction = nextAction;
787
968
  }
788
969
  return handle;
789
970
  }
@@ -796,6 +977,7 @@ function createFormHandle(formEl, options = {}) {
796
977
  action = action.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
797
978
  }
798
979
  formEl.setAttribute('action', action);
980
+ formEl.__wCustomAction = action;
799
981
  return handle;
800
982
  }
801
983
 
@@ -870,12 +1052,60 @@ function createFormHandle(formEl, options = {}) {
870
1052
 
871
1053
  const { data: payloadData, formData, isMultipart } = parseFormElement(formEl, submitter);
872
1054
 
873
- // Clear previous errors before request starts
1055
+ // Execute beforeSubmit hooks before initiating network request
1056
+ for (const guardFn of beforeSubmitListeners) {
1057
+ let allowed = true;
1058
+ try {
1059
+ allowed = await guardFn(payloadData, { form: handle, submitter, action: actionUrl, method });
1060
+ } catch (guardErr) {
1061
+ return { ok: false, submitted: false, canceled: true, reason: 'before-submit-error', error: guardErr };
1062
+ }
1063
+ if (allowed === false) {
1064
+ return { ok: false, submitted: false, canceled: true, reason: 'before-submit' };
1065
+ }
1066
+ }
1067
+
1068
+ // Clear previous errors before request starts, mark submitted = true
1069
+ cancelFieldValidation();
1070
+ submitted = true;
874
1071
  fieldErrors = {};
875
1072
  formError = null;
1073
+ lastError = null;
876
1074
  syncErrorsToDom(false);
1075
+ const submitRevision = inputRevision;
1076
+ const submittedSnapshot = captureInitialFormSnapshot(formEl);
877
1077
  setPendingState(true, submitter);
878
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
+
879
1109
  try {
880
1110
  let responseData;
881
1111
  const reqKey = `form:${formName}:${method}:${actionUrl}`;
@@ -883,17 +1113,39 @@ function createFormHandle(formEl, options = {}) {
883
1113
  responseData = await api.get(actionUrl, payloadData, {
884
1114
  key: reqKey,
885
1115
  policy: 'latest',
1116
+ signal: submitAbortController.signal,
886
1117
  });
887
1118
  } else {
888
1119
  const bodyToSend = isMultipart && formData ? formData : payloadData;
889
- responseData = await api.request(method, actionUrl, bodyToSend, {
1120
+ const reqOpts = {
890
1121
  key: reqKey,
891
1122
  policy: 'drop',
892
- });
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
+ }
893
1146
  }
894
1147
 
895
1148
  lastData = responseData;
896
- dirty = false;
897
1149
  fieldErrors = {};
898
1150
  formError = null;
899
1151
  syncErrorsToDom(false);
@@ -914,17 +1166,51 @@ function createFormHandle(formEl, options = {}) {
914
1166
  clearAttr === 'true' ||
915
1167
  clearAttr === '1';
916
1168
 
917
- if (shouldClear) {
918
- clear();
919
- } else if (shouldReset) {
920
- reset();
921
- } else {
922
- // Current state becomes the new clean baseline after successful save
923
- initialSnapshot = captureInitialFormSnapshot(formEl);
924
- }
1169
+ const editedDuringSubmit = inputRevision !== submitRevision;
925
1170
 
926
- if (isDraftEnabled) {
927
- clearDraft();
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
+ }
1182
+
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
+ }
928
1214
  }
929
1215
 
930
1216
  // Invoke registered success hooks. Note: Any background fragment refresh failure
@@ -943,11 +1229,12 @@ function createFormHandle(formEl, options = {}) {
943
1229
  }
944
1230
 
945
1231
  // Explicit redirect behavior
946
- const redirectTarget =
1232
+ const rawRedirectTarget =
947
1233
  submitOptions.redirectOnSuccess ||
948
1234
  (formEl && formEl.getAttribute('data-w-redirect')) ||
949
1235
  null;
950
- if (redirectTarget && isSafeRedirectUrl(redirectTarget)) {
1236
+ const redirectTarget = resolveSafeRedirectUrl(rawRedirectTarget);
1237
+ if (redirectTarget) {
951
1238
  if (typeof options.navigate === 'function') {
952
1239
  options.navigate(redirectTarget);
953
1240
  } else if (typeof window !== 'undefined' && window.location) {
@@ -967,6 +1254,16 @@ function createFormHandle(formEl, options = {}) {
967
1254
 
968
1255
  return { ok: true, submitted: true, data: responseData, refreshOk, refreshError, hookError };
969
1256
  } catch (err) {
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
+ }
970
1267
  if (err && err.kind === 'validation') {
971
1268
  fieldErrors = { ...(err.fieldErrors || {}) };
972
1269
  formError = err.formError || (Object.keys(fieldErrors).length === 0 ? err.message : null);
@@ -989,44 +1286,227 @@ function createFormHandle(formEl, options = {}) {
989
1286
  if (submitOptions.throwOnError) {
990
1287
  throw err;
991
1288
  }
992
- 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
+ };
993
1296
  } finally {
1297
+ if (activeSubmitAbort === submitAbortController) {
1298
+ activeSubmitAbort = null;
1299
+ }
994
1300
  setPendingState(false, submitter);
995
1301
  }
996
1302
  }
997
1303
 
998
- // Listen to native submit and input/change events on `formEl`
999
- let draftDebounceTimer = null;
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`
1000
1432
  const onDomInput = (evt) => {
1433
+ inputRevision += 1;
1001
1434
  dirty = computeFormDirty(formEl, initialSnapshot);
1002
1435
  const target = evt && evt.target;
1003
1436
  if (target && target.getAttribute) {
1437
+ const type = (target.type || '').toLowerCase();
1004
1438
  const rawName = target.getAttribute('name');
1439
+ if (type === 'file' && evt.type === 'change' && rawName) {
1440
+ revokePreviewUrls(rawName);
1441
+ }
1005
1442
  if (rawName) {
1006
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
+ }
1007
1447
  if (fieldErrors[norm] || fieldErrors[rawName]) {
1008
1448
  delete fieldErrors[norm];
1009
1449
  delete fieldErrors[rawName];
1010
1450
  syncErrorsToDom(false);
1011
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
+ }
1012
1464
  }
1013
1465
  }
1014
1466
  if (isDraftEnabled) {
1015
- if (draftDebounceTimer) clearTimeout(draftDebounceTimer);
1467
+ cancelDraftDebounce();
1016
1468
  draftDebounceTimer = setTimeout(() => {
1469
+ draftDebounceTimer = null;
1017
1470
  saveDraft();
1018
1471
  }, 150);
1019
1472
  }
1020
1473
  notifyStateChange();
1021
1474
  };
1022
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
+
1023
1496
  const onDomSubmit = (evt) => {
1024
1497
  submit(evt);
1025
1498
  };
1026
1499
 
1027
1500
  const onDomReset = () => {
1028
- setTimeout(() => {
1501
+ cancelDraftDebounce();
1502
+ cancelFieldValidation();
1503
+ revokePreviewUrls();
1504
+ if (resetTimer) clearTimeout(resetTimer);
1505
+ resetTimer = setTimeout(() => {
1506
+ resetTimer = null;
1029
1507
  dirty = false;
1508
+ submitted = false;
1509
+ touchedFields.clear();
1030
1510
  fieldErrors = {};
1031
1511
  formError = null;
1032
1512
  syncErrorsToDom(false);
@@ -1037,6 +1517,7 @@ function createFormHandle(formEl, options = {}) {
1037
1517
  if (formEl && typeof formEl.addEventListener === 'function') {
1038
1518
  formEl.addEventListener('input', onDomInput);
1039
1519
  formEl.addEventListener('change', onDomInput);
1520
+ formEl.addEventListener('focusout', onDomFocusOut);
1040
1521
  formEl.addEventListener('submit', onDomSubmit);
1041
1522
  formEl.addEventListener('reset', onDomReset);
1042
1523
  }
@@ -1062,9 +1543,66 @@ function createFormHandle(formEl, options = {}) {
1062
1543
  dirty = computeFormDirty(formEl, initialSnapshot);
1063
1544
  return dirty;
1064
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,
1594
+ get inputRevision() {
1595
+ return inputRevision;
1596
+ },
1597
+ get draftKey() {
1598
+ return draftKey;
1599
+ },
1065
1600
  get values() {
1066
1601
  return parseFormElement(formEl).data;
1067
1602
  },
1603
+ get baseline() {
1604
+ return { ...(baselineValues || {}) };
1605
+ },
1068
1606
  get errors() {
1069
1607
  return { ...fieldErrors };
1070
1608
  },
@@ -1074,6 +1612,9 @@ function createFormHandle(formEl, options = {}) {
1074
1612
  get formError() {
1075
1613
  return formError;
1076
1614
  },
1615
+ get lastError() {
1616
+ return lastError;
1617
+ },
1077
1618
  get lastData() {
1078
1619
  return lastData;
1079
1620
  },
@@ -1093,6 +1634,7 @@ function createFormHandle(formEl, options = {}) {
1093
1634
  saveDraft,
1094
1635
  restoreDraft,
1095
1636
  clearDraft,
1637
+ setDraftKey,
1096
1638
  get hasDraft() {
1097
1639
  return hasDraft();
1098
1640
  },
@@ -1112,12 +1654,20 @@ function createFormHandle(formEl, options = {}) {
1112
1654
  clearErrors() {
1113
1655
  fieldErrors = {};
1114
1656
  formError = null;
1657
+ lastError = null;
1115
1658
  syncErrorsToDom(false);
1116
1659
  notifyStateChange();
1117
1660
  },
1118
1661
  reset,
1119
1662
  clear,
1120
1663
  submit,
1664
+ beforeSubmit(fn) {
1665
+ if (typeof fn === 'function') {
1666
+ beforeSubmitListeners.add(fn);
1667
+ return () => beforeSubmitListeners.delete(fn);
1668
+ }
1669
+ return () => {};
1670
+ },
1121
1671
  onSuccess(fn) {
1122
1672
  if (typeof fn === 'function') {
1123
1673
  successListeners.add(fn);
@@ -1140,15 +1690,25 @@ function createFormHandle(formEl, options = {}) {
1140
1690
  return () => {};
1141
1691
  },
1142
1692
  destroy() {
1693
+ cancelDraftDebounce();
1694
+ cancelFieldValidation();
1695
+ cancelUpload();
1696
+ revokePreviewUrls();
1697
+ if (resetTimer) {
1698
+ clearTimeout(resetTimer);
1699
+ resetTimer = null;
1700
+ }
1143
1701
  if (formEl && typeof formEl.removeEventListener === 'function') {
1144
1702
  formEl.removeEventListener('input', onDomInput);
1145
1703
  formEl.removeEventListener('change', onDomInput);
1704
+ formEl.removeEventListener('focusout', onDomFocusOut);
1146
1705
  formEl.removeEventListener('submit', onDomSubmit);
1147
1706
  formEl.removeEventListener('reset', onDomReset);
1148
1707
  }
1149
1708
  if (formEl) {
1150
1709
  delete formEl.__wFormHandle;
1151
1710
  }
1711
+ beforeSubmitListeners.clear();
1152
1712
  successListeners.clear();
1153
1713
  errorListeners.clear();
1154
1714
  stateListeners.clear();