@verbb/formie-browser 1.0.4 → 1.0.5

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.
Files changed (182) hide show
  1. package/CHANGELOG.md +62 -22
  2. package/dist/chunks/address-country-Rx6GQTND.js +68 -0
  3. package/dist/chunks/address-finder-yXU5yMjG.js +30 -0
  4. package/dist/chunks/address-state-BQ0xGV9n.js +344 -0
  5. package/dist/chunks/api-CmwLRq_n.js +780 -0
  6. package/dist/chunks/api-DMK8NSUI.js +407 -0
  7. package/dist/chunks/api-sPqGbOww.js +125 -0
  8. package/dist/chunks/async-nPFRNQ06.js +25 -0
  9. package/dist/chunks/bpoint-DSjqRv63.js +24 -0
  10. package/dist/chunks/calculations-CgVJcQ2P.js +299 -0
  11. package/dist/chunks/captcha-eu-CWJb64_j.js +31 -0
  12. package/dist/chunks/checkbox-radio-DQ0H67Tj.js +125 -0
  13. package/dist/chunks/chunk-3b4jIN3o.js +13 -0
  14. package/dist/chunks/combobox-D-2b42wn.js +1954 -0
  15. package/dist/chunks/conditions-CZ3FU5QO.js +412 -0
  16. package/dist/chunks/constants-DVcJAvc5.js +38 -0
  17. package/dist/chunks/country-from-ip-BwxKaBuJ.js +26 -0
  18. package/dist/chunks/custom-google-maps-BsaSirEZ.js +194 -0
  19. package/dist/chunks/custom-link-CJ1-FjEM.js +46 -0
  20. package/dist/chunks/custom-maps-DYW_pBid.js +161 -0
  21. package/dist/chunks/date-picker-5CA_-zGb.js +5311 -0
  22. package/dist/chunks/debug-BV0DvdHx.js +41 -0
  23. package/dist/chunks/dist-BF5GU3_9.js +2674 -0
  24. package/dist/chunks/event-names-BCI2FLD8.js +51 -0
  25. package/dist/chunks/eway-D3ctTbEk.js +70 -0
  26. package/dist/chunks/field-references.keys-58ZSTrCW.js +21 -0
  27. package/dist/chunks/field-references.resolver-CHwn0G0L.js +145 -0
  28. package/dist/chunks/file-upload-D49m8-DR.js +287 -0
  29. package/dist/chunks/friendly-captcha-v1-ByXzENYZ.js +31 -0
  30. package/dist/chunks/friendly-captcha-v2-BPdlmhQu.js +36 -0
  31. package/dist/chunks/friendly-challenge-8uPatWqk.js +832 -0
  32. package/dist/chunks/go-cardless-BwASSYSK.js +23 -0
  33. package/dist/chunks/google-address-OhKilPWE.js +161 -0
  34. package/dist/chunks/hcaptcha-CdCd9oEi.js +54 -0
  35. package/dist/chunks/hidden-D7_Ch-QN.js +26 -0
  36. package/dist/chunks/http-D-JExro7.js +27 -0
  37. package/dist/chunks/i18n-BY1ds1BL.js +121 -0
  38. package/dist/chunks/leaflet-src-j-lHNmhR.js +4585 -0
  39. package/dist/chunks/loqate-Cp2zvwpE.js +76 -0
  40. package/dist/chunks/mollie-COG-ko2D.js +23 -0
  41. package/dist/chunks/moneris-CMH8PlRc.js +117 -0
  42. package/dist/chunks/opayo-D2jsR5vh.js +113 -0
  43. package/dist/chunks/paddle-DxF88RfC.js +55 -0
  44. package/dist/chunks/paypal-b5pnwrA2.js +95 -0
  45. package/dist/chunks/payway-BJYu9DIR.js +55 -0
  46. package/dist/chunks/phone-country-D3ayr60L.js +2645 -0
  47. package/dist/chunks/place-kit-BGHklIZ-.js +48 -0
  48. package/dist/chunks/placekit-autocomplete.esm-DWVabQlq.js +1236 -0
  49. package/dist/chunks/recaptcha-enterprise-BSwi8IVX.js +65 -0
  50. package/dist/chunks/recaptcha-shared-B4zAescW.js +15 -0
  51. package/dist/chunks/recaptcha-v2-checkbox-CnDQmsKa.js +40 -0
  52. package/dist/chunks/recaptcha-v2-invisible-Cz5uIlp-.js +53 -0
  53. package/dist/chunks/recaptcha-v3-BtbbP111.js +27 -0
  54. package/dist/chunks/repeater-B5leVxZU.js +102 -0
  55. package/dist/chunks/rich-text-Cv8ADyk-.js +349 -0
  56. package/dist/chunks/scripts-6CYSUGKQ.js +24 -0
  57. package/dist/chunks/sdk-BhHAYgga.js +1397 -0
  58. package/dist/chunks/shared-Bx9s0i0P.js +100 -0
  59. package/dist/chunks/signature-CpgqHCEx.js +463 -0
  60. package/dist/chunks/snaptcha-vz1RnZ15.js +8 -0
  61. package/dist/chunks/square-BP8RWAKB.js +40 -0
  62. package/dist/chunks/stripe-MihN63uC.js +184 -0
  63. package/dist/chunks/styles-BfoIZwJp.js +19 -0
  64. package/dist/chunks/summary-D3AjxpYN.js +114 -0
  65. package/dist/chunks/table-C-lkQN6I.js +78 -0
  66. package/dist/chunks/text-limit-B09elPDB.js +125 -0
  67. package/dist/chunks/theme-classes-Tv7q7ToE.js +44 -0
  68. package/dist/chunks/turnstile-D_Pi1CKM.js +58 -0
  69. package/dist/chunks/utils-Vqp8i_RM.js +49401 -0
  70. package/dist/css/formie-theme.css +2 -0
  71. package/dist/css/theme/fields/_address-state.css +94 -0
  72. package/dist/css/theme/fields/_combobox.css +246 -0
  73. package/dist/css/theme/fields/_select.css +13 -0
  74. package/dist/index.js +2447 -39
  75. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  76. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  77. package/dist/js/core/validation-focus.d.ts +4 -0
  78. package/dist/js/core/validation-focus.d.ts.map +1 -0
  79. package/dist/js/modules/address/api.d.ts +1 -0
  80. package/dist/js/modules/address/api.d.ts.map +1 -1
  81. package/dist/js/modules/address/constants.d.ts +22 -1
  82. package/dist/js/modules/address/constants.d.ts.map +1 -1
  83. package/dist/js/modules/address/host.d.ts +2 -2
  84. package/dist/js/modules/address/host.d.ts.map +1 -1
  85. package/dist/js/modules/address/loqate.d.ts.map +1 -1
  86. package/dist/js/modules/fields/address-country.d.ts +3 -0
  87. package/dist/js/modules/fields/address-country.d.ts.map +1 -0
  88. package/dist/js/modules/fields/address-state.d.ts +3 -0
  89. package/dist/js/modules/fields/address-state.d.ts.map +1 -0
  90. package/dist/js/modules/fields/calculations.d.ts.map +1 -1
  91. package/dist/js/modules/fields/combobox.d.ts +17 -0
  92. package/dist/js/modules/fields/combobox.d.ts.map +1 -0
  93. package/dist/js/modules/fields/custom-google-maps.d.ts +64 -0
  94. package/dist/js/modules/fields/custom-google-maps.d.ts.map +1 -0
  95. package/dist/js/modules/fields/custom-link.d.ts +3 -0
  96. package/dist/js/modules/fields/custom-link.d.ts.map +1 -0
  97. package/dist/js/modules/fields/custom-maps.d.ts +3 -0
  98. package/dist/js/modules/fields/custom-maps.d.ts.map +1 -0
  99. package/dist/js/modules/fields/date-picker.d.ts.map +1 -1
  100. package/dist/js/modules/fields/index.d.ts.map +1 -1
  101. package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
  102. package/dist/js/submit/pipeline.d.ts.map +1 -1
  103. package/dist/js/utils/country-from-ip.d.ts +7 -0
  104. package/dist/js/utils/country-from-ip.d.ts.map +1 -0
  105. package/dist/js/utils/field-references.d.ts +1 -0
  106. package/dist/js/utils/field-references.d.ts.map +1 -1
  107. package/dist/js/utils/field-references.row-scope.d.ts +11 -0
  108. package/dist/js/utils/field-references.row-scope.d.ts.map +1 -0
  109. package/package.json +6 -3
  110. package/src/css/formie-theme.css +2 -0
  111. package/src/css/theme/fields/_address-state.css +94 -0
  112. package/src/css/theme/fields/_combobox.css +246 -0
  113. package/src/css/theme/fields/_select.css +13 -0
  114. package/src/js/core/create-formie-client.ts +27 -0
  115. package/src/js/core/submit-result-ui.ts +4 -4
  116. package/src/js/core/validation-focus.ts +117 -0
  117. package/src/js/modules/address/api.ts +7 -0
  118. package/src/js/modules/address/constants.ts +82 -2
  119. package/src/js/modules/address/host.ts +16 -6
  120. package/src/js/modules/address/loqate.ts +3 -17
  121. package/src/js/modules/fields/address-country.ts +156 -0
  122. package/src/js/modules/fields/address-state.ts +946 -0
  123. package/src/js/modules/fields/calculations.ts +32 -0
  124. package/src/js/modules/fields/combobox.ts +188 -0
  125. package/src/js/modules/fields/custom-google-maps.ts +416 -0
  126. package/src/js/modules/fields/custom-link.ts +95 -0
  127. package/src/js/modules/fields/custom-maps.ts +339 -0
  128. package/src/js/modules/fields/date-picker.ts +96 -3
  129. package/src/js/modules/fields/index.ts +6 -0
  130. package/src/js/modules/fields/phone-country.ts +6 -0
  131. package/src/js/submit/pipeline.ts +11 -1
  132. package/src/js/utils/country-from-ip.ts +54 -0
  133. package/src/js/utils/field-references.row-scope.ts +303 -0
  134. package/src/js/utils/field-references.ts +1 -0
  135. package/dist/chunks/address-finder-BU4roxT6.js +0 -37
  136. package/dist/chunks/bpoint-uNg5j6rt.js +0 -26
  137. package/dist/chunks/calculations-C9t6Zop9.js +0 -147
  138. package/dist/chunks/captcha-eu-BAPzHNts.js +0 -34
  139. package/dist/chunks/checkbox-radio-CHeLNVIK.js +0 -141
  140. package/dist/chunks/conditions-DKxVCsjD.js +0 -459
  141. package/dist/chunks/date-picker-B3iQ797Z.js +0 -4994
  142. package/dist/chunks/eway-DCielIML.js +0 -74
  143. package/dist/chunks/file-upload-DP2TOUYV.js +0 -316
  144. package/dist/chunks/friendly-captcha-v1-DdlcGiFY.js +0 -33
  145. package/dist/chunks/friendly-captcha-v2-76w-YnFm.js +0 -38
  146. package/dist/chunks/go-cardless-rwCkzbNK.js +0 -24
  147. package/dist/chunks/google-address-BD8IZ86V.js +0 -181
  148. package/dist/chunks/hcaptcha-BNNTL6uf.js +0 -56
  149. package/dist/chunks/hidden-CW6Rcf1N.js +0 -31
  150. package/dist/chunks/index-BEsfee-D.js +0 -1839
  151. package/dist/chunks/index-BP7Unm5Y.js +0 -863
  152. package/dist/chunks/index-D6npYxvr.js +0 -4423
  153. package/dist/chunks/loqate-B-ZNS65B.js +0 -55
  154. package/dist/chunks/mollie-BDAZ-1X4.js +0 -24
  155. package/dist/chunks/moneris-B1b0uEtY.js +0 -101
  156. package/dist/chunks/opayo-CC5hraHo.js +0 -122
  157. package/dist/chunks/paddle-B4GmrDsq.js +0 -60
  158. package/dist/chunks/paypal-BFS6XsIS.js +0 -100
  159. package/dist/chunks/payway-DekoJead.js +0 -56
  160. package/dist/chunks/phone-country-Cm5YUeXI.js +0 -3765
  161. package/dist/chunks/place-kit-DizZWV_e.js +0 -42
  162. package/dist/chunks/placekit-autocomplete.esm-LAwfbPZl.js +0 -1299
  163. package/dist/chunks/recaptcha-enterprise-BbOvO7JZ.js +0 -71
  164. package/dist/chunks/recaptcha-shared-Dwg1eVFS.js +0 -15
  165. package/dist/chunks/recaptcha-v2-checkbox-SY3ti5iQ.js +0 -41
  166. package/dist/chunks/recaptcha-v2-invisible-DSmQnn6c.js +0 -59
  167. package/dist/chunks/recaptcha-v3-DNh7YCnR.js +0 -28
  168. package/dist/chunks/repeater-DM0vuFNQ.js +0 -124
  169. package/dist/chunks/rich-text-C8aGYp70.js +0 -304
  170. package/dist/chunks/scripts-CKTFtxPO.js +0 -36
  171. package/dist/chunks/sdk-3SicTWby.js +0 -1559
  172. package/dist/chunks/shared--BU5CFoL.js +0 -80
  173. package/dist/chunks/signature-pFhZfH7d.js +0 -550
  174. package/dist/chunks/snaptcha-VuOUOaLj.js +0 -8
  175. package/dist/chunks/square-BR9pvToy.js +0 -46
  176. package/dist/chunks/stripe-OHUwoV9g.js +0 -202
  177. package/dist/chunks/styles-C3aqgtek.js +0 -21
  178. package/dist/chunks/summary-Da7Ktg3N.js +0 -126
  179. package/dist/chunks/table-C0PmR9bI.js +0 -95
  180. package/dist/chunks/text-limit-mLEwZDIa.js +0 -144
  181. package/dist/chunks/turnstile-9tM7q_LK.js +0 -59
  182. package/dist/chunks/utils-BntI3W4S.js +0 -6149
