@verbb/formie-browser 1.0.15 → 1.0.16
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 +36 -0
- package/dist/chunks/{address-country-BcEQKw0T.js → address-country-DLJF-OkC.js} +4 -8
- package/dist/chunks/{address-finder-BWGINCSA.js → address-finder-QK1m1eyv.js} +1 -1
- package/dist/chunks/{address-state-DI3ndhMU.js → address-state-CHKit8AW.js} +26 -35
- package/dist/chunks/{api-BK9b340y.js → api-BPtH5RBA.js} +152 -120
- package/dist/chunks/{api-Dxl_CKw-.js → api-BR2Gqy37.js} +306 -301
- package/dist/chunks/{api-ghNr2uxX.js → api-CmuOZ0i3.js} +1 -1
- package/dist/chunks/{bpoint-BwtuyN_O.js → bpoint-GvYfyPjD.js} +1 -1
- package/dist/chunks/{calculations-YEoVCG2q.js → calculations-DeEmdUDq.js} +5 -7
- package/dist/chunks/{captcha-eu-DWLoQFKB.js → captcha-eu-eU_M6snp.js} +1 -1
- package/dist/chunks/{checkbox-radio-gsm8QPJg.js → checkbox-radio-CSXo6svm.js} +4 -12
- package/dist/chunks/{combobox-Ba08_cHL.js → combobox-CJh2DoSO.js} +292 -333
- package/dist/chunks/{conditions-Ceeg6ReZ.js → conditions-Dz3068K0.js} +56 -72
- package/dist/chunks/{country-from-ip-DDj_mFki.js → country-from-ip-BqQ-BqLH.js} +1 -1
- package/dist/chunks/{custom-google-maps-C4l2S3GO.js → custom-google-maps-rNm5VFbb.js} +2 -6
- package/dist/chunks/{custom-link-CLazgKYH.js → custom-link-CLqFCssQ.js} +1 -1
- package/dist/chunks/{custom-maps-DqcAlD0v.js → custom-maps-DgjYQDLj.js} +4 -8
- package/dist/chunks/{date-picker-DaPvMrop.js → date-picker-BVJxJVYT.js} +30 -47
- package/dist/chunks/{debug-BV0DvdHx.js → debug-rhzqP1j2.js} +2 -14
- package/dist/chunks/{dist-ZLeW0zZ3.js → dist-CJReDlDK.js} +13 -8
- package/dist/chunks/{eway-uw0DzU-L.js → eway-BZuTBAUW.js} +1 -1
- package/dist/chunks/{field-references.resolver-Ca8MguFv.js → field-references.resolver-uSHf5-NP.js} +1 -1
- package/dist/chunks/{file-upload-B8_mH012.js → file-upload-vEUr_EuP.js} +114 -108
- package/dist/chunks/{friendly-captcha-v1-CVXaXgDO.js → friendly-captcha-v1-C_cF1WD2.js} +2 -2
- package/dist/chunks/{friendly-captcha-v2-DkeIVTKI.js → friendly-captcha-v2-BenESH-n.js} +2 -2
- package/dist/chunks/{friendly-challenge-DPG9MJb1.js → friendly-challenge-BgPBEMXx.js} +21 -35
- package/dist/chunks/{go-cardless-c4wLJdOt.js → go-cardless-DWZeB0Bq.js} +2 -6
- package/dist/chunks/{google-address-JHYvjwgN.js → google-address-DQR6rsbh.js} +2 -2
- package/dist/chunks/{hcaptcha-Dt3ELLlP.js → hcaptcha-COilLmRT.js} +1 -1
- package/dist/chunks/{hidden-BZhXnNPe.js → hidden-Bc2TpcgF.js} +1 -1
- package/dist/chunks/{http-C2QdHuWO.js → http-Dj_skqaa.js} +1 -1
- package/dist/chunks/{leaflet-src-DE6rv6M-.js → leaflet-src-Cg-LUOD8.js} +74 -122
- package/dist/chunks/{loqate-m51iPyO1.js → loqate-ZZClcacr.js} +1 -1
- package/dist/chunks/{mollie-Bn0PX173.js → mollie-8OMXIYa8.js} +2 -6
- package/dist/chunks/{moneris-BRbUvI4t.js → moneris-qYHJ9EFy.js} +1 -1
- package/dist/chunks/opayo-DlWPEnla.js +159 -0
- package/dist/chunks/{paddle-CympHVvd.js → paddle-CArfWih0.js} +1 -1
- package/dist/chunks/{password-validation-C8_uO_c_.js → password-validation-HGOU9kf3.js} +2 -2
- package/dist/chunks/{paypal-h75h6wae.js → paypal-BcaMCMGY.js} +1 -1
- package/dist/chunks/{payway-C9rHTCC_.js → payway-vxC7_bfR.js} +3 -11
- package/dist/chunks/{phone-country-B8fv-L5_.js → phone-country-Bx9EG50h.js} +123 -140
- package/dist/chunks/{place-kit-BnZGQ-L1.js → place-kit-BKk8xDXf.js} +2 -2
- package/dist/chunks/{placekit-autocomplete.esm-CZZERYur.js → placekit-autocomplete.esm-D2A_0iVU.js} +2 -5
- package/dist/chunks/{recaptcha-enterprise-CUs8BO6V.js → recaptcha-enterprise-Cx6HUpMa.js} +6 -12
- package/dist/chunks/recaptcha-shared-CcMI-r8t.js +33 -0
- package/dist/chunks/{recaptcha-v2-checkbox-P1McW4Pv.js → recaptcha-v2-checkbox-DQ9BJzcv.js} +2 -2
- package/dist/chunks/{recaptcha-v2-invisible-FGDqozSP.js → recaptcha-v2-invisible-BP0T8Pan.js} +2 -2
- package/dist/chunks/recaptcha-v3-DKMGQ27K.js +38 -0
- package/dist/chunks/{repeater-CyhDbozO.js → repeater-BR0RUvTs.js} +3 -7
- package/dist/chunks/{rich-text-uV4o_pGk.js → rich-text-H2A6cm1t.js} +33 -11
- package/dist/chunks/{rolldown-runtime-DtPi1Y-2.js → rolldown-runtime-VHNaYcLd.js} +1 -1
- package/dist/chunks/{sdk-lkwSvlpM.js → sdk-_cmrRYgp.js} +4 -16
- package/dist/chunks/{shared-OKhSay59.js → shared-CJ1LIEh3.js} +1 -3
- package/dist/chunks/{signature-7cldFbqj.js → signature-CfKA_poI.js} +9 -41
- package/dist/chunks/{snaptcha-C3QLYxFT.js → snaptcha-P3EoSQud.js} +1 -1
- package/dist/chunks/{square-C1z5DPVw.js → square-Dyz0p_GU.js} +1 -1
- package/dist/chunks/{stripe-Dmkdkq1y.js → stripe-Bfd_jp_k.js} +3 -6
- package/dist/chunks/{summary-Bn-g5SDx.js → summary-B3CAebLV.js} +44 -36
- package/dist/chunks/{survey-likert-fk1hUPff.js → survey-likert-CEtUceag.js} +1 -1
- package/dist/chunks/{survey-rank-BsLGl2LX.js → survey-rank-Dtt97y6s.js} +3 -7
- package/dist/chunks/{survey-rating-DVS6e6fM.js → survey-rating-mlDMI0uq.js} +9 -23
- package/dist/chunks/{table-j7MlLLCc.js → table-0k6Md0IO.js} +1 -1
- package/dist/chunks/{text-limit-LGMPhFuC.js → text-limit-CTVML6s7.js} +10 -10
- package/dist/chunks/{turnstile-ptihQfLl.js → turnstile-Sp4ubowK.js} +1 -1
- package/dist/chunks/{upload-manager-BW7l-BhF.js → upload-manager-Ccg3d_4I.js} +11 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +752 -756
- package/dist/js/core/create-formie-client.d.ts.map +1 -1
- package/dist/js/core/dom-events.d.ts +5 -0
- package/dist/js/core/dom-events.d.ts.map +1 -1
- package/dist/js/core/page-client-event.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.map +1 -1
- package/dist/js/core/validation-focus.d.ts.map +1 -1
- package/dist/js/modules/captchas/factories.d.ts.map +1 -1
- package/dist/js/modules/captchas/host.d.ts +4 -1
- package/dist/js/modules/captchas/host.d.ts.map +1 -1
- package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -1
- package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
- package/dist/js/modules/captchas/recaptcha-v3.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 +2 -0
- package/dist/js/modules/fields/combobox.d.ts.map +1 -1
- package/dist/js/modules/fields/conditions.d.ts.map +1 -1
- package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
- package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
- package/dist/js/modules/fields/rich-text.d.ts.map +1 -1
- package/dist/js/modules/fields/summary.d.ts.map +1 -1
- package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
- package/dist/js/modules/payments/factories.d.ts.map +1 -1
- package/dist/js/modules/payments/opayo.d.ts.map +1 -1
- package/dist/js/submit/pipeline.d.ts +2 -0
- package/dist/js/submit/pipeline.d.ts.map +1 -1
- package/dist/js/utils/form-started-at.d.ts +1 -0
- package/dist/js/utils/form-started-at.d.ts.map +1 -1
- package/dist/js/utils/unload-warning.d.ts +4 -0
- package/dist/js/utils/unload-warning.d.ts.map +1 -1
- package/dist/js/validation/rules/required.d.ts.map +1 -1
- package/dist/js/validation/submit-readiness.d.ts.map +1 -1
- package/dist/js/validation/validator.d.ts +1 -0
- package/dist/js/validation/validator.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/js/core/create-formie-client.ts +20 -18
- package/src/js/core/dom-events.ts +30 -1
- package/src/js/core/page-client-event.ts +5 -7
- package/src/js/core/submit-result-state.ts +13 -2
- package/src/js/core/submit-result-ui.ts +21 -0
- package/src/js/core/validation-focus.ts +17 -3
- package/src/js/modules/captchas/factories.ts +241 -101
- package/src/js/modules/captchas/host.ts +16 -0
- package/src/js/modules/captchas/recaptcha-enterprise.ts +1 -1
- package/src/js/modules/captchas/recaptcha-shared.ts +23 -5
- package/src/js/modules/captchas/recaptcha-v3.ts +23 -14
- package/src/js/modules/fields/address-state.ts +6 -4
- package/src/js/modules/fields/combobox.ts +19 -1
- package/src/js/modules/fields/conditions.ts +3 -0
- package/src/js/modules/fields/file-upload.ts +11 -4
- package/src/js/modules/fields/phone-country.ts +23 -0
- package/src/js/modules/fields/rich-text.ts +33 -0
- package/src/js/modules/fields/summary.ts +63 -10
- package/src/js/modules/fields/text-limit.ts +11 -0
- package/src/js/modules/payments/factories.ts +87 -54
- package/src/js/modules/payments/opayo.ts +7 -0
- package/src/js/submit/pipeline.ts +16 -9
- package/src/js/utils/form-started-at.ts +19 -4
- package/src/js/utils/unload-warning.ts +63 -7
- package/src/js/validation/rules/required.ts +6 -0
- package/src/js/validation/submit-readiness.ts +2 -0
- package/src/js/validation/validator.ts +15 -4
- package/src/vendor.d.ts +2 -0
- package/dist/chunks/opayo-BwWdInEP.js +0 -188
- package/dist/chunks/recaptcha-shared-DTbTw-MF.js +0 -27
- package/dist/chunks/recaptcha-v3-CflUHeZD.js +0 -28
|
@@ -17,21 +17,25 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
|
|
|
17
17
|
defaultPlaceholderSelector: '[data-recaptcha-placeholder]',
|
|
18
18
|
defaultTokenFieldNames: ['g-recaptcha-response'],
|
|
19
19
|
load: ({ options }) => {
|
|
20
|
-
|
|
21
|
-
// explicit widget render, so the loader needs the site key up front.
|
|
22
|
-
return loadRecaptchaGlobal(options.provider, false, options.provider.siteKey || undefined);
|
|
20
|
+
return loadRecaptchaGlobal(options.provider);
|
|
23
21
|
},
|
|
24
|
-
mount: ({ api, provider }) => {
|
|
25
|
-
return new Promise((resolve) => {
|
|
26
|
-
api.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
mount: ({ api, container, provider, services }) => {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
// An existing api.js may have loaded without render=<our site key>.
|
|
25
|
+
// Register this form explicitly, then execute its returned client ID.
|
|
26
|
+
void whenRecaptchaReady(api, async() => {
|
|
27
|
+
resolve(api.render(container, {
|
|
28
|
+
sitekey: provider.siteKey || '',
|
|
29
|
+
size: 'invisible',
|
|
30
|
+
badge: provider.badge || 'bottomright',
|
|
31
|
+
theme: provider.theme || 'light',
|
|
32
|
+
'expired-callback': () => services.tokens.clear(),
|
|
33
|
+
'error-callback': () => services.tokens.clear(),
|
|
34
|
+
}));
|
|
35
|
+
}).catch(reject);
|
|
32
36
|
});
|
|
33
37
|
},
|
|
34
|
-
challenge: async({ api, provider, placeholder, services, stageCtx }) => {
|
|
38
|
+
challenge: async({ api, widget, provider, placeholder, services, stageCtx }) => {
|
|
35
39
|
// If a token already exists we can skip execution, which matters for
|
|
36
40
|
// re-renders or multi-step flows where the same challenge was already
|
|
37
41
|
// completed very recently.
|
|
@@ -42,7 +46,7 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
|
|
|
42
46
|
// v3 returns its token directly from `execute()`, unlike widget-based
|
|
43
47
|
// captchas that signal completion later via callbacks.
|
|
44
48
|
await whenRecaptchaReady(api, async() => {
|
|
45
|
-
const token = await api.execute(
|
|
49
|
+
const token = await api.execute(widget, { action: provider.action || 'submit' });
|
|
46
50
|
|
|
47
51
|
if (typeof token === 'string' && token.trim() !== '') {
|
|
48
52
|
services.tokens.write(token.trim());
|
|
@@ -59,7 +63,12 @@ export const recaptchaV3Module = defineCaptchaModule<RecaptchaProviderOptions, R
|
|
|
59
63
|
stageCtx.abort(message);
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
|
-
|
|
66
|
+
reset: ({ api, widget, services }) => {
|
|
67
|
+
api.reset(widget);
|
|
68
|
+
services.tokens.clear();
|
|
69
|
+
},
|
|
70
|
+
unmount: ({ api, widget, services }) => {
|
|
71
|
+
api.reset(widget);
|
|
63
72
|
services.tokens.clear();
|
|
64
73
|
},
|
|
65
74
|
});
|
|
@@ -39,6 +39,7 @@ type AddressStateOptions = {
|
|
|
39
39
|
inputMode?: string;
|
|
40
40
|
hideWhenUnused?: boolean;
|
|
41
41
|
useSearchable?: boolean;
|
|
42
|
+
searchableOptionLimit?: number | null;
|
|
42
43
|
useDatalist?: boolean;
|
|
43
44
|
optionLabel?: 'name' | 'short';
|
|
44
45
|
optionValue?: 'name' | 'short';
|
|
@@ -569,22 +570,23 @@ function updateFieldLabel(stateField: HTMLElement, label: string): void {
|
|
|
569
570
|
function initSearchableSelect(
|
|
570
571
|
select: HTMLSelectElement,
|
|
571
572
|
placeholder: string | null,
|
|
573
|
+
maxOptions: number | null | undefined,
|
|
572
574
|
): () => void {
|
|
573
575
|
select.setAttribute('data-formie-combobox-input', 'true');
|
|
574
576
|
|
|
575
577
|
dispatchFieldEvent(select, 'combobox', 'before-init', {
|
|
576
578
|
select,
|
|
577
|
-
options: { placeholder },
|
|
579
|
+
options: { placeholder, maxOptions },
|
|
578
580
|
});
|
|
579
581
|
|
|
580
582
|
const cleanup = initFormieCombobox(
|
|
581
583
|
select as Parameters<typeof initFormieCombobox>[0],
|
|
582
|
-
{ placeholder },
|
|
584
|
+
{ placeholder, maxOptions },
|
|
583
585
|
);
|
|
584
586
|
|
|
585
587
|
dispatchFieldEvent(select, 'combobox', 'after-init', {
|
|
586
588
|
combobox: (select as Parameters<typeof initFormieCombobox>[0])._formieTomSelect,
|
|
587
|
-
options: { placeholder },
|
|
589
|
+
options: { placeholder, maxOptions },
|
|
588
590
|
});
|
|
589
591
|
|
|
590
592
|
return cleanup;
|
|
@@ -694,7 +696,7 @@ async function applyCountryState(
|
|
|
694
696
|
}
|
|
695
697
|
|
|
696
698
|
if (useSearchable) {
|
|
697
|
-
fieldState.comboboxCleanup = initSearchableSelect(select, placeholder);
|
|
699
|
+
fieldState.comboboxCleanup = initSearchableSelect(select, placeholder, options.searchableOptionLimit);
|
|
698
700
|
} else {
|
|
699
701
|
select.removeAttribute('data-formie-combobox-input');
|
|
700
702
|
}
|
|
@@ -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 { t } from '#utils/i18n';
|
|
9
10
|
import { VALIDATION_SKIP_ATTR } from '#validation/skip';
|
|
10
11
|
|
|
11
12
|
const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
|
|
@@ -21,6 +22,8 @@ const NATIVE_THEME_CLASSES = [
|
|
|
21
22
|
|
|
22
23
|
export type FormieComboboxOptions = {
|
|
23
24
|
multiple?: boolean;
|
|
25
|
+
maxOptions?: number | null;
|
|
26
|
+
allowClear?: boolean;
|
|
24
27
|
placeholder?: string | null;
|
|
25
28
|
};
|
|
26
29
|
|
|
@@ -87,8 +90,23 @@ export function initFormieCombobox(select: SelectElement, options: FormieCombobo
|
|
|
87
90
|
|
|
88
91
|
const mergedOptions: Record<string, unknown> = {
|
|
89
92
|
create: false,
|
|
93
|
+
// Limit rendered results while retaining the full option list for searching.
|
|
94
|
+
maxOptions: options.maxOptions === undefined ? 100 : options.maxOptions,
|
|
90
95
|
maxItems: multiple ? null : 1,
|
|
91
|
-
plugins: multiple ? ['remove_button'] :
|
|
96
|
+
plugins: multiple ? ['remove_button'] : options.allowClear ? {
|
|
97
|
+
remove_button: {
|
|
98
|
+
// Use a native button so clearing a single answer works with keyboard input too.
|
|
99
|
+
html: () => {
|
|
100
|
+
const button = document.createElement('button');
|
|
101
|
+
button.type = 'button';
|
|
102
|
+
button.className = 'remove';
|
|
103
|
+
button.title = t('Clear selection');
|
|
104
|
+
button.setAttribute('aria-label', button.title);
|
|
105
|
+
button.textContent = '×';
|
|
106
|
+
return button;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
} : [],
|
|
92
110
|
hideSelected: multiple ? true : null,
|
|
93
111
|
clearAfterSelect: multiple,
|
|
94
112
|
closeAfterSelect: !multiple,
|
|
@@ -300,6 +300,9 @@ export const conditionsModule: BrowserModuleDefinition = {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
rebuild();
|
|
303
|
+
// Initial visibility and disabled state belong to module setup. Finish
|
|
304
|
+
// them before the form captures its clean baseline or announces readiness.
|
|
305
|
+
evaluateAll();
|
|
303
306
|
|
|
304
307
|
await ctx.emit('formie:module:conditions:init', {
|
|
305
308
|
count: entries.length,
|
|
@@ -3,6 +3,7 @@ import fileUploadCss from '#theme-css/fields/_file.css?inline';
|
|
|
3
3
|
import type { BrowserModuleDefinition } from '#contracts/modules';
|
|
4
4
|
import { dispatchFieldEvent, releaseFormValidators, retainFormValidators } from '#modules/fields/shared';
|
|
5
5
|
import { ensureModuleStyles } from '#modules/styles';
|
|
6
|
+
import { appendFormCsrfToFormData } from '#utils/csrf';
|
|
6
7
|
import { getFieldModuleEventName, getFileUploadEventName, getFormStateEventName } from '#utils/event-names';
|
|
7
8
|
import { requestJson } from '#utils/http';
|
|
8
9
|
|
|
@@ -429,11 +430,15 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
|
|
|
429
430
|
body.append('fieldHandle', fieldHandle);
|
|
430
431
|
}
|
|
431
432
|
|
|
432
|
-
const
|
|
433
|
+
const passthroughNames = ['submissionUid', 'resumeToken', 'submissionEditToken', 'siteId', 'renderId', 'draftContext', 'draftContextToken'] as const;
|
|
433
434
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
435
|
+
passthroughNames.forEach((name) => {
|
|
436
|
+
const contextInput = form?.querySelector(`input[name="${name}"]`);
|
|
437
|
+
|
|
438
|
+
if (contextInput instanceof HTMLInputElement && contextInput.value.trim()) {
|
|
439
|
+
body.append(name, contextInput.value.trim());
|
|
440
|
+
}
|
|
441
|
+
});
|
|
437
442
|
|
|
438
443
|
assets.forEach((asset) => {
|
|
439
444
|
if (asset.assetId) {
|
|
@@ -444,6 +449,8 @@ function buildHydrateFormData(field: HTMLElement, input: HTMLInputElement, asset
|
|
|
444
449
|
}
|
|
445
450
|
});
|
|
446
451
|
|
|
452
|
+
appendFormCsrfToFormData(body, form);
|
|
453
|
+
|
|
447
454
|
return body;
|
|
448
455
|
}
|
|
449
456
|
|
|
@@ -164,6 +164,27 @@ function initPhoneField(phoneInput: PhoneInput, countryInput: HTMLInputElement,
|
|
|
164
164
|
phoneInput.addEventListener('blur', syncCountry);
|
|
165
165
|
syncCountry();
|
|
166
166
|
|
|
167
|
+
// Hidden input values also change their native reset default, so retain the initial country.
|
|
168
|
+
let defaultCountry = countryInput.value;
|
|
169
|
+
let destroyed = false;
|
|
170
|
+
void validator.promise.then(() => {
|
|
171
|
+
if (!destroyed) {
|
|
172
|
+
defaultCountry ||= validator.getSelectedCountryData()?.iso2?.toUpperCase() || '';
|
|
173
|
+
}
|
|
174
|
+
}, () => {});
|
|
175
|
+
|
|
176
|
+
const form = phoneInput.form;
|
|
177
|
+
const onReset = (event: Event) => {
|
|
178
|
+
queueMicrotask(() => {
|
|
179
|
+
if (!destroyed && !event.defaultPrevented && defaultCountry) {
|
|
180
|
+
validator.setCountry(defaultCountry.toLowerCase() as IntlCountryCode);
|
|
181
|
+
validator.setNumber(phoneInput.defaultValue);
|
|
182
|
+
syncCountry();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
form?.addEventListener('reset', onReset);
|
|
187
|
+
|
|
167
188
|
// Keep the country dropdown button in sync when the tel input is enabled/disabled after
|
|
168
189
|
// init (multi-page page-hidden, or conditions). intl-tel-input disables the button to match
|
|
169
190
|
// a disabled input at init, but won't re-enable it when `disabled` is later removed.
|
|
@@ -199,6 +220,8 @@ function initPhoneField(phoneInput: PhoneInput, countryInput: HTMLInputElement,
|
|
|
199
220
|
});
|
|
200
221
|
|
|
201
222
|
return () => {
|
|
223
|
+
destroyed = true;
|
|
224
|
+
form?.removeEventListener('reset', onReset);
|
|
202
225
|
phoneInput.removeEventListener('countrychange', syncCountry);
|
|
203
226
|
phoneInput.removeEventListener('blur', syncCountry);
|
|
204
227
|
countryButtonObserver.disconnect();
|
|
@@ -168,6 +168,37 @@ function initRichTextField(container: HTMLElement, input: RichTextInput, options
|
|
|
168
168
|
input.richText = editor;
|
|
169
169
|
editor.content.innerHTML = input.value || '';
|
|
170
170
|
|
|
171
|
+
editor.content.setAttribute('role', 'textbox');
|
|
172
|
+
editor.content.setAttribute('aria-multiline', 'true');
|
|
173
|
+
|
|
174
|
+
const labels = Array.from(input.labels || []);
|
|
175
|
+
const ariaAttributes = ['aria-label', 'aria-labelledby', 'aria-invalid', 'aria-describedby', 'aria-errormessage'];
|
|
176
|
+
const syncAccessibility = () => {
|
|
177
|
+
ariaAttributes.forEach((attribute) => {
|
|
178
|
+
const value = input.getAttribute(attribute);
|
|
179
|
+
|
|
180
|
+
if (value !== null) {
|
|
181
|
+
editor.content.setAttribute(attribute, value);
|
|
182
|
+
} else {
|
|
183
|
+
editor.content.removeAttribute(attribute);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!input.hasAttribute('aria-label') && !input.hasAttribute('aria-labelledby') && labels.length) {
|
|
188
|
+
editor.content.setAttribute('aria-label', labels.map((label) => label.textContent?.trim() || '').join(' '));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
editor.content.setAttribute('aria-required', input.getAttribute('aria-required') || String(input.required));
|
|
192
|
+
};
|
|
193
|
+
syncAccessibility();
|
|
194
|
+
|
|
195
|
+
// Validation updates the submitted textarea; expose the same state on the editor.
|
|
196
|
+
const accessibilityObserver = new MutationObserver(syncAccessibility);
|
|
197
|
+
accessibilityObserver.observe(input, { attributes: true, attributeFilter: [...ariaAttributes, 'aria-required', 'required'] });
|
|
198
|
+
|
|
199
|
+
const onLabelClick = () => editor.content.focus();
|
|
200
|
+
labels.forEach((label) => label.addEventListener('click', onLabelClick));
|
|
201
|
+
|
|
171
202
|
const form = input.form;
|
|
172
203
|
const onReset = (event: Event) => {
|
|
173
204
|
// Wait for the textarea to regain its default value before updating Pell.
|
|
@@ -188,6 +219,8 @@ function initRichTextField(container: HTMLElement, input: RichTextInput, options
|
|
|
188
219
|
});
|
|
189
220
|
|
|
190
221
|
return () => {
|
|
222
|
+
accessibilityObserver.disconnect();
|
|
223
|
+
labels.forEach((label) => label.removeEventListener('click', onLabelClick));
|
|
191
224
|
form?.removeEventListener('reset', onReset);
|
|
192
225
|
container.innerHTML = '';
|
|
193
226
|
delete input.richText;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import summaryCss from '#theme-css/fields/_summary.css?inline';
|
|
2
2
|
|
|
3
3
|
import type { BrowserModuleDefinition } from '#contracts/modules';
|
|
4
|
+
import type { FormSubmitResult } from '#contracts/schema';
|
|
4
5
|
import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/shared';
|
|
5
6
|
import { ensureModuleStyles } from '#modules/styles';
|
|
6
7
|
import { toggleThemeClasses } from '#theme/theme-classes';
|
|
@@ -28,8 +29,10 @@ function getSummaryRequestUrl(): string {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
function getSummaryRequestState(field: HTMLElement): SummaryRequestState {
|
|
31
|
-
const
|
|
32
|
-
const accessToken =
|
|
32
|
+
const tokenElement = field.querySelector('[data-formie-summary-token]');
|
|
33
|
+
const accessToken = (tokenElement instanceof HTMLInputElement
|
|
34
|
+
? tokenElement.value
|
|
35
|
+
: tokenElement?.getAttribute('data-formie-summary-token'))?.trim() || null;
|
|
33
36
|
|
|
34
37
|
return {
|
|
35
38
|
accessToken,
|
|
@@ -71,6 +74,9 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
71
74
|
let dirtyVersion = 0;
|
|
72
75
|
let requestVersion = 0;
|
|
73
76
|
let activeRequest: AbortController | null = null;
|
|
77
|
+
let destroyed = false;
|
|
78
|
+
const initialState = getSummaryRequestState(field);
|
|
79
|
+
let accessToken = initialState.accessToken;
|
|
74
80
|
|
|
75
81
|
const getBlocks = (): HTMLElement | null => {
|
|
76
82
|
const blocks = field.querySelector(BLOCKS_SELECTOR);
|
|
@@ -101,11 +107,10 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
101
107
|
toggleThemeClasses(blocks, form, 'loading', false);
|
|
102
108
|
};
|
|
103
109
|
|
|
104
|
-
const initialState = getSummaryRequestState(field);
|
|
105
110
|
setLoadingState(!!initialState.accessToken);
|
|
106
111
|
|
|
107
112
|
const queueFetch = (): void => {
|
|
108
|
-
if (!isVisible || (hasFetched && !isDirty)) {
|
|
113
|
+
if (destroyed || !isVisible || (hasFetched && !isDirty)) {
|
|
109
114
|
return;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -114,7 +119,11 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
114
119
|
};
|
|
115
120
|
|
|
116
121
|
const fetchSummary = debounce(async() => {
|
|
117
|
-
|
|
122
|
+
if (destroyed) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const state = { accessToken };
|
|
118
127
|
|
|
119
128
|
if (!getBlocks() || !state.accessToken) {
|
|
120
129
|
debug.warn('Missing state for fetch.', state);
|
|
@@ -170,7 +179,9 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
170
179
|
setLoadingState(false);
|
|
171
180
|
activeRequest = null;
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
// Retry only when newer input arrived during this request. A
|
|
183
|
+
// failed request alone must not create an endless retry loop.
|
|
184
|
+
if (isDirty && dirtyVersion !== requestDirtyVersion) {
|
|
174
185
|
queueFetch();
|
|
175
186
|
}
|
|
176
187
|
}
|
|
@@ -194,18 +205,56 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
194
205
|
queueFetch();
|
|
195
206
|
};
|
|
196
207
|
|
|
197
|
-
const handleSubmitResult = (): void => {
|
|
198
|
-
|
|
199
|
-
|
|
208
|
+
const handleSubmitResult = (event: Event): void => {
|
|
209
|
+
const result = (event as CustomEvent<FormSubmitResult>).detail;
|
|
210
|
+
const tokens = result?.meta?.summaryTokens;
|
|
211
|
+
const uid = field.dataset.formieFieldUid;
|
|
212
|
+
|
|
213
|
+
// AJAX navigation reuses the initial markup, which had no submission
|
|
214
|
+
// token. The accepted save now grants access to this summary field.
|
|
215
|
+
if (uid && tokens && typeof tokens === 'object') {
|
|
216
|
+
const token = (tokens as Record<string, unknown>)[uid];
|
|
217
|
+
|
|
218
|
+
if (typeof token === 'string' && token) {
|
|
219
|
+
accessToken = token;
|
|
220
|
+
// Page navigation can remount modules with updated configuration.
|
|
221
|
+
getContainer()?.setAttribute('data-formie-summary-token', token);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
markDirty();
|
|
226
|
+
debug.log('Submit result received; refreshing.', { uid, hasToken: !!accessToken });
|
|
200
227
|
queueFetch();
|
|
201
228
|
};
|
|
202
229
|
|
|
203
230
|
const handlePageNavigate = (): void => {
|
|
204
|
-
|
|
231
|
+
markDirty();
|
|
205
232
|
debug.log('Page navigation received; refreshing.');
|
|
206
233
|
queueFetch();
|
|
207
234
|
};
|
|
208
235
|
|
|
236
|
+
const handleReset = (event: Event): void => {
|
|
237
|
+
queueMicrotask(() => {
|
|
238
|
+
if (event.defaultPrevented || destroyed) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A new submission must not display or fetch the previous visitor's
|
|
243
|
+
// completed values while its initial markup is reused.
|
|
244
|
+
requestVersion += 1;
|
|
245
|
+
activeRequest?.abort();
|
|
246
|
+
activeRequest = null;
|
|
247
|
+
accessToken = null;
|
|
248
|
+
getContainer()?.removeAttribute('data-formie-summary-token');
|
|
249
|
+
field.querySelectorAll('input[data-formie-summary-token]').forEach((input) => input.remove());
|
|
250
|
+
hasFetched = false;
|
|
251
|
+
getBlocks()?.replaceChildren();
|
|
252
|
+
setLoadingState(false);
|
|
253
|
+
markDirty();
|
|
254
|
+
queueFetch();
|
|
255
|
+
});
|
|
256
|
+
};
|
|
257
|
+
|
|
209
258
|
// Defer the summary fetch until the field is near view. This avoids extra
|
|
210
259
|
// requests for hidden pages or summaries the user never reaches.
|
|
211
260
|
const observer = new IntersectionObserver((entries) => {
|
|
@@ -228,14 +277,18 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
|
|
|
228
277
|
observer.observe(field);
|
|
229
278
|
form.addEventListener('input', handleFieldMutation);
|
|
230
279
|
form.addEventListener('change', handleFieldMutation);
|
|
280
|
+
form.addEventListener('reset', handleReset);
|
|
231
281
|
root.addEventListener('formie:page:navigate:after', handlePageNavigate as EventListener);
|
|
232
282
|
root.addEventListener('formie:submit:result', handleSubmitResult as EventListener);
|
|
233
283
|
|
|
234
284
|
return () => {
|
|
285
|
+
destroyed = true;
|
|
286
|
+
requestVersion += 1;
|
|
235
287
|
activeRequest?.abort();
|
|
236
288
|
observer.disconnect();
|
|
237
289
|
form.removeEventListener('input', handleFieldMutation);
|
|
238
290
|
form.removeEventListener('change', handleFieldMutation);
|
|
291
|
+
form.removeEventListener('reset', handleReset);
|
|
239
292
|
root.removeEventListener('formie:page:navigate:after', handlePageNavigate as EventListener);
|
|
240
293
|
root.removeEventListener('formie:submit:result', handleSubmitResult as EventListener);
|
|
241
294
|
debug.log('Field destroyed.');
|
|
@@ -323,12 +323,23 @@ export const textLimitModule: BrowserModuleDefinition = {
|
|
|
323
323
|
};
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
+
const onReset = (event: Event) => {
|
|
327
|
+
// Native reset restores input values after the reset event.
|
|
328
|
+
queueMicrotask(() => {
|
|
329
|
+
if (!event.defaultPrevented) {
|
|
330
|
+
counterInputs.forEach(updateCounter);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
ctx.form?.addEventListener('reset', onReset);
|
|
335
|
+
|
|
326
336
|
await ctx.emit('formie:module:text-limit:init', {
|
|
327
337
|
count: inputs.length,
|
|
328
338
|
});
|
|
329
339
|
|
|
330
340
|
return {
|
|
331
341
|
destroy: () => {
|
|
342
|
+
ctx.form?.removeEventListener('reset', onReset);
|
|
332
343
|
unbinds.forEach((unbind) => {
|
|
333
344
|
unbind();
|
|
334
345
|
});
|
|
@@ -132,6 +132,9 @@ export function createManagedPaymentModule<
|
|
|
132
132
|
const cleanups: Cleanup[] = [];
|
|
133
133
|
let apiPromise: Promise<TApi> | null = null;
|
|
134
134
|
let widget: TWidget | null = null;
|
|
135
|
+
let mountPromise: Promise<void> | null = null;
|
|
136
|
+
let destructionPromise: Promise<void> | null = null;
|
|
137
|
+
let destroyed = false;
|
|
135
138
|
let customSetupResult: PaymentSetupResult | null = null;
|
|
136
139
|
let authorizeInFlight: Promise<boolean> | null = null;
|
|
137
140
|
|
|
@@ -145,32 +148,59 @@ export function createManagedPaymentModule<
|
|
|
145
148
|
};
|
|
146
149
|
|
|
147
150
|
const ensureMounted = async() => {
|
|
148
|
-
|
|
151
|
+
const mount = adapter.mount;
|
|
152
|
+
|
|
153
|
+
if (destroyed || !mount || widget || !isPaymentFieldActive(ctx.target)) {
|
|
149
154
|
return;
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
157
|
+
// Conditions can fire again while a provider is requesting its session.
|
|
158
|
+
// Share the whole mount, including API loading, with submission callers.
|
|
159
|
+
if (!mountPromise) {
|
|
160
|
+
mountPromise = (async() => {
|
|
161
|
+
try {
|
|
162
|
+
const api = await getApi();
|
|
163
|
+
|
|
164
|
+
if (destroyed || !isPaymentFieldActive(ctx.target)) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
services.removeError();
|
|
169
|
+
widget = await mount({
|
|
170
|
+
api,
|
|
171
|
+
field: ctx.target,
|
|
172
|
+
services,
|
|
173
|
+
options,
|
|
174
|
+
provider: options.provider,
|
|
175
|
+
});
|
|
176
|
+
debug.log('Payment widget mounted.', {
|
|
177
|
+
moduleId: adapter.moduleId,
|
|
178
|
+
handle: options.handle,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Setup and submission callers still receive provider failures.
|
|
182
|
+
debug.warn('Payment widget mount failed.', {
|
|
183
|
+
moduleId: adapter.moduleId,
|
|
184
|
+
handle: options.handle,
|
|
185
|
+
});
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
})().finally(() => {
|
|
189
|
+
mountPromise = null;
|
|
171
190
|
});
|
|
172
|
-
throw error;
|
|
173
191
|
}
|
|
192
|
+
|
|
193
|
+
return mountPromise;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleVisibility = () => {
|
|
197
|
+
// Event listeners have no awaited caller to handle a rejection.
|
|
198
|
+
// Allow the next visibility or submission attempt to retry the mount.
|
|
199
|
+
void ensureMounted().catch(() => {
|
|
200
|
+
if (!destroyed && isPaymentFieldActive(ctx.target)) {
|
|
201
|
+
services.addError(options.transport.errorMessage);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
174
204
|
};
|
|
175
205
|
|
|
176
206
|
// Clear stale payment feedback at the start of every submit attempt.
|
|
@@ -194,10 +224,6 @@ export function createManagedPaymentModule<
|
|
|
194
224
|
// Remount when page flow or field conditions reveal this payment field.
|
|
195
225
|
const visibilityEvents = ['formie:page:navigate:after', 'formie:submit:result'];
|
|
196
226
|
visibilityEvents.forEach((eventName) => {
|
|
197
|
-
const handleVisibility = () => {
|
|
198
|
-
void ensureMounted();
|
|
199
|
-
};
|
|
200
|
-
|
|
201
227
|
ctx.root.addEventListener(eventName, handleVisibility as EventListener);
|
|
202
228
|
cleanups.push(() => {
|
|
203
229
|
ctx.root.removeEventListener(eventName, handleVisibility as EventListener);
|
|
@@ -205,41 +231,48 @@ export function createManagedPaymentModule<
|
|
|
205
231
|
});
|
|
206
232
|
|
|
207
233
|
// Conditions emit on the module bus (not only as page-nav DOM events).
|
|
208
|
-
cleanups.push(ctx.on('formie:conditions:evaluated',
|
|
209
|
-
void ensureMounted();
|
|
210
|
-
}));
|
|
234
|
+
cleanups.push(ctx.on('formie:conditions:evaluated', handleVisibility));
|
|
211
235
|
|
|
212
|
-
const destroy =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
236
|
+
const destroy = (): Promise<void> => {
|
|
237
|
+
if (destructionPromise) return destructionPromise;
|
|
238
|
+
destroyed = true;
|
|
239
|
+
destructionPromise = (async() => {
|
|
240
|
+
debug.log('Destroying payment module.', {
|
|
241
|
+
moduleId: adapter.moduleId,
|
|
242
|
+
handle: options.handle,
|
|
243
|
+
});
|
|
244
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
218
245
|
|
|
219
|
-
|
|
220
|
-
|
|
246
|
+
// A session request may finish after teardown starts. Release the
|
|
247
|
+
// resulting widget before another instance can use this container.
|
|
248
|
+
await mountPromise?.catch(() => {});
|
|
249
|
+
const mountedWidget = widget;
|
|
250
|
+
widget = null;
|
|
221
251
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
252
|
+
try {
|
|
253
|
+
if (mountedWidget && adapter.unmount) {
|
|
254
|
+
const api = await getApi();
|
|
255
|
+
|
|
256
|
+
await adapter.unmount({
|
|
257
|
+
api,
|
|
258
|
+
widget: mountedWidget,
|
|
259
|
+
field: ctx.target,
|
|
260
|
+
services,
|
|
261
|
+
options,
|
|
262
|
+
provider: options.provider,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
if (registry[occurrenceKey]?.destroy === destroy) {
|
|
267
|
+
delete registry[occurrenceKey];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
debug.log('Payment module destroy complete.', {
|
|
231
271
|
moduleId: adapter.moduleId,
|
|
232
272
|
handle: options.handle,
|
|
233
273
|
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (registry[occurrenceKey]?.destroy === destroy) {
|
|
237
|
-
delete registry[occurrenceKey];
|
|
238
|
-
}
|
|
239
|
-
debug.log('Payment module destroy complete.', {
|
|
240
|
-
moduleId: adapter.moduleId,
|
|
241
|
-
handle: options.handle,
|
|
242
|
-
});
|
|
274
|
+
})();
|
|
275
|
+
return destructionPromise;
|
|
243
276
|
};
|
|
244
277
|
|
|
245
278
|
registry[occurrenceKey] = { destroy };
|
|
@@ -3,6 +3,7 @@ import { browserRequest, type BrowserRequestOptions } from '@verbb/formie-core';
|
|
|
3
3
|
import opayoCss from '#theme-css/integrations/_opayo.css?inline';
|
|
4
4
|
|
|
5
5
|
import { definePaymentModule } from '#modules/payments/api';
|
|
6
|
+
import { findPaymentInputBySuffix } from '#modules/payments/utils';
|
|
6
7
|
import { ensureModuleStyles } from '#modules/styles';
|
|
7
8
|
import { toggleThemeClasses } from '#theme/theme-classes';
|
|
8
9
|
import { createDebug } from '#utils/debug';
|
|
@@ -277,6 +278,12 @@ export const opayoModule = definePaymentModule<OpayoProviderOptions, null, Opayo
|
|
|
277
278
|
return false;
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
// A completed challenge resumes the original transaction. The server
|
|
282
|
+
// verifies this reference and its ownership; no new card token is needed.
|
|
283
|
+
if (findPaymentInputBySuffix(field, 'opayo3DSComplete')?.value.trim()) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
280
287
|
if (isDropInCheckoutMode(provider)) {
|
|
281
288
|
if (!widget) {
|
|
282
289
|
services.addError('Opayo drop-in checkout is not ready.');
|