@verbb/formie-browser 1.0.14 → 1.0.15
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/CHANGELOG.md +30 -0
- package/dist/chunks/{address-country-Dn71jroa.js → address-country-BcEQKw0T.js} +1 -1
- package/dist/chunks/{address-finder-O49uDRoR.js → address-finder-BWGINCSA.js} +1 -1
- package/dist/chunks/{address-state-ByYatUur.js → address-state-DI3ndhMU.js} +98 -88
- package/dist/chunks/{api-DYYsVIub.js → api-BK9b340y.js} +16 -16
- package/dist/chunks/{api-D6kJ7kXZ.js → api-Dxl_CKw-.js} +30 -25
- package/dist/chunks/{async-nPFRNQ06.js → async-2GdXNFCE.js} +18 -1
- package/dist/chunks/{bpoint-D0RKFM4F.js → bpoint-BwtuyN_O.js} +1 -1
- package/dist/chunks/{captcha-eu-DERJbXnH.js → captcha-eu-DWLoQFKB.js} +2 -2
- package/dist/chunks/{combobox-CWNdx4Fk.js → combobox-Ba08_cHL.js} +259 -258
- package/dist/chunks/{conditions-DqNHLTbN.js → conditions-Ceeg6ReZ.js} +113 -110
- package/dist/chunks/{date-picker-RUnevF7l.js → date-picker-DaPvMrop.js} +12 -11
- package/dist/chunks/{eway-or6dtjoT.js → eway-uw0DzU-L.js} +3 -3
- package/dist/chunks/{file-upload-Bm-TXyNB.js → file-upload-B8_mH012.js} +28 -16
- package/dist/chunks/{friendly-captcha-v1-GFqCt1Mh.js → friendly-captcha-v1-CVXaXgDO.js} +1 -1
- package/dist/chunks/{friendly-captcha-v2-WoW246JG.js → friendly-captcha-v2-DkeIVTKI.js} +1 -1
- package/dist/chunks/{go-cardless-DzJ-0FKg.js → go-cardless-c4wLJdOt.js} +1 -1
- package/dist/chunks/{google-address-C1P1nTDM.js → google-address-JHYvjwgN.js} +13 -8
- package/dist/chunks/{hcaptcha-D0T8aIHz.js → hcaptcha-Dt3ELLlP.js} +2 -2
- package/dist/chunks/{loqate-DD1S7PY9.js → loqate-m51iPyO1.js} +1 -1
- package/dist/chunks/{mollie-DG8Ju83x.js → mollie-Bn0PX173.js} +1 -1
- package/dist/chunks/{moneris-C8m3Fvqu.js → moneris-BRbUvI4t.js} +1 -1
- package/dist/chunks/{opayo-B3EvrVxw.js → opayo-BwWdInEP.js} +2 -2
- package/dist/chunks/{paddle-BIYR9Um3.js → paddle-CympHVvd.js} +2 -2
- package/dist/chunks/{paypal-B_yxIo2d.js → paypal-h75h6wae.js} +2 -2
- package/dist/chunks/{payway-CeOzS0sL.js → payway-C9rHTCC_.js} +2 -2
- package/dist/chunks/{phone-country-bAqje17P.js → phone-country-B8fv-L5_.js} +1 -1
- package/dist/chunks/{place-kit-BE5gi3vo.js → place-kit-BnZGQ-L1.js} +8 -8
- package/dist/chunks/{recaptcha-enterprise-CruXddZq.js → recaptcha-enterprise-CUs8BO6V.js} +2 -2
- package/dist/chunks/{recaptcha-shared-AQRMDMme.js → recaptcha-shared-DTbTw-MF.js} +2 -2
- package/dist/chunks/{recaptcha-v2-checkbox-CCnJYCnG.js → recaptcha-v2-checkbox-P1McW4Pv.js} +2 -2
- package/dist/chunks/{recaptcha-v2-invisible-BuVqRRYn.js → recaptcha-v2-invisible-FGDqozSP.js} +2 -2
- package/dist/chunks/{recaptcha-v3-CloqhO2A.js → recaptcha-v3-CflUHeZD.js} +2 -2
- package/dist/chunks/{repeater-DwE27jI1.js → repeater-CyhDbozO.js} +47 -33
- package/dist/chunks/{scripts-CbQ7agX3.js → scripts-DrccjrFv.js} +1 -1
- package/dist/chunks/skip-CEW89ExY.js +7 -0
- package/dist/chunks/{snaptcha-C04kNGYQ.js → snaptcha-C3QLYxFT.js} +1 -1
- package/dist/chunks/{square-DEzxv9D9.js → square-C1z5DPVw.js} +2 -2
- package/dist/chunks/{stripe-CVPQGDz3.js → stripe-Dmkdkq1y.js} +2 -2
- package/dist/chunks/{summary-BF_8mrXI.js → summary-Bn-g5SDx.js} +1 -1
- package/dist/chunks/{table-DnIBdeKF.js → table-j7MlLLCc.js} +1 -1
- package/dist/chunks/{turnstile-n9DNyPxi.js → turnstile-ptihQfLl.js} +2 -2
- package/dist/chunks/{upload-manager-BbiUosZq.js → upload-manager-BW7l-BhF.js} +248 -236
- package/dist/index.js +548 -535
- package/dist/js/core/create-formie-client.d.ts.map +1 -1
- package/dist/js/core/submit-result-state.d.ts.map +1 -1
- package/dist/js/core/submit-result-ui.d.ts +3 -1
- package/dist/js/core/submit-result-ui.d.ts.map +1 -1
- package/dist/js/modules/address/google-address.d.ts +10 -0
- package/dist/js/modules/address/google-address.d.ts.map +1 -1
- package/dist/js/modules/address/place-kit.d.ts.map +1 -1
- package/dist/js/modules/fields/address-state.d.ts.map +1 -1
- package/dist/js/modules/fields/combobox.d.ts.map +1 -1
- package/dist/js/modules/fields/conditions/effects.d.ts.map +1 -1
- package/dist/js/modules/fields/conditions.d.ts.map +1 -1
- package/dist/js/modules/fields/date-picker.d.ts.map +1 -1
- package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
- package/dist/js/modules/fields/repeater.d.ts.map +1 -1
- package/dist/js/modules/fields/upload-manager.d.ts.map +1 -1
- package/dist/js/utils/async.d.ts +1 -0
- package/dist/js/utils/async.d.ts.map +1 -1
- package/dist/js/validation/rules/required.d.ts.map +1 -1
- package/dist/js/validation/validator.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/js/core/create-formie-client.ts +17 -10
- package/src/js/core/submit-result-state.ts +17 -0
- package/src/js/core/submit-result-ui.ts +4 -3
- package/src/js/modules/address/google-address.ts +95 -37
- package/src/js/modules/address/place-kit.ts +13 -7
- package/src/js/modules/fields/address-country.ts +5 -5
- package/src/js/modules/fields/address-state.ts +74 -23
- package/src/js/modules/fields/combobox.ts +3 -0
- package/src/js/modules/fields/conditions/effects.ts +4 -5
- package/src/js/modules/fields/conditions.ts +6 -0
- package/src/js/modules/fields/date-picker.ts +6 -3
- package/src/js/modules/fields/file-upload.ts +44 -14
- package/src/js/modules/fields/phone-country.ts +2 -2
- package/src/js/modules/fields/repeater.ts +55 -4
- package/src/js/modules/fields/upload-manager.ts +54 -18
- package/src/js/utils/async.ts +34 -0
- package/src/js/validation/rules/required.ts +12 -0
- package/src/js/validation/validator.ts +14 -2
|
@@ -58,6 +58,9 @@ type ComboboxSelect = HTMLSelectElement & {
|
|
|
58
58
|
|
|
59
59
|
type FieldState = {
|
|
60
60
|
addressRoot: HTMLElement;
|
|
61
|
+
isCpEdit: boolean;
|
|
62
|
+
initialCountry: string;
|
|
63
|
+
initialValue: string;
|
|
61
64
|
stateField: HTMLElement;
|
|
62
65
|
stateControl: AddressStateControl;
|
|
63
66
|
countryControl: HTMLInputElement | HTMLSelectElement | null;
|
|
@@ -105,7 +108,8 @@ function getStateControl(field: HTMLElement): AddressStateControl | null {
|
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
function getFieldLabel(field: HTMLElement): HTMLElement | null {
|
|
108
|
-
return field.querySelector('[data-formie-field-label]')
|
|
111
|
+
return field.querySelector('[data-formie-field-label]')
|
|
112
|
+
|| (field.querySelector('[data-formie-address-state-cp]') ? field.querySelector(':scope > .heading > label') : null);
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
function dispatchControlEvents(control: AddressStateControl): void {
|
|
@@ -130,13 +134,15 @@ function syncComboboxValue(control: AddressStateControl, value: string): void {
|
|
|
130
134
|
}
|
|
131
135
|
|
|
132
136
|
function setControlValue(control: AddressStateControl, value: string): void {
|
|
133
|
-
|
|
134
|
-
|
|
137
|
+
const previousValue = control.value;
|
|
138
|
+
control.value = value;
|
|
139
|
+
syncComboboxValue(control, control.value);
|
|
140
|
+
|
|
141
|
+
// A select rejects values outside its options. Dispatch only actual changes
|
|
142
|
+
// so an unmatched autofill value cannot recursively trigger reconciliation.
|
|
143
|
+
if (control.value !== previousValue) {
|
|
135
144
|
dispatchControlEvents(control);
|
|
136
|
-
return;
|
|
137
145
|
}
|
|
138
|
-
|
|
139
|
-
syncComboboxValue(control, value);
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
function syncAutofillAnchor(fieldState: FieldState, value: string): void {
|
|
@@ -181,6 +187,7 @@ function ensureAutofillAnchor(fieldState: FieldState): HTMLInputElement {
|
|
|
181
187
|
anchor.setAttribute('tabindex', '-1');
|
|
182
188
|
anchor.setAttribute('aria-hidden', 'true');
|
|
183
189
|
anchor.className = 'formie-sr-only';
|
|
190
|
+
anchor.hidden = fieldState.isCpEdit;
|
|
184
191
|
fieldState.addressRoot.appendChild(anchor);
|
|
185
192
|
fieldState.autofillAnchor = anchor;
|
|
186
193
|
}
|
|
@@ -204,14 +211,13 @@ function reconcileAutofillValue(fieldState: FieldState): void {
|
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
const subdivisions = fieldState.lastSubdivisions;
|
|
207
|
-
const resolved =
|
|
208
|
-
? (resolveOptionMatch(pending, subdivisions) ||
|
|
214
|
+
const resolved = fieldState.stateControl instanceof HTMLSelectElement
|
|
215
|
+
? (resolveOptionMatch(pending, subdivisions) || '')
|
|
209
216
|
: pending;
|
|
210
217
|
|
|
211
|
-
setControlValue(fieldState.stateControl, resolved);
|
|
212
|
-
syncComboboxValue(fieldState.stateControl, resolved);
|
|
213
218
|
syncAutofillAnchor(fieldState, resolved);
|
|
214
219
|
fieldState.pendingStateValue = resolved;
|
|
220
|
+
setControlValue(fieldState.stateControl, resolved);
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
function buildSubdivisionsUrl(action: string, country: string, optionLabel: string, optionValue: string): string {
|
|
@@ -320,6 +326,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
|
|
|
320
326
|
'data-formie-input-type',
|
|
321
327
|
'data-formie-input-error-state',
|
|
322
328
|
'data-formie-address-state-dynamic',
|
|
329
|
+
'data-formie-address-state-cp',
|
|
323
330
|
'data-formie-address-state-hide-when-unused',
|
|
324
331
|
'data-formie-address-state-use-searchable',
|
|
325
332
|
'data-formie-address-state-use-datalist',
|
|
@@ -340,7 +347,7 @@ function copyControlAttributes(source: AddressStateControl, target: AddressState
|
|
|
340
347
|
}
|
|
341
348
|
|
|
342
349
|
function applyTextThemeClasses(template: AddressStateControl, input: HTMLInputElement): void {
|
|
343
|
-
input.className = template.className;
|
|
350
|
+
input.className = template.hasAttribute('data-formie-address-state-cp') ? 'text fullwidth' : template.className;
|
|
344
351
|
}
|
|
345
352
|
|
|
346
353
|
function applySelectThemeClasses(template: AddressStateControl, select: HTMLSelectElement): void {
|
|
@@ -461,11 +468,16 @@ function setFieldVisibility(fieldState: FieldState, visible: boolean): void {
|
|
|
461
468
|
const { stateField, stateControl, required } = fieldState;
|
|
462
469
|
|
|
463
470
|
toggleThemeClasses(stateField, stateField, 'conditionalHidden', !visible);
|
|
464
|
-
|
|
471
|
+
// Use module-owned visibility: the generic hidden attribute unmounts modules,
|
|
472
|
+
// which would remove the country listener needed to show this field again.
|
|
473
|
+
stateField.toggleAttribute('data-formie-address-state-hidden', !visible);
|
|
474
|
+
|
|
465
475
|
|
|
466
476
|
if (!visible) {
|
|
467
477
|
stateControl.required = false;
|
|
468
|
-
|
|
478
|
+
// Keep the empty value in Craft's serialized form data so hiding the
|
|
479
|
+
// field during initialization does not count as an unsaved edit.
|
|
480
|
+
stateControl.disabled = !fieldState.isCpEdit;
|
|
469
481
|
return;
|
|
470
482
|
}
|
|
471
483
|
|
|
@@ -479,7 +491,7 @@ function ensureFetchingAnnouncement(fieldState: FieldState): HTMLElement {
|
|
|
479
491
|
}
|
|
480
492
|
|
|
481
493
|
const announcement = document.createElement('div');
|
|
482
|
-
announcement.className = 'formie-sr-only';
|
|
494
|
+
announcement.className = fieldState.isCpEdit ? 'visually-hidden' : 'formie-sr-only';
|
|
483
495
|
announcement.setAttribute('data-formie-address-state-fetching-announce', 'true');
|
|
484
496
|
announcement.setAttribute('aria-live', 'polite');
|
|
485
497
|
announcement.setAttribute('aria-atomic', 'true');
|
|
@@ -509,7 +521,7 @@ function showFetchingUI(fieldState: FieldState): void {
|
|
|
509
521
|
fieldState.stateControl.setAttribute('tabindex', '-1');
|
|
510
522
|
|
|
511
523
|
toggleThemeClasses(fieldState.stateField, fieldState.stateField, 'conditionalHidden', false);
|
|
512
|
-
fieldState.stateField.removeAttribute('data-formie-
|
|
524
|
+
fieldState.stateField.removeAttribute('data-formie-address-state-hidden');
|
|
513
525
|
fieldState.stateField.setAttribute('data-formie-address-state-skeleton-active', 'true');
|
|
514
526
|
|
|
515
527
|
const controlWrapper = fieldState.stateField.querySelector('[data-formie-field-control]');
|
|
@@ -546,7 +558,7 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
|
|
|
546
558
|
return;
|
|
547
559
|
}
|
|
548
560
|
|
|
549
|
-
const required = labelEl.querySelector('[data-formie-field-required]');
|
|
561
|
+
const required = labelEl.querySelector('[data-formie-field-required], .required');
|
|
550
562
|
labelEl.textContent = label;
|
|
551
563
|
|
|
552
564
|
if (required) {
|
|
@@ -595,6 +607,10 @@ async function applyCountryState(
|
|
|
595
607
|
const country = fieldState.countryControl?.value?.trim() || '';
|
|
596
608
|
stashPendingStateValue(fieldState);
|
|
597
609
|
const currentValue = getEffectiveStateValue(fieldState);
|
|
610
|
+
// Opening an existing submission must not discard a historical state value
|
|
611
|
+
// merely because current country metadata no longer includes it.
|
|
612
|
+
const preserveStoredValue = fieldState.isCpEdit && !!currentValue
|
|
613
|
+
&& country === fieldState.initialCountry && currentValue === fieldState.initialValue;
|
|
598
614
|
const fetchGeneration = ++fieldState.fetchGeneration;
|
|
599
615
|
|
|
600
616
|
fieldState.comboboxCleanup?.();
|
|
@@ -608,7 +624,7 @@ async function applyCountryState(
|
|
|
608
624
|
|
|
609
625
|
hideFetchingUI(fieldState);
|
|
610
626
|
|
|
611
|
-
if (hideWhenUnused) {
|
|
627
|
+
if (hideWhenUnused && !preserveStoredValue) {
|
|
612
628
|
setFieldVisibility(fieldState, false);
|
|
613
629
|
setControlValue(fieldState.stateControl, '');
|
|
614
630
|
syncAutofillAnchor(fieldState, '');
|
|
@@ -623,7 +639,7 @@ async function applyCountryState(
|
|
|
623
639
|
const textControl = createTextControl(fieldState.stateControl, currentValue);
|
|
624
640
|
fieldState.stateControl = replaceControl(fieldState.stateControl, textControl);
|
|
625
641
|
} else {
|
|
626
|
-
fieldState.stateControl.disabled =
|
|
642
|
+
fieldState.stateControl.disabled = !fieldState.isCpEdit && !preserveStoredValue;
|
|
627
643
|
fieldState.stateControl.placeholder = placeholder || fieldState.stateControl.placeholder;
|
|
628
644
|
}
|
|
629
645
|
|
|
@@ -644,14 +660,17 @@ async function applyCountryState(
|
|
|
644
660
|
return;
|
|
645
661
|
}
|
|
646
662
|
|
|
647
|
-
const subdivisions = response?.subdivisions || [];
|
|
663
|
+
const subdivisions = [...(response?.subdivisions || [])];
|
|
664
|
+
if (preserveStoredValue && subdivisions.length && !resolveOptionMatch(currentValue, subdivisions)) {
|
|
665
|
+
subdivisions.push({ label: currentValue, value: currentValue });
|
|
666
|
+
}
|
|
648
667
|
const administrativeAreaUsed = response?.administrativeAreaUsed ?? true;
|
|
649
668
|
const administrativeAreaLabel = response?.administrativeAreaLabel || 'State / Province';
|
|
650
669
|
fieldState.lastSubdivisions = subdivisions;
|
|
651
670
|
|
|
652
671
|
hideFetchingUI(fieldState);
|
|
653
672
|
|
|
654
|
-
if (hideWhenUnused && !administrativeAreaUsed) {
|
|
673
|
+
if (hideWhenUnused && !administrativeAreaUsed && !preserveStoredValue) {
|
|
655
674
|
setFieldVisibility(fieldState, false);
|
|
656
675
|
setControlValue(fieldState.stateControl, '');
|
|
657
676
|
syncAutofillAnchor(fieldState, '');
|
|
@@ -755,6 +774,9 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
|
|
|
755
774
|
|
|
756
775
|
const fieldState: FieldState = {
|
|
757
776
|
addressRoot,
|
|
777
|
+
isCpEdit: stateControl.hasAttribute('data-formie-address-state-cp'),
|
|
778
|
+
initialCountry: getCountryControl(addressRoot)?.value?.trim() || '',
|
|
779
|
+
initialValue: stateControl.value?.trim() || '',
|
|
758
780
|
stateField: field,
|
|
759
781
|
stateControl,
|
|
760
782
|
countryControl: getCountryControl(addressRoot),
|
|
@@ -772,6 +794,10 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
|
|
|
772
794
|
lastSubdivisions: [],
|
|
773
795
|
};
|
|
774
796
|
|
|
797
|
+
const initialStateValue = stateControl.value;
|
|
798
|
+
const form = field.closest('form');
|
|
799
|
+
let disposed = false;
|
|
800
|
+
let resetTimer: number | null = null;
|
|
775
801
|
ensureAutofillAnchor(fieldState);
|
|
776
802
|
|
|
777
803
|
const refresh = (force = false) => {
|
|
@@ -830,6 +856,7 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
|
|
|
830
856
|
|
|
831
857
|
if (isStateControl && !isAnchor) {
|
|
832
858
|
syncAutofillAnchor(fieldState, target.value);
|
|
859
|
+
fieldState.pendingStateValue = target.value;
|
|
833
860
|
}
|
|
834
861
|
|
|
835
862
|
stashPendingStateValue(fieldState);
|
|
@@ -891,12 +918,31 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
|
|
|
891
918
|
syncAutofillAnchor(fieldState, stateValue);
|
|
892
919
|
|
|
893
920
|
void applyCountryState(fieldState, options).then(() => {
|
|
894
|
-
|
|
895
|
-
|
|
921
|
+
// Keep the normalized option value, which may differ from a provider's
|
|
922
|
+
// abbreviation (for example "CA" when options store "California").
|
|
923
|
+
reconcileAutofillValue(fieldState);
|
|
896
924
|
fieldState.lastCountry = fieldState.countryControl?.value?.trim() || '';
|
|
897
925
|
});
|
|
898
926
|
};
|
|
899
927
|
|
|
928
|
+
const onReset = (event: Event) => {
|
|
929
|
+
if (resetTimer !== null) window.clearTimeout(resetTimer);
|
|
930
|
+
// Enhanced selects finish their own reset in microtasks. Rebuild the
|
|
931
|
+
// dependent options after those controls have restored the country.
|
|
932
|
+
resetTimer = window.setTimeout(() => {
|
|
933
|
+
resetTimer = null;
|
|
934
|
+
if (event.defaultPrevented || disposed) return;
|
|
935
|
+
clearScheduledAutofillTimers(fieldState);
|
|
936
|
+
// The current select may contain another country's options. Keep the
|
|
937
|
+
// original value pending until the reset country's options are ready.
|
|
938
|
+
fieldState.stateControl.value = '';
|
|
939
|
+
fieldState.pendingStateValue = initialStateValue;
|
|
940
|
+
syncAutofillAnchor(fieldState, initialStateValue);
|
|
941
|
+
refresh(true);
|
|
942
|
+
}, 0);
|
|
943
|
+
};
|
|
944
|
+
form?.addEventListener('reset', onReset);
|
|
945
|
+
|
|
900
946
|
fieldState.countryControl?.addEventListener('change', onCountryChange);
|
|
901
947
|
fieldState.countryControl?.addEventListener('input', onCountryChange);
|
|
902
948
|
addressRoot.addEventListener('input', onAddressInput);
|
|
@@ -907,10 +953,15 @@ function initAddressStateField(field: HTMLElement, options: AddressStateOptions)
|
|
|
907
953
|
addressRoot.addEventListener('formie:address:loqate:populate', onProviderPopulate);
|
|
908
954
|
addressRoot.addEventListener('formie:address:place-kit:populate', onProviderPopulate);
|
|
909
955
|
|
|
910
|
-
|
|
956
|
+
// Apply the empty-country visibility state on first mount as well.
|
|
957
|
+
refresh(true);
|
|
911
958
|
scheduleAutofillSweep(fieldState, options);
|
|
912
959
|
|
|
913
960
|
return () => {
|
|
961
|
+
disposed = true;
|
|
962
|
+
if (resetTimer !== null) window.clearTimeout(resetTimer);
|
|
963
|
+
fieldState.fetchGeneration++;
|
|
964
|
+
form?.removeEventListener('reset', onReset);
|
|
914
965
|
clearScheduledAutofillTimers(fieldState);
|
|
915
966
|
hideFetchingUI(fieldState);
|
|
916
967
|
fieldState.skeletonEl?.remove();
|
|
@@ -6,6 +6,7 @@ import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/sh
|
|
|
6
6
|
import { ensureModuleStyles } from '#modules/styles';
|
|
7
7
|
import { getThemeClasses } from '#theme/theme-classes';
|
|
8
8
|
import { createDebug } from '#utils/debug';
|
|
9
|
+
import { VALIDATION_SKIP_ATTR } from '#validation/skip';
|
|
9
10
|
|
|
10
11
|
const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
|
|
11
12
|
const MODULE_ID = 'combobox';
|
|
@@ -116,6 +117,8 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
|
|
|
116
117
|
});
|
|
117
118
|
|
|
118
119
|
const instance = new TomSelect(select, mergedOptions) as TomSelectInstance;
|
|
120
|
+
// The search query is temporary; the native select carries the field value.
|
|
121
|
+
instance.control_input.setAttribute(VALIDATION_SKIP_ATTR, '');
|
|
119
122
|
removeEmptyOptionFromCombobox(instance);
|
|
120
123
|
cleanLeakedThemeClasses(instance.wrapper, select);
|
|
121
124
|
|
|
@@ -47,9 +47,10 @@ function clearConditionNodeValues(node: Element): void {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function setVisibilityState(node: Element, hidden: boolean): boolean {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
50
|
+
// Navigation owns page-hidden. A matching condition must not reveal an
|
|
51
|
+
// inactive page, or clear its values just because the visitor moved on.
|
|
52
|
+
const hiddenAttr = CONDITIONAL_HIDDEN_ATTR;
|
|
53
|
+
const hiddenClasses = getThemeClasses(node, 'conditionalHidden');
|
|
53
54
|
const wasHidden = node.hasAttribute(hiddenAttr);
|
|
54
55
|
|
|
55
56
|
if (hidden) {
|
|
@@ -190,9 +191,7 @@ function clearCpMutedState(node: Element): void {
|
|
|
190
191
|
|
|
191
192
|
function clearHideState(node: Element): void {
|
|
192
193
|
node.removeAttribute(CONDITIONAL_HIDDEN_ATTR);
|
|
193
|
-
node.removeAttribute(PAGE_HIDDEN_ATTR);
|
|
194
194
|
node.classList.remove(...getThemeClasses(node, 'conditionalHidden'));
|
|
195
|
-
node.classList.remove(...getThemeClasses(node, 'pageHidden'));
|
|
196
195
|
}
|
|
197
196
|
|
|
198
197
|
function syncAncestorRows(node: Element): void {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import type { ConditionEntry, ConditionInput } from '#modules/fields/conditions/types';
|
|
11
11
|
import { getConditionInputEventNames } from '#modules/fields/conditions/values';
|
|
12
12
|
import { createDebug } from '#utils/debug';
|
|
13
|
+
import { getFormStateEventName } from '#utils/event-names';
|
|
13
14
|
|
|
14
15
|
const debug = createDebug('conditions');
|
|
15
16
|
|
|
@@ -212,8 +213,13 @@ export const conditionsModule: BrowserModuleDefinition = {
|
|
|
212
213
|
};
|
|
213
214
|
|
|
214
215
|
ctx.form.addEventListener('reset', resetHandler);
|
|
216
|
+
// Canonical resets have already restored values. Settle conditions
|
|
217
|
+
// before the unload guard captures the form's new clean state.
|
|
218
|
+
const stateResetEvent = getFormStateEventName('reset');
|
|
219
|
+
ctx.form.addEventListener(stateResetEvent, evaluateAll);
|
|
215
220
|
sourceUnbinds.push(() => {
|
|
216
221
|
ctx.form?.removeEventListener('reset', resetHandler);
|
|
222
|
+
ctx.form?.removeEventListener(stateResetEvent, evaluateAll);
|
|
217
223
|
});
|
|
218
224
|
|
|
219
225
|
// CP status menu (etc.) updates `data-formie-submission` and fires this event.
|
|
@@ -90,8 +90,9 @@ function normalizeOffsetDate(input: string | null | undefined, type: 'min' | 'ma
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const absoluteDate = parseSubmissionDate(input);
|
|
94
|
+
if (absoluteDate) {
|
|
95
|
+
return absoluteDate;
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
// Form config may express relative min/max bounds like "+2 weeks", which are
|
|
@@ -209,7 +210,9 @@ function formatSubmissionDate(date: Date, options: DatePickerOptions): string {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
function parseSubmissionDate(value: string): Date | null {
|
|
212
|
-
|
|
213
|
+
// Date-only field values represent a local calendar day. JavaScript otherwise
|
|
214
|
+
// parses YYYY-MM-DD as UTC and shifts the picker to yesterday west of UTC.
|
|
215
|
+
const parsed = new Date(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00` : value);
|
|
213
216
|
|
|
214
217
|
if (Number.isNaN(parsed.getTime())) {
|
|
215
218
|
return null;
|
|
@@ -30,6 +30,9 @@ type UploadedAsset = {
|
|
|
30
30
|
assetId: number | null;
|
|
31
31
|
filename: string;
|
|
32
32
|
url?: string | null;
|
|
33
|
+
uploadUid?: string | null;
|
|
34
|
+
attachToken?: string | null;
|
|
35
|
+
viewToken?: string | null;
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
type FileUploadHydrateResponse = {
|
|
@@ -151,6 +154,9 @@ function normalizeUploadedAsset(candidate: unknown): UploadedAsset | null {
|
|
|
151
154
|
assetId,
|
|
152
155
|
filename,
|
|
153
156
|
url,
|
|
157
|
+
uploadUid: toTrimmedString(candidate.uploadUid) || null,
|
|
158
|
+
attachToken: toTrimmedString(candidate.attachToken) || null,
|
|
159
|
+
viewToken: toTrimmedString(candidate.viewToken ?? candidate.uploadToken) || null,
|
|
154
160
|
};
|
|
155
161
|
}
|
|
156
162
|
|
|
@@ -190,6 +196,9 @@ function readUploadedAssetsFromHiddenInputs(field: HTMLElement, input: HTMLInput
|
|
|
190
196
|
return {
|
|
191
197
|
assetId: toPositiveInt(hiddenInput.getAttribute(HIDDEN_INPUT_VALUE_ATTR)) ?? toPositiveInt(hiddenInput.value),
|
|
192
198
|
filename: '',
|
|
199
|
+
viewToken: hiddenInput.getAttribute('data-formie-file-upload-token'),
|
|
200
|
+
uploadUid: hiddenInput.name.endsWith('[uploadUid]') ? hiddenInput.value : null,
|
|
201
|
+
attachToken: getHiddenInputs(field).find((entry) => entry.name === hiddenInput.name.replace(/\[uploadUid\]$/, '[attachToken]'))?.value,
|
|
193
202
|
};
|
|
194
203
|
}).filter((asset) => {
|
|
195
204
|
return asset.assetId !== null;
|
|
@@ -203,6 +212,7 @@ function mergeUploadedAssets(assetIds: UploadedAsset[], summaryItems: UploadedAs
|
|
|
203
212
|
|
|
204
213
|
return assetIds.map((asset, index) => {
|
|
205
214
|
return {
|
|
215
|
+
...asset,
|
|
206
216
|
assetId: asset.assetId,
|
|
207
217
|
filename: summaryItems[index]?.filename || '',
|
|
208
218
|
};
|
|
@@ -248,8 +258,8 @@ function registerValidators(form: HTMLFormElement | null): void {
|
|
|
248
258
|
});
|
|
249
259
|
|
|
250
260
|
validator.addValidator('fileSizeMinLimit', ({ input }) => {
|
|
251
|
-
const
|
|
252
|
-
const sizeBytes =
|
|
261
|
+
const maxFileSize = parseFloat(input.getAttribute('data-formie-size-min-limit') || '');
|
|
262
|
+
const sizeBytes = maxFileSize * 1000 * 1000;
|
|
253
263
|
|
|
254
264
|
if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
|
|
255
265
|
return true;
|
|
@@ -266,8 +276,8 @@ function registerValidators(form: HTMLFormElement | null): void {
|
|
|
266
276
|
});
|
|
267
277
|
|
|
268
278
|
validator.addValidator('fileSizeMaxLimit', ({ input }) => {
|
|
269
|
-
const
|
|
270
|
-
const sizeBytes =
|
|
279
|
+
const maxFileSize = parseFloat(input.getAttribute('data-formie-size-max-limit') || '');
|
|
280
|
+
const sizeBytes = maxFileSize * 1000 * 1000;
|
|
271
281
|
|
|
272
282
|
if (input.type !== 'file' || !sizeBytes || !('files' in input) || !input.files?.length) {
|
|
273
283
|
return true;
|
|
@@ -366,12 +376,26 @@ function updateUploadedAssetInputs(field: HTMLElement, input: HTMLInputElement,
|
|
|
366
376
|
|
|
367
377
|
const hiddenInput = document.createElement('input');
|
|
368
378
|
hiddenInput.type = 'hidden';
|
|
369
|
-
|
|
370
|
-
hiddenInput.
|
|
379
|
+
const hasCapability = asset.uploadUid && asset.attachToken;
|
|
380
|
+
hiddenInput.name = `${baseName}[${index}][${hasCapability ? 'uploadUid' : 'assetId'}]`;
|
|
381
|
+
hiddenInput.value = hasCapability ? asset.uploadUid! : String(asset.assetId);
|
|
371
382
|
hiddenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
|
|
372
383
|
hiddenInput.setAttribute(HIDDEN_INPUT_VALUE_ATTR, String(asset.assetId));
|
|
384
|
+
if (asset.viewToken) {
|
|
385
|
+
hiddenInput.setAttribute('data-formie-file-upload-token', asset.viewToken);
|
|
386
|
+
}
|
|
373
387
|
insertionPoint.insertAdjacentElement('afterend', hiddenInput);
|
|
374
388
|
insertionPoint = hiddenInput;
|
|
389
|
+
|
|
390
|
+
if (hasCapability) {
|
|
391
|
+
const tokenInput = document.createElement('input');
|
|
392
|
+
tokenInput.type = 'hidden';
|
|
393
|
+
tokenInput.name = `${baseName}[${index}][attachToken]`;
|
|
394
|
+
tokenInput.value = asset.attachToken!;
|
|
395
|
+
tokenInput.setAttribute(HIDDEN_INPUT_ENTRY_ATTR, 'true');
|
|
396
|
+
insertionPoint.insertAdjacentElement('afterend', tokenInput);
|
|
397
|
+
insertionPoint = tokenInput;
|
|
398
|
+
}
|
|
375
399
|
});
|
|
376
400
|
|
|
377
401
|
input.value = '';
|
|
@@ -391,7 +415,7 @@ function getFormHandle(form: HTMLFormElement | null): string {
|
|
|
391
415
|
return form.getAttribute('data-formie-handle')?.trim() || '';
|
|
392
416
|
}
|
|
393
417
|
|
|
394
|
-
function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement,
|
|
418
|
+
function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, assets: UploadedAsset[]): FormData {
|
|
395
419
|
const form = input.form;
|
|
396
420
|
const body = new FormData();
|
|
397
421
|
const handle = getFormHandle(form);
|
|
@@ -411,8 +435,13 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
|
|
|
411
435
|
body.append('submissionUid', submissionUidInput.value.trim());
|
|
412
436
|
}
|
|
413
437
|
|
|
414
|
-
|
|
415
|
-
|
|
438
|
+
assets.forEach((asset) => {
|
|
439
|
+
if (asset.assetId) {
|
|
440
|
+
body.append('assetIds[]', String(asset.assetId));
|
|
441
|
+
if (asset.viewToken) {
|
|
442
|
+
body.append(`uploadTokens[${asset.assetId}]`, asset.viewToken);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
416
445
|
});
|
|
417
446
|
|
|
418
447
|
return body;
|
|
@@ -433,7 +462,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
|
|
|
433
462
|
const response = await requestJson<FileUploadHydrateResponse>(hydrateEndpoint, {
|
|
434
463
|
profile: field.closest('form')?.dataset.formieRequestProfile as import('@verbb/formie-core').BrowserRequestOptions['profile'],
|
|
435
464
|
method: 'POST',
|
|
436
|
-
body: buildHydrateFormData(field, input,
|
|
465
|
+
body: buildHydrateFormData(field, input, uploadedAssets),
|
|
437
466
|
});
|
|
438
467
|
const hydratedAssets = normalizeUploadedAssets(response.assets);
|
|
439
468
|
|
|
@@ -452,6 +481,7 @@ async function hydrateUploadedAssets(field: HTMLElement, input: HTMLInputElement
|
|
|
452
481
|
|
|
453
482
|
const hydratedAsset = hydratedMap.get(asset.assetId)!;
|
|
454
483
|
return {
|
|
484
|
+
...asset,
|
|
455
485
|
assetId: asset.assetId,
|
|
456
486
|
filename: hydratedAsset.filename || asset.filename,
|
|
457
487
|
url: hydratedAsset.url ?? asset.url,
|
|
@@ -480,24 +510,24 @@ function getUploadedAssetsFromEvent(event: Event, matcher: FileUploadEventMatche
|
|
|
480
510
|
|
|
481
511
|
if (detailInputKey !== '' && detailInputKey === matcher.inputKey) {
|
|
482
512
|
matchedByInputKey = true;
|
|
483
|
-
candidates.push(detail.assets, detail.assetIds, detail.data);
|
|
513
|
+
candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
|
|
484
514
|
}
|
|
485
515
|
|
|
486
516
|
if (detailInputName !== '' && detailInputName === matcher.inputName) {
|
|
487
517
|
matchedByInputName = true;
|
|
488
|
-
candidates.push(detail.assets, detail.assetIds, detail.data);
|
|
518
|
+
candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
|
|
489
519
|
}
|
|
490
520
|
|
|
491
521
|
if (detailFieldHandle === matcher.fieldHandle) {
|
|
492
522
|
matchedByFieldHandle = true;
|
|
493
|
-
candidates.push(detail.assets, detail.assetIds, detail.data);
|
|
523
|
+
candidates.push(detail.uploadedAssets, detail.assets, detail.assetIds, detail.data);
|
|
494
524
|
}
|
|
495
525
|
|
|
496
526
|
if (nestedData) {
|
|
497
527
|
const nestedAssetCandidates = [
|
|
528
|
+
nestedData.uploadedAssets,
|
|
498
529
|
nestedData.assets,
|
|
499
530
|
nestedData.assetIds,
|
|
500
|
-
nestedData.uploadedAssets,
|
|
501
531
|
nestedData.data,
|
|
502
532
|
];
|
|
503
533
|
|
|
@@ -18,7 +18,7 @@ ensureModuleStyles(MODULE_ID, [intlTelInputCss]);
|
|
|
18
18
|
|
|
19
19
|
type PhoneCountryOptions = {
|
|
20
20
|
countryDefaultValue?: string;
|
|
21
|
-
|
|
21
|
+
allowedCountryCodes?: string[];
|
|
22
22
|
countryPreselectFromIp?: boolean;
|
|
23
23
|
countryFromIpAction?: string;
|
|
24
24
|
language?: string;
|
|
@@ -93,7 +93,7 @@ function buildOptions(options: PhoneCountryOptions, form: HTMLFormElement | null
|
|
|
93
93
|
loadUtils: () => import('intl-tel-input/utils'),
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
-
const allowedCountries = (options.
|
|
96
|
+
const allowedCountries = (options.allowedCountryCodes || [])
|
|
97
97
|
.map((item) => {
|
|
98
98
|
return item.toLowerCase();
|
|
99
99
|
})
|
|
@@ -32,6 +32,18 @@ function getRowCount(field: HTMLElement): number {
|
|
|
32
32
|
return field.querySelectorAll(ROW_SELECTOR).length;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function focusRow(row: Element | null): boolean {
|
|
36
|
+
const control = Array.from(row?.querySelectorAll<HTMLElement>(
|
|
37
|
+
'input:not([type="hidden"]), select, textarea, button, a[href], [role="button"], [role="combobox"], [role="textbox"], [contenteditable="true"]',
|
|
38
|
+
) || []).find((element) => {
|
|
39
|
+
return element.tabIndex >= 0 && !element.matches(':disabled')
|
|
40
|
+
&& !element.closest('[hidden], [inert]') && element.getClientRects().length > 0;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
control?.focus();
|
|
44
|
+
return !!control;
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): void {
|
|
36
48
|
if (!addButton) {
|
|
37
49
|
return;
|
|
@@ -46,7 +58,7 @@ function syncAddButton(addButton: HTMLButtonElement | null, rowCount: number): v
|
|
|
46
58
|
addButton.disabled = false;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
function bindRepeaterField(field: HTMLElement): () => void {
|
|
61
|
+
function bindRepeaterField(field: HTMLElement, isCpEdit: boolean): () => void {
|
|
50
62
|
const container = field.matches(CONTAINER_SELECTOR)
|
|
51
63
|
? field
|
|
52
64
|
: field.querySelector(CONTAINER_SELECTOR);
|
|
@@ -57,6 +69,9 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
57
69
|
return () => {};
|
|
58
70
|
}
|
|
59
71
|
|
|
72
|
+
const form = field.closest('form');
|
|
73
|
+
let disposed = false;
|
|
74
|
+
let generation = 0;
|
|
60
75
|
const removeHandlers = new Map<HTMLElement, EventListener>();
|
|
61
76
|
// Start after the highest existing row id so SSR rows and client-added rows
|
|
62
77
|
// keep producing unique `__ROW__` replacements across one repeater instance.
|
|
@@ -84,8 +99,17 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
84
99
|
return;
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
const restoreFocus = row.contains(document.activeElement)
|
|
103
|
+
&& (!isCpEdit || (event instanceof MouseEvent && event.detail === 0));
|
|
104
|
+
const nextRow = row.nextElementSibling;
|
|
105
|
+
const previousRow = row.previousElementSibling;
|
|
87
106
|
row.remove();
|
|
88
107
|
syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
|
|
108
|
+
// Removing the focused control otherwise sends keyboard users back
|
|
109
|
+
// to the document body. Keep them in the nearest surviving row.
|
|
110
|
+
if (restoreFocus && !focusRow(nextRow) && !focusRow(previousRow)) {
|
|
111
|
+
if (addButton instanceof HTMLButtonElement) addButton.focus();
|
|
112
|
+
}
|
|
89
113
|
debug.log('Row removed.', {
|
|
90
114
|
rowCount: getRowCount(field),
|
|
91
115
|
});
|
|
@@ -100,7 +124,7 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
100
124
|
});
|
|
101
125
|
};
|
|
102
126
|
|
|
103
|
-
const addRow = async() => {
|
|
127
|
+
const addRow = async(focusNewRow = false) => {
|
|
104
128
|
if (!(addButton instanceof HTMLButtonElement)) {
|
|
105
129
|
return;
|
|
106
130
|
}
|
|
@@ -130,11 +154,16 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
130
154
|
return;
|
|
131
155
|
}
|
|
132
156
|
|
|
157
|
+
const addedGeneration = generation;
|
|
133
158
|
container.appendChild(row);
|
|
134
159
|
// Give downstream field modules a tick to see the new row before we emit
|
|
135
160
|
// the public append/init-row lifecycle events used for nested enhancements.
|
|
136
161
|
await sleep(50);
|
|
162
|
+
if (disposed || addedGeneration !== generation || !row.isConnected) return;
|
|
137
163
|
bindRemoveButtons();
|
|
164
|
+
// Rows are inserted before Add, so leaving focus there skips the new
|
|
165
|
+
// inputs on Tab. Do not steal focus if the user moved on while mounting.
|
|
166
|
+
if (focusNewRow && document.activeElement === addButton) focusRow(row);
|
|
138
167
|
syncAddButton(addButton, getRowCount(field));
|
|
139
168
|
debug.log('Row appended.', {
|
|
140
169
|
rowCount: getRowCount(field),
|
|
@@ -151,7 +180,10 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
151
180
|
|
|
152
181
|
const addHandler = (event: Event) => {
|
|
153
182
|
event.preventDefault();
|
|
154
|
-
|
|
183
|
+
// Pointer additions in the CP should not focus Craft's field wrappers
|
|
184
|
+
// and show a focus ring around the newly inserted field.
|
|
185
|
+
const focusNewRow = !isCpEdit || (event instanceof MouseEvent && event.detail === 0);
|
|
186
|
+
void addRow(focusNewRow);
|
|
155
187
|
};
|
|
156
188
|
|
|
157
189
|
if (addButton instanceof HTMLButtonElement) {
|
|
@@ -170,6 +202,23 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
170
202
|
}
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
const initialRows = Array.from(container.children).map((row) => row.cloneNode(true));
|
|
206
|
+
|
|
207
|
+
const onReset = (event: Event) => {
|
|
208
|
+
queueMicrotask(() => {
|
|
209
|
+
if (event.defaultPrevented || disposed) return;
|
|
210
|
+
generation++;
|
|
211
|
+
removeHandlers.forEach((handler, button) => button.removeEventListener('click', handler));
|
|
212
|
+
removeHandlers.clear();
|
|
213
|
+
// Replace rows so removed initial rows return and added rows disappear.
|
|
214
|
+
// The module loader disposes old controls and hydrates the new rows.
|
|
215
|
+
container.replaceChildren(...initialRows.map((row) => row.cloneNode(true)));
|
|
216
|
+
bindRemoveButtons();
|
|
217
|
+
syncAddButton(addButton instanceof HTMLButtonElement ? addButton : null, getRowCount(field));
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
form?.addEventListener('reset', onReset);
|
|
221
|
+
|
|
173
222
|
dispatchFieldEvent(field, MODULE_ID, 'init', {
|
|
174
223
|
repeater: field,
|
|
175
224
|
});
|
|
@@ -178,6 +227,8 @@ function bindRepeaterField(field: HTMLElement): () => void {
|
|
|
178
227
|
});
|
|
179
228
|
|
|
180
229
|
return () => {
|
|
230
|
+
disposed = true;
|
|
231
|
+
form?.removeEventListener('reset', onReset);
|
|
181
232
|
if (addButton instanceof HTMLButtonElement) {
|
|
182
233
|
addButton.removeEventListener('click', addHandler);
|
|
183
234
|
}
|
|
@@ -211,7 +262,7 @@ export const repeaterModule: BrowserModuleDefinition = {
|
|
|
211
262
|
});
|
|
212
263
|
|
|
213
264
|
const destroyBindings = fields.map((field) => {
|
|
214
|
-
return bindRepeaterField(field);
|
|
265
|
+
return bindRepeaterField(field, ctx.surface === 'cp-edit');
|
|
215
266
|
});
|
|
216
267
|
debug.log('Module setup.', { fieldCount: fields.length });
|
|
217
268
|
|