@@ -12,9 +12,21 @@ import {
12
12
  import type { FormieModuleDefinition } from '#contracts/modules';
13
13
  import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/shared';
14
14
  import { buildFieldValueRegistry, fieldKeyToInputName, normalizeFieldKey, resolveFieldReferenceLive } from '#utils/field-references';
15
+ import { getRowScopedWatchNames, resolveRowScopedFieldReference, type RowScopeParams } from '#utils/field-references.row-scope';
15
16
  import { createDebug } from '#utils/debug';
16
17
 
17
18
  const INPUT_SELECTOR = 'input[data-formie-calculation-input]';
19
+
20
+ function getRowScopeParams(variable: CalculationVariable): RowScopeParams {
21
+ const scoped = variable as CalculationVariable & RowScopeParams;
22
+
23
+ return {
24
+ scope: scoped.scope,
25
+ index: scoped.index,
26
+ rows: scoped.rows,
27
+ fieldKind: scoped.fieldKind,
28
+ };
29
+ }
18
30
  const MODULE_ID = 'calculations';
19
31
  const debug = createDebug('fields', 'calculations');
20
32
 
@@ -27,6 +39,15 @@ function resolveVariables(
27
39
  const variables: Record<string, unknown> = {};
28
40
 
29
41
  variableEntries.forEach(([variableKey, variable]) => {
42
+ const scopeParams = getRowScopeParams(variable);
43
+ const scope = String(scopeParams.scope || '').trim();
44
+
45
+ if (scope) {
46
+ const resolved = resolveRowScopedFieldReference(variable.sourceKey || '', scopeParams, registry);
47
+ variables[variableKey] = readCalculationVariableValue(variable, resolved.value);
48
+ return;
49
+ }
50
+
30
51
  const resolved = resolveFieldReferenceLive(variable.sourceKey || '', registry);
31
52
  variables[variableKey] = readCalculationVariableValue(variable, resolved.value);
32
53
  });
@@ -39,6 +60,17 @@ function getWatchNames(root: Element, variableEntries: CalculationVariableEntry[
39
60
  const watchNames = new Set<string>();
40
61
 
41
62
  variableEntries.forEach(([, variable]) => {
63
+ const scopeParams = getRowScopeParams(variable);
64
+ const scope = String(scopeParams.scope || '').trim();
65
+
66
+ if (scope) {
67
+ getRowScopedWatchNames(variable.sourceKey || '', scopeParams, registry).forEach((name) => {
68
+ watchNames.add(name);
69
+ });
70
+
71
+ return;
72
+ }
73
+
42
74
  const key = normalizeFieldKey(variable.sourceKey || '');
43
75
  const entry = registry.get(key);
44
76
 
@@ -0,0 +1,188 @@
1
+ import TomSelect from 'tom-select/dist/esm/tom-select.complete.js';
2
+
3
+ import type { FormieModuleDefinition } from '#contracts/modules';
4
+ import comboboxThemeCss from '#theme-css/fields/_combobox.css?inline';
5
+ import { dispatchFieldEvent, getModuleFieldContainers } from '#modules/fields/shared';
6
+ import { ensureModuleStyles } from '#modules/styles';
7
+ import { createDebug } from '#utils/debug';
8
+
9
+ const SELECT_SELECTOR = 'select[data-formie-combobox-input]';
10
+ const MODULE_ID = 'combobox';
11
+ const debug = createDebug('fields', 'combobox');
12
+
13
+ ensureModuleStyles(MODULE_ID, [comboboxThemeCss]);
14
+
15
+ const NATIVE_THEME_CLASSES = [
16
+ 'formie-select',
17
+ 'formie-dropdown-input',
18
+ 'formie-input-error',
19
+ ] as const;
20
+
21
+ export type FormieComboboxOptions = {
22
+ multiple?: boolean;
23
+ placeholder?: string | null;
24
+ };
25
+
26
+ type TomSelectInstance = InstanceType<typeof TomSelect> & {
27
+ wrapper: HTMLElement;
28
+ dropdown?: HTMLElement;
29
+ };
30
+
31
+ type SelectElement = HTMLSelectElement & {
32
+ _formieTomSelect?: TomSelectInstance;
33
+ };
34
+
35
+ function stripNativeThemeClasses(select: HTMLSelectElement): string[] {
36
+ const removed: string[] = [];
37
+
38
+ NATIVE_THEME_CLASSES.forEach((className) => {
39
+ if (select.classList.contains(className)) {
40
+ select.classList.remove(className);
41
+ removed.push(className);
42
+ }
43
+ });
44
+
45
+ return removed;
46
+ }
47
+
48
+ function restoreNativeThemeClasses(select: HTMLSelectElement, classNames: string[]): void {
49
+ classNames.forEach((className) => {
50
+ select.classList.add(className);
51
+ });
52
+ }
53
+
54
+ function cleanLeakedThemeClasses(element: HTMLElement): void {
55
+ NATIVE_THEME_CLASSES.forEach((className) => {
56
+ element.classList.remove(className);
57
+ });
58
+ }
59
+
60
+ function resolvePlaceholder(select: HTMLSelectElement, configuredPlaceholder?: string | null): string | null {
61
+ const configured = configuredPlaceholder?.trim();
62
+
63
+ if (configured) {
64
+ return configured;
65
+ }
66
+
67
+ const emptyOption = select.querySelector('option[value=""]');
68
+
69
+ return emptyOption?.textContent?.trim() || null;
70
+ }
71
+
72
+ function removeEmptyOptionFromCombobox(instance: TomSelectInstance): void {
73
+ if (instance.options['']) {
74
+ instance.removeOption('', true);
75
+ }
76
+ }
77
+
78
+ export function initFormieCombobox(select: SelectElement, options: FormieComboboxOptions = {}): () => void {
79
+ select._formieTomSelect?.destroy();
80
+
81
+ const multiple = options.multiple === true;
82
+ const restoredClasses = stripNativeThemeClasses(select);
83
+ const placeholder = resolvePlaceholder(select, options.placeholder);
84
+
85
+ const mergedOptions: Record<string, unknown> = {
86
+ create: false,
87
+ maxItems: multiple ? null : 1,
88
+ plugins: multiple ? ['remove_button'] : [],
89
+ hideSelected: multiple ? true : null,
90
+ clearAfterSelect: multiple,
91
+ closeAfterSelect: !multiple,
92
+ allowEmptyOption: !multiple,
93
+ openOnFocus: true,
94
+ diacritics: true,
95
+ // Tom Select copies the native select class attribute onto its wrapper,
96
+ // which would duplicate Formie select chrome if left on the <select>.
97
+ copyClassesToDropdown: false,
98
+ wrapperClass: 'ts-wrapper formie-combobox',
99
+ onChange: () => {
100
+ // Mirror Tom Select changes back through the native select so validation,
101
+ // conditions, and calculations respond to combobox-driven updates.
102
+ select.dispatchEvent(new Event('input', { bubbles: true }));
103
+ select.dispatchEvent(new Event('change', { bubbles: true }));
104
+ },
105
+ };
106
+
107
+ if (placeholder) {
108
+ mergedOptions.placeholder = placeholder;
109
+ }
110
+
111
+ dispatchFieldEvent(select, MODULE_ID, 'before-init', {
112
+ select,
113
+ options: mergedOptions,
114
+ });
115
+
116
+ const instance = new TomSelect(select, mergedOptions) as TomSelectInstance;
117
+ removeEmptyOptionFromCombobox(instance);
118
+ cleanLeakedThemeClasses(instance.wrapper);
119
+
120
+ if (instance.dropdown) {
121
+ cleanLeakedThemeClasses(instance.dropdown);
122
+ }
123
+
124
+ // Tom Select keeps the native select as a preceding sibling; ensure it never
125
+ // remains visible once enhanced (notably for multi-select listboxes).
126
+ select.style.display = 'none';
127
+
128
+ select._formieTomSelect = instance;
129
+
130
+ debug.log('Initialized.', {
131
+ inputName: select.name,
132
+ multiple,
133
+ });
134
+
135
+ dispatchFieldEvent(select, MODULE_ID, 'after-init', {
136
+ combobox: instance,
137
+ options: mergedOptions,
138
+ });
139
+
140
+ return () => {
141
+ instance.destroy();
142
+ select.style.removeProperty('display');
143
+ restoreNativeThemeClasses(select, restoredClasses);
144
+ delete select._formieTomSelect;
145
+ debug.log('Destroyed.', {
146
+ inputName: select.name,
147
+ });
148
+ };
149
+ }
150
+
151
+ export const comboboxModule: FormieModuleDefinition = {
152
+ id: MODULE_ID,
153
+ kind: 'field',
154
+ match: (ctx) => {
155
+ return !!ctx.target.querySelector(SELECT_SELECTOR);
156
+ },
157
+ setup: async (ctx) => {
158
+ const options = (ctx.options || {}) as FormieComboboxOptions;
159
+ const fields = getModuleFieldContainers(ctx);
160
+ const cleanups = fields.map((field) => {
161
+ const select = field.querySelector(SELECT_SELECTOR);
162
+
163
+ if (!(select instanceof HTMLSelectElement)) {
164
+ debug.warn('Field missing combobox select; skipping.');
165
+ return () => { };
166
+ }
167
+
168
+ return initFormieCombobox(select as SelectElement, options);
169
+ });
170
+
171
+ debug.log('Module setup.', { fieldCount: fields.length });
172
+
173
+ await ctx.emit('formie:module:combobox:init', {
174
+ count: cleanups.length,
175
+ });
176
+
177
+ return {
178
+ destroy: () => {
179
+ cleanups.forEach((cleanup) => {
180
+ cleanup();
181
+ });
182
+
183
+ debug.log('Module destroy.', { fieldCount: fields.length });
184
+ void ctx.emit('formie:module:combobox:destroy', {});
185
+ },
186
+ };
187
+ },
188
+ };
@@ -0,0 +1,416 @@
1
+ import type { FormieModuleDefinition } from '#contracts/modules';
2
+ import { getModuleFieldTarget, observeMatchingElements } from '#modules/fields/shared';
3
+
4
+ const MODULE_ID = 'custom-google-maps';
5
+ const GOOGLE_MAPS_SELECTOR = '[data-formie-custom-google-maps]';
6
+
7
+ type LatLng = {
8
+ lat: number;
9
+ lng: number;
10
+ };
11
+
12
+ type GoogleMapsSettings = {
13
+ apiUrl?: string;
14
+ defaultLat?: number | null;
15
+ defaultLng?: number | null;
16
+ defaultZoom?: number | null;
17
+ minZoom?: number | null;
18
+ maxZoom?: number | null;
19
+ country?: string | null;
20
+ };
21
+
22
+ type GoogleMapsElements = {
23
+ root: HTMLElement;
24
+ canvas: HTMLElement | null;
25
+ searchInput: HTMLInputElement | null;
26
+ currentLocationButton: HTMLButtonElement | null;
27
+ inputs: Record<string, HTMLInputElement>;
28
+ };
29
+
30
+ type GoogleAddressComponent = {
31
+ long_name: string;
32
+ short_name: string;
33
+ types: string[];
34
+ };
35
+
36
+ type GooglePlace = {
37
+ formatted_address?: string;
38
+ name?: string;
39
+ place_id?: string;
40
+ address_components?: GoogleAddressComponent[];
41
+ geometry?: {
42
+ location?: {
43
+ lat: () => number;
44
+ lng: () => number;
45
+ };
46
+ };
47
+ };
48
+
49
+ type GoogleMapsNamespace = {
50
+ maps: {
51
+ Map: new(element: HTMLElement, options: Record<string, unknown>) => GoogleMap;
52
+ Marker: new(options: Record<string, unknown>) => GoogleMarker;
53
+ LatLng: new(lat: number, lng: number) => unknown;
54
+ importLibrary?: (name: string) => Promise<unknown>;
55
+ places: {
56
+ Autocomplete: new(input: HTMLInputElement, options?: Record<string, unknown>) => GoogleAutocomplete;
57
+ };
58
+ };
59
+ };
60
+
61
+ type GoogleMap = {
62
+ setCenter: (latLng: LatLng) => void;
63
+ setZoom: (zoom: number) => void;
64
+ getZoom: () => number | undefined;
65
+ addListener: (event: string, callback: (event: { latLng?: { lat: () => number; lng: () => number } }) => void) => void;
66
+ };
67
+
68
+ type GoogleMarker = {
69
+ setPosition: (latLng: LatLng) => void;
70
+ getPosition: () => { lat: () => number; lng: () => number } | null | undefined;
71
+ addListener: (event: string, callback: () => void) => void;
72
+ };
73
+
74
+ type GoogleAutocomplete = {
75
+ addListener: (event: string, callback: () => void) => void;
76
+ getPlace: () => GooglePlace;
77
+ };
78
+
79
+ declare global {
80
+ interface Window {
81
+ google?: GoogleMapsNamespace;
82
+ }
83
+ }
84
+
85
+ let googleMapsPromise: Promise<GoogleMapsNamespace> | null = null;
86
+
87
+ function isCustomGoogleMaps(element: Element): element is HTMLElement {
88
+ return element instanceof HTMLElement && element.matches(GOOGLE_MAPS_SELECTOR);
89
+ }
90
+
91
+ function parseSettings(root: HTMLElement): GoogleMapsSettings {
92
+ const raw = root.getAttribute('data-formie-custom-google-maps-settings');
93
+
94
+ if (!raw) {
95
+ return {};
96
+ }
97
+
98
+ try {
99
+ return JSON.parse(raw) as GoogleMapsSettings;
100
+ } catch {
101
+ return {};
102
+ }
103
+ }
104
+
105
+ function findElements(root: HTMLElement): GoogleMapsElements {
106
+ const inputs: Record<string, HTMLInputElement> = {};
107
+
108
+ root.querySelectorAll<HTMLInputElement>('[data-formie-custom-google-maps-field]').forEach((input) => {
109
+ const key = input.getAttribute('data-formie-custom-google-maps-field');
110
+
111
+ if (key) {
112
+ inputs[key] = input;
113
+ }
114
+ });
115
+
116
+ return {
117
+ root,
118
+ canvas: root.querySelector<HTMLElement>('[data-formie-custom-google-maps-canvas]'),
119
+ searchInput: root.querySelector<HTMLInputElement>('[data-formie-custom-google-maps-search]'),
120
+ currentLocationButton: root.querySelector<HTMLButtonElement>('[data-formie-custom-google-maps-current-location]'),
121
+ inputs,
122
+ };
123
+ }
124
+
125
+ function toNumber(value: string | number | null | undefined): number | null {
126
+ if (value === null || value === undefined || value === '') {
127
+ return null;
128
+ }
129
+
130
+ const number = Number(value);
131
+
132
+ return Number.isFinite(number) ? number : null;
133
+ }
134
+
135
+ function getCurrentLatLng(elements: GoogleMapsElements, settings: GoogleMapsSettings): LatLng {
136
+ return {
137
+ lat: toNumber(elements.inputs.lat?.value) ?? settings.defaultLat ?? -37.7841813,
138
+ lng: toNumber(elements.inputs.lng?.value) ?? settings.defaultLng ?? 144.9378721,
139
+ };
140
+ }
141
+
142
+ function getCurrentZoom(elements: GoogleMapsElements, settings: GoogleMapsSettings): number {
143
+ return toNumber(elements.inputs.zoom?.value) ?? settings.defaultZoom ?? 11;
144
+ }
145
+
146
+ function setValue(input: HTMLInputElement | undefined | null, value: string | number | null | undefined): void {
147
+ if (!input) {
148
+ return;
149
+ }
150
+
151
+ input.value = value === null || value === undefined ? '' : String(value);
152
+ input.dispatchEvent(new Event('input', { bubbles: true }));
153
+ input.dispatchEvent(new Event('change', { bubbles: true }));
154
+ }
155
+
156
+ function loadScript(src: string): Promise<void> {
157
+ return new Promise((resolve, reject) => {
158
+ const existing = Array.from(document.scripts).find((script) => script.src === src);
159
+
160
+ if (window.google?.maps?.places) {
161
+ resolve();
162
+ return;
163
+ }
164
+
165
+ if (existing) {
166
+ existing.addEventListener('load', () => resolve(), { once: true });
167
+ existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true });
168
+ return;
169
+ }
170
+
171
+ const script = document.createElement('script');
172
+ script.src = src;
173
+ script.async = true;
174
+ script.onload = () => resolve();
175
+ script.onerror = () => reject(new Error(`Failed to load ${src}`));
176
+ document.head.appendChild(script);
177
+ });
178
+ }
179
+
180
+ function wait(ms: number): Promise<void> {
181
+ return new Promise((resolve) => {
182
+ window.setTimeout(resolve, ms);
183
+ });
184
+ }
185
+
186
+ async function ensureGoogleMapsLibraries(timeoutMs = 12000, intervalMs = 50): Promise<GoogleMapsNamespace> {
187
+ const started = Date.now();
188
+
189
+ while (Date.now() - started < timeoutMs) {
190
+ const maps = window.google?.maps;
191
+
192
+ if (maps?.importLibrary) {
193
+ try {
194
+ if (!maps.Map) {
195
+ await maps.importLibrary('maps');
196
+ }
197
+
198
+ if (!maps.Marker) {
199
+ await maps.importLibrary('marker');
200
+ }
201
+
202
+ if (!maps.places?.Autocomplete) {
203
+ await maps.importLibrary('places');
204
+ }
205
+ } catch {
206
+ // Keep polling; the classic constructors may still arrive after the script settles.
207
+ }
208
+ }
209
+
210
+ if (window.google?.maps?.Map && window.google.maps.Marker && window.google.maps.places?.Autocomplete) {
211
+ return window.google;
212
+ }
213
+
214
+ await wait(intervalMs);
215
+ }
216
+
217
+ throw new Error('Google Maps Places API did not initialize.');
218
+ }
219
+
220
+ function loadGoogleMaps(settings: GoogleMapsSettings): Promise<GoogleMapsNamespace> {
221
+ if (!settings.apiUrl) {
222
+ return Promise.reject(new Error('Google Maps API URL is missing.'));
223
+ }
224
+
225
+ if (!googleMapsPromise) {
226
+ googleMapsPromise = loadScript(settings.apiUrl).then(() => ensureGoogleMapsLibraries());
227
+ }
228
+
229
+ return googleMapsPromise;
230
+ }
231
+
232
+ function component(components: GoogleAddressComponent[] | undefined, type: string, short = false): string {
233
+ const match = components?.find((entry) => entry.types.includes(type));
234
+
235
+ return short ? match?.short_name || '' : match?.long_name || '';
236
+ }
237
+
238
+ function parsePlace(place: GooglePlace): Record<string, string> {
239
+ const components = place.address_components || [];
240
+ const streetNumber = component(components, 'street_number');
241
+ const route = component(components, 'route');
242
+ const street1 = [streetNumber, route].filter(Boolean).join(' ');
243
+ const lat = place.geometry?.location?.lat();
244
+ const lng = place.geometry?.location?.lng();
245
+
246
+ return {
247
+ formatted: place.formatted_address || '',
248
+ raw: JSON.stringify(place),
249
+ name: place.name || '',
250
+ street1,
251
+ city: component(components, 'locality') || component(components, 'postal_town') || component(components, 'administrative_area_level_2'),
252
+ state: component(components, 'administrative_area_level_1', true),
253
+ zip: component(components, 'postal_code'),
254
+ neighborhood: component(components, 'neighborhood') || component(components, 'sublocality'),
255
+ county: component(components, 'administrative_area_level_2'),
256
+ country: component(components, 'country'),
257
+ countryCode: component(components, 'country', true),
258
+ placeId: place.place_id || '',
259
+ lat: lat === undefined ? '' : String(lat),
260
+ lng: lng === undefined ? '' : String(lng),
261
+ };
262
+ }
263
+
264
+ function applyValues(elements: GoogleMapsElements, values: Record<string, string | number>, map?: GoogleMap, marker?: GoogleMarker): void {
265
+ Object.entries(values).forEach(([key, value]) => {
266
+ setValue(elements.inputs[key], value);
267
+ });
268
+
269
+ if (values.formatted !== undefined) {
270
+ setValue(elements.searchInput, values.formatted);
271
+ }
272
+
273
+ const lat = toNumber(values.lat);
274
+ const lng = toNumber(values.lng);
275
+
276
+ if (lat !== null && lng !== null) {
277
+ const latLng = { lat, lng };
278
+ marker?.setPosition(latLng);
279
+ map?.setCenter(latLng);
280
+ }
281
+ }
282
+
283
+ async function initGoogleMapsField(elements: GoogleMapsElements, settings: GoogleMapsSettings): Promise<() => void> {
284
+ const google = await loadGoogleMaps(settings);
285
+ const initialLatLng = getCurrentLatLng(elements, settings);
286
+ const initialZoom = getCurrentZoom(elements, settings);
287
+ let map: GoogleMap | undefined;
288
+ let marker: GoogleMarker | undefined;
289
+
290
+ if (elements.canvas) {
291
+ map = new google.maps.Map(elements.canvas, {
292
+ center: initialLatLng,
293
+ zoom: initialZoom,
294
+ minZoom: settings.minZoom ?? undefined,
295
+ maxZoom: settings.maxZoom ?? undefined,
296
+ });
297
+
298
+ marker = new google.maps.Marker({
299
+ position: initialLatLng,
300
+ map,
301
+ draggable: true,
302
+ });
303
+
304
+ map.addListener('click', (event) => {
305
+ const lat = event.latLng?.lat();
306
+ const lng = event.latLng?.lng();
307
+
308
+ if (lat === undefined || lng === undefined) {
309
+ return;
310
+ }
311
+
312
+ applyValues(elements, {
313
+ lat,
314
+ lng,
315
+ zoom: map?.getZoom() || initialZoom,
316
+ }, map, marker);
317
+ });
318
+
319
+ marker.addListener('dragend', () => {
320
+ const position = marker?.getPosition();
321
+
322
+ if (!position) {
323
+ return;
324
+ }
325
+
326
+ applyValues(elements, {
327
+ lat: position.lat(),
328
+ lng: position.lng(),
329
+ zoom: map?.getZoom() || initialZoom,
330
+ }, map, marker);
331
+ });
332
+ }
333
+
334
+ let autocomplete: GoogleAutocomplete | null = null;
335
+
336
+ if (elements.searchInput) {
337
+ autocomplete = new google.maps.places.Autocomplete(elements.searchInput, {
338
+ fields: ['address_components', 'formatted_address', 'geometry', 'name', 'place_id'],
339
+ componentRestrictions: settings.country ? { country: settings.country.toLowerCase() } : undefined,
340
+ });
341
+
342
+ autocomplete.addListener('place_changed', () => {
343
+ const place = autocomplete?.getPlace();
344
+
345
+ if (!place) {
346
+ return;
347
+ }
348
+
349
+ applyValues(elements, parsePlace(place), map, marker);
350
+ });
351
+ }
352
+
353
+ const onCurrentLocationClick = (): void => {
354
+ navigator.geolocation?.getCurrentPosition((position) => {
355
+ applyValues(elements, {
356
+ lat: position.coords.latitude,
357
+ lng: position.coords.longitude,
358
+ zoom: map?.getZoom() || initialZoom,
359
+ }, map, marker);
360
+ });
361
+ };
362
+
363
+ elements.currentLocationButton?.addEventListener('click', onCurrentLocationClick);
364
+
365
+ return () => {
366
+ elements.currentLocationButton?.removeEventListener('click', onCurrentLocationClick);
367
+ };
368
+ }
369
+
370
+ function initCustomGoogleMaps(root: HTMLElement): () => void {
371
+ const elements = findElements(root);
372
+ const settings = parseSettings(root);
373
+ let cleanup: (() => void) | null = null;
374
+ let destroyed = false;
375
+
376
+ void initGoogleMapsField(elements, settings).then((destroy) => {
377
+ if (destroyed) {
378
+ destroy();
379
+ return;
380
+ }
381
+
382
+ cleanup = destroy;
383
+ }).catch((error: unknown) => {
384
+ console.warn('[Formie] Unable to initialize Google Maps custom field.', error);
385
+ });
386
+
387
+ return () => {
388
+ destroyed = true;
389
+ cleanup?.();
390
+ };
391
+ }
392
+
393
+ export const customGoogleMapsModule: FormieModuleDefinition = {
394
+ id: MODULE_ID,
395
+ kind: 'field',
396
+ match: ({ target }) => {
397
+ return target instanceof Element && (
398
+ target.matches(GOOGLE_MAPS_SELECTOR) ||
399
+ !!target.querySelector(GOOGLE_MAPS_SELECTOR)
400
+ );
401
+ },
402
+ setup: async(ctx) => {
403
+ const field = getModuleFieldTarget(ctx);
404
+ const root = field || ctx.target;
405
+
406
+ if (!(root instanceof Element)) {
407
+ return;
408
+ }
409
+
410
+ const cleanup = observeMatchingElements(root, GOOGLE_MAPS_SELECTOR, isCustomGoogleMaps, initCustomGoogleMaps);
411
+
412
+ return {
413
+ destroy: cleanup,
414
+ };
415
+ },
416
+ };