@verbb/formie-browser 1.0.4 → 1.0.6

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 (197) hide show
  1. package/CHANGELOG.md +76 -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-CeDgB9jt.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-DjZOtHfS.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-BQ4a9hmy.js +2659 -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-D0sb8bmX.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-xz4QO7CR.js +536 -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-DbgEFHLO.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/upload-manager-BpqWSHmU.js +2558 -0
  70. package/dist/chunks/utils-Vqp8i_RM.js +49401 -0
  71. package/dist/css/formie-theme.css +2 -0
  72. package/dist/css/theme/_buttons.css +16 -0
  73. package/dist/css/theme/_tokens.css +2 -0
  74. package/dist/css/theme/fields/_address-state.css +94 -0
  75. package/dist/css/theme/fields/_combobox.css +246 -0
  76. package/dist/css/theme/fields/_select.css +13 -0
  77. package/dist/css/theme/fields/_signature.css +19 -0
  78. package/dist/css/theme/fields/_upload-manager.css +279 -0
  79. package/dist/index.js +2448 -39
  80. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  81. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  82. package/dist/js/core/validation-focus.d.ts +4 -0
  83. package/dist/js/core/validation-focus.d.ts.map +1 -0
  84. package/dist/js/modules/address/api.d.ts +1 -0
  85. package/dist/js/modules/address/api.d.ts.map +1 -1
  86. package/dist/js/modules/address/constants.d.ts +22 -1
  87. package/dist/js/modules/address/constants.d.ts.map +1 -1
  88. package/dist/js/modules/address/host.d.ts +2 -2
  89. package/dist/js/modules/address/host.d.ts.map +1 -1
  90. package/dist/js/modules/address/loqate.d.ts.map +1 -1
  91. package/dist/js/modules/fields/address-country.d.ts +3 -0
  92. package/dist/js/modules/fields/address-country.d.ts.map +1 -0
  93. package/dist/js/modules/fields/address-state.d.ts +3 -0
  94. package/dist/js/modules/fields/address-state.d.ts.map +1 -0
  95. package/dist/js/modules/fields/calculations.d.ts.map +1 -1
  96. package/dist/js/modules/fields/combobox.d.ts +17 -0
  97. package/dist/js/modules/fields/combobox.d.ts.map +1 -0
  98. package/dist/js/modules/fields/custom-google-maps.d.ts +64 -0
  99. package/dist/js/modules/fields/custom-google-maps.d.ts.map +1 -0
  100. package/dist/js/modules/fields/custom-link.d.ts +3 -0
  101. package/dist/js/modules/fields/custom-link.d.ts.map +1 -0
  102. package/dist/js/modules/fields/custom-maps.d.ts +3 -0
  103. package/dist/js/modules/fields/custom-maps.d.ts.map +1 -0
  104. package/dist/js/modules/fields/date-picker.d.ts.map +1 -1
  105. package/dist/js/modules/fields/index.d.ts.map +1 -1
  106. package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
  107. package/dist/js/modules/fields/signature.d.ts.map +1 -1
  108. package/dist/js/modules/fields/upload-manager.d.ts +3 -0
  109. package/dist/js/modules/fields/upload-manager.d.ts.map +1 -0
  110. package/dist/js/submit/pipeline.d.ts.map +1 -1
  111. package/dist/js/utils/country-from-ip.d.ts +7 -0
  112. package/dist/js/utils/country-from-ip.d.ts.map +1 -0
  113. package/dist/js/utils/field-references.d.ts +1 -0
  114. package/dist/js/utils/field-references.d.ts.map +1 -1
  115. package/dist/js/utils/field-references.row-scope.d.ts +11 -0
  116. package/dist/js/utils/field-references.row-scope.d.ts.map +1 -0
  117. package/package.json +8 -3
  118. package/src/css/formie-theme.css +2 -0
  119. package/src/css/theme/_buttons.css +16 -0
  120. package/src/css/theme/_tokens.css +2 -0
  121. package/src/css/theme/fields/_address-state.css +94 -0
  122. package/src/css/theme/fields/_combobox.css +246 -0
  123. package/src/css/theme/fields/_select.css +13 -0
  124. package/src/css/theme/fields/_signature.css +19 -0
  125. package/src/css/theme/fields/_upload-manager.css +279 -0
  126. package/src/js/core/create-formie-client.ts +27 -0
  127. package/src/js/core/submit-result-ui.ts +4 -4
  128. package/src/js/core/validation-focus.ts +117 -0
  129. package/src/js/modules/address/api.ts +7 -0
  130. package/src/js/modules/address/constants.ts +82 -2
  131. package/src/js/modules/address/host.ts +16 -6
  132. package/src/js/modules/address/loqate.ts +3 -17
  133. package/src/js/modules/fields/address-country.ts +156 -0
  134. package/src/js/modules/fields/address-state.ts +946 -0
  135. package/src/js/modules/fields/calculations.ts +32 -0
  136. package/src/js/modules/fields/combobox.ts +188 -0
  137. package/src/js/modules/fields/custom-google-maps.ts +416 -0
  138. package/src/js/modules/fields/custom-link.ts +95 -0
  139. package/src/js/modules/fields/custom-maps.ts +339 -0
  140. package/src/js/modules/fields/date-picker.ts +96 -3
  141. package/src/js/modules/fields/index.ts +7 -0
  142. package/src/js/modules/fields/phone-country.ts +6 -0
  143. package/src/js/modules/fields/signature.ts +199 -15
  144. package/src/js/modules/fields/upload-manager.ts +1277 -0
  145. package/src/js/submit/pipeline.ts +11 -1
  146. package/src/js/utils/country-from-ip.ts +54 -0
  147. package/src/js/utils/field-references.row-scope.ts +303 -0
  148. package/src/js/utils/field-references.ts +1 -0
  149. package/src/vendor.d.ts +1 -1
  150. package/dist/chunks/address-finder-BU4roxT6.js +0 -37
  151. package/dist/chunks/bpoint-uNg5j6rt.js +0 -26
  152. package/dist/chunks/calculations-C9t6Zop9.js +0 -147
  153. package/dist/chunks/captcha-eu-BAPzHNts.js +0 -34
  154. package/dist/chunks/checkbox-radio-CHeLNVIK.js +0 -141
  155. package/dist/chunks/conditions-DKxVCsjD.js +0 -459
  156. package/dist/chunks/date-picker-B3iQ797Z.js +0 -4994
  157. package/dist/chunks/eway-DCielIML.js +0 -74
  158. package/dist/chunks/file-upload-DP2TOUYV.js +0 -316
  159. package/dist/chunks/friendly-captcha-v1-DdlcGiFY.js +0 -33
  160. package/dist/chunks/friendly-captcha-v2-76w-YnFm.js +0 -38
  161. package/dist/chunks/go-cardless-rwCkzbNK.js +0 -24
  162. package/dist/chunks/google-address-BD8IZ86V.js +0 -181
  163. package/dist/chunks/hcaptcha-BNNTL6uf.js +0 -56
  164. package/dist/chunks/hidden-CW6Rcf1N.js +0 -31
  165. package/dist/chunks/index-BEsfee-D.js +0 -1839
  166. package/dist/chunks/index-BP7Unm5Y.js +0 -863
  167. package/dist/chunks/index-D6npYxvr.js +0 -4423
  168. package/dist/chunks/loqate-B-ZNS65B.js +0 -55
  169. package/dist/chunks/mollie-BDAZ-1X4.js +0 -24
  170. package/dist/chunks/moneris-B1b0uEtY.js +0 -101
  171. package/dist/chunks/opayo-CC5hraHo.js +0 -122
  172. package/dist/chunks/paddle-B4GmrDsq.js +0 -60
  173. package/dist/chunks/paypal-BFS6XsIS.js +0 -100
  174. package/dist/chunks/payway-DekoJead.js +0 -56
  175. package/dist/chunks/phone-country-Cm5YUeXI.js +0 -3765
  176. package/dist/chunks/place-kit-DizZWV_e.js +0 -42
  177. package/dist/chunks/placekit-autocomplete.esm-LAwfbPZl.js +0 -1299
  178. package/dist/chunks/recaptcha-enterprise-BbOvO7JZ.js +0 -71
  179. package/dist/chunks/recaptcha-shared-Dwg1eVFS.js +0 -15
  180. package/dist/chunks/recaptcha-v2-checkbox-SY3ti5iQ.js +0 -41
  181. package/dist/chunks/recaptcha-v2-invisible-DSmQnn6c.js +0 -59
  182. package/dist/chunks/recaptcha-v3-DNh7YCnR.js +0 -28
  183. package/dist/chunks/repeater-DM0vuFNQ.js +0 -124
  184. package/dist/chunks/rich-text-C8aGYp70.js +0 -304
  185. package/dist/chunks/scripts-CKTFtxPO.js +0 -36
  186. package/dist/chunks/sdk-3SicTWby.js +0 -1559
  187. package/dist/chunks/shared--BU5CFoL.js +0 -80
  188. package/dist/chunks/signature-pFhZfH7d.js +0 -550
  189. package/dist/chunks/snaptcha-VuOUOaLj.js +0 -8
  190. package/dist/chunks/square-BR9pvToy.js +0 -46
  191. package/dist/chunks/stripe-OHUwoV9g.js +0 -202
  192. package/dist/chunks/styles-C3aqgtek.js +0 -21
  193. package/dist/chunks/summary-Da7Ktg3N.js +0 -126
  194. package/dist/chunks/table-C0PmR9bI.js +0 -95
  195. package/dist/chunks/text-limit-mLEwZDIa.js +0 -144
  196. package/dist/chunks/turnstile-9tM7q_LK.js +0 -59
  197. 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
+ };