@verbb/formie-browser 1.0.7 → 1.0.8

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/chunks/_survey-presentations-CFsgToRT.js +4 -0
  3. package/dist/chunks/{api-DztsCJ4r.js → api-C8wyLC-s.js} +184 -150
  4. package/dist/chunks/{bpoint-2qDxlKD9.js → bpoint-CwSw_p7A.js} +1 -1
  5. package/dist/chunks/{eway-CImXrB4g.js → eway-B30BMlDp.js} +1 -1
  6. package/dist/chunks/{go-cardless-Bz7HHtH0.js → go-cardless-BiF3iLel.js} +1 -1
  7. package/dist/chunks/{mollie-BtnB3KGh.js → mollie-D__SwtcH.js} +1 -1
  8. package/dist/chunks/{moneris-Bd4vUk8b.js → moneris-KJS1BKx7.js} +1 -1
  9. package/dist/chunks/{opayo-B-eRIXo-.js → opayo-A_LIkc-v.js} +1 -1
  10. package/dist/chunks/{paddle-BUFeEU4d.js → paddle-BjbOOb0m.js} +1 -1
  11. package/dist/chunks/{paypal-8Euw7Hrn.js → paypal-CyjHIV8v.js} +1 -1
  12. package/dist/chunks/{payway-dV0SzJxy.js → payway-a_cyrr3j.js} +1 -1
  13. package/dist/chunks/{square-reyJukb7.js → square-Bga6WXHV.js} +1 -1
  14. package/dist/chunks/{stripe-BC-2PBvY.js → stripe-BlqVocl3.js} +1 -1
  15. package/dist/chunks/{summary-D3AjxpYN.js → summary-Dks0gXS3.js} +42 -38
  16. package/dist/chunks/survey-likert-DmJ3klhP.js +20 -0
  17. package/dist/chunks/survey-rank-DeUenftw.js +127 -0
  18. package/dist/chunks/survey-rating-BPUYDgBJ.js +88 -0
  19. package/dist/css/formie-theme.css +1 -0
  20. package/dist/css/theme/fields/_survey-presentations.css +396 -0
  21. package/dist/index.js +469 -465
  22. package/dist/js/contracts/schema.d.ts +5 -0
  23. package/dist/js/contracts/schema.d.ts.map +1 -1
  24. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  25. package/dist/js/core/page-client-event.d.ts +8 -4
  26. package/dist/js/core/page-client-event.d.ts.map +1 -1
  27. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  28. package/dist/js/modules/fields/index.d.ts.map +1 -1
  29. package/dist/js/modules/fields/summary.d.ts.map +1 -1
  30. package/dist/js/modules/fields/survey-likert.d.ts +3 -0
  31. package/dist/js/modules/fields/survey-likert.d.ts.map +1 -0
  32. package/dist/js/modules/fields/survey-rank.d.ts +3 -0
  33. package/dist/js/modules/fields/survey-rank.d.ts.map +1 -0
  34. package/dist/js/modules/fields/survey-rating.d.ts +3 -0
  35. package/dist/js/modules/fields/survey-rating.d.ts.map +1 -0
  36. package/dist/js/transport/forms-api.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/css/formie-theme.css +1 -0
  39. package/src/css/theme/fields/_survey-presentations.css +396 -0
  40. package/src/js/contracts/schema.ts +6 -0
  41. package/src/js/core/create-formie-client.ts +14 -1
  42. package/src/js/core/page-client-event.ts +86 -18
  43. package/src/js/core/submit-result-state.ts +6 -2
  44. package/src/js/modules/fields/index.ts +3 -0
  45. package/src/js/modules/fields/summary.ts +17 -3
  46. package/src/js/modules/fields/survey-likert.ts +50 -0
  47. package/src/js/modules/fields/survey-rank.ts +385 -0
  48. package/src/js/modules/fields/survey-rating.ts +182 -0
  49. package/src/js/transport/forms-api.ts +1 -0
@@ -3,11 +3,17 @@ import { createDebug } from '#utils/debug';
3
3
  const debug = createDebug('general', 'page-client-event');
4
4
 
5
5
  const CLIENT_EVENT_ATTR = 'data-formie-client-event';
6
+ const PENDING_CLIENT_EVENTS_ATTR = 'data-formie-pending-client-events';
6
7
 
7
8
  type ClientEventPayload = {
8
9
  fields?: Array<{ label?: string; value?: string }>;
9
10
  };
10
11
 
12
+ export type ResolvedClientEvent = {
13
+ event: string;
14
+ payload: Record<string, string>;
15
+ };
16
+
11
17
  function escapePageIdForSelector(pageId: string): string {
12
18
  if (typeof window !== 'undefined' && window.CSS?.escape) {
13
19
  return window.CSS.escape(pageId);
@@ -70,11 +76,83 @@ function buildPayloadObject(fields: Array<{ label?: string; value?: string }>):
70
76
  return out;
71
77
  }
72
78
 
79
+ function normalizeResolvedClientEvents(events: unknown): ResolvedClientEvent[] {
80
+ if (!Array.isArray(events)) {
81
+ return [];
82
+ }
83
+
84
+ return events
85
+ .map((item) => {
86
+ if (!item || typeof item !== 'object') {
87
+ return null;
88
+ }
89
+
90
+ const record = item as ResolvedClientEvent;
91
+ const eventName = typeof record.event === 'string' ? record.event.trim() : '';
92
+ const payload = record.payload && typeof record.payload === 'object' ? record.payload : null;
93
+
94
+ if (!eventName || !payload) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ event: eventName,
100
+ payload,
101
+ };
102
+ })
103
+ .filter((item): item is ResolvedClientEvent => item !== null);
104
+ }
105
+
106
+ export function dispatchResolvedClientEvents(form: HTMLFormElement, events: ResolvedClientEvent[]): void {
107
+ if (!events.length) {
108
+ return;
109
+ }
110
+
111
+ const win = window as Window & { dataLayer?: unknown[] };
112
+ win.dataLayer = win.dataLayer || [];
113
+
114
+ events.forEach((item) => {
115
+ win.dataLayer!.push(item.payload);
116
+
117
+ form.dispatchEvent(new CustomEvent('formie:client-event', {
118
+ bubbles: true,
119
+ detail: {
120
+ event: item.event,
121
+ payload: item.payload,
122
+ },
123
+ }));
124
+ });
125
+
126
+ debug.log('Dispatched resolved client events.', {
127
+ count: events.length,
128
+ events: events.map((item) => item.event),
129
+ });
130
+ }
131
+
132
+ export function dispatchPendingClientEventsFromForm(form: HTMLFormElement): void {
133
+ const rawAttr = form.getAttribute(PENDING_CLIENT_EVENTS_ATTR);
134
+
135
+ if (!rawAttr?.trim()) {
136
+ return;
137
+ }
138
+
139
+ try {
140
+ const parsed = JSON.parse(rawAttr) as unknown;
141
+ const events = normalizeResolvedClientEvents(parsed);
142
+
143
+ if (events.length) {
144
+ dispatchResolvedClientEvents(form, events);
145
+ }
146
+ } catch {
147
+ debug.warn('Invalid pending client events JSON on form element.');
148
+ } finally {
149
+ form.removeAttribute(PENDING_CLIENT_EVENTS_ATTR);
150
+ }
151
+ }
152
+
73
153
  /**
74
- * When the builder enables JavaScript events for a page, the theme emits
75
- * `data-formie-client-event` on that page's section. On each successful
76
- * **submit** (not back/save), push the configured key/value object to
77
- * `window.dataLayer` (when present) and dispatch `formie:client-event`.
154
+ * Legacy static page attribute dispatch. Prefer server-resolved `clientEvents`
155
+ * from the submit response when available.
78
156
  */
79
157
  export function dispatchPageClientEventForSubmit(form: HTMLFormElement, action: string): void {
80
158
  if (action !== 'submit') {
@@ -112,18 +190,8 @@ export function dispatchPageClientEventForSubmit(form: HTMLFormElement, action:
112
190
  }
113
191
 
114
192
  const payload = buildPayloadObject(config.fields);
115
- const win = window as Window & { dataLayer?: unknown[] };
116
-
117
- win.dataLayer = win.dataLayer || [];
118
- win.dataLayer.push(payload);
119
-
120
- form.dispatchEvent(new CustomEvent('formie:client-event', {
121
- bubbles: true,
122
- detail: { payload },
123
- }));
124
-
125
- debug.log('Dispatched page client event.', {
126
- pageId,
127
- keys: Object.keys(payload),
128
- });
193
+ dispatchResolvedClientEvents(form, [{
194
+ event: typeof payload.event === 'string' && payload.event !== '' ? payload.event : 'formPageSubmission',
195
+ payload,
196
+ }]);
129
197
  }
@@ -1,7 +1,7 @@
1
1
  import type { FormAction } from '#contracts/common';
2
2
  import type { FormSubmitResult } from '#contracts/schema';
3
3
  import { dispatchFormieDomEvent } from '#core/dom-events';
4
- import { dispatchPageClientEventForSubmit } from '#core/page-client-event';
4
+ import { dispatchPageClientEventForSubmit, dispatchResolvedClientEvents } from '#core/page-client-event';
5
5
  import { syncPageTabErrors } from '#core/page-tab-errors';
6
6
  import { addThemeClasses, removeThemeClasses, toggleThemeClasses } from '#theme/theme-classes';
7
7
  import { createDebug } from '#utils/debug';
@@ -568,7 +568,11 @@ export function applySubmitResultState(form: HTMLFormElement, result: FormSubmit
568
568
  return;
569
569
  }
570
570
 
571
- dispatchPageClientEventForSubmit(form, action);
571
+ if (Array.isArray(result.clientEvents) && result.clientEvents.length > 0) {
572
+ dispatchResolvedClientEvents(form, result.clientEvents);
573
+ } else {
574
+ dispatchPageClientEventForSubmit(form, action);
575
+ }
572
576
 
573
577
  if (result.nextPage?.id) {
574
578
  // Advancing pages is treated as a fresh validation cycle for the next page.
@@ -21,6 +21,9 @@ export const builtinFieldModuleLoaders: Record<string, () => Promise<FormieModul
21
21
  'rich-text': () => import('#modules/fields/rich-text').then((module) => module.richTextModule),
22
22
  'signature': () => import('#modules/fields/signature').then((module) => module.signatureModule),
23
23
  'summary': () => import('#modules/fields/summary').then((module) => module.summaryModule),
24
+ 'survey-likert': () => import('#modules/fields/survey-likert').then((module) => module.surveyLikertModule),
25
+ 'survey-rank': () => import('#modules/fields/survey-rank').then((module) => module.surveyRankModule),
26
+ 'survey-rating': () => import('#modules/fields/survey-rating').then((module) => module.surveyRatingModule),
24
27
  'table': () => import('#modules/fields/table').then((module) => module.tableModule),
25
28
  'text-limit': () => import('#modules/fields/text-limit').then((module) => module.textLimitModule),
26
29
  };
@@ -11,6 +11,8 @@ import { requestText } from '#utils/http';
11
11
  const BLOCKS_SELECTOR = '[data-formie-summary-blocks]';
12
12
  const CONTAINER_SELECTOR = '[data-formie-summary-container]';
13
13
  const SUMMARY_ACTION = 'formie/fields/get-summary-html';
14
+ const THEME_CONFIG_ATTR = 'data-formie-theme-config';
15
+ const FRONTEND_THEME_ATTR = 'data-formie-frontend-theme';
14
16
  const MODULE_ID = 'summary';
15
17
  const debug = createDebug('fields', 'summary');
16
18
 
@@ -18,6 +20,8 @@ ensureModuleStyles(MODULE_ID, [summaryCss]);
18
20
 
19
21
  type SummaryRequestState = {
20
22
  accessToken: string | null;
23
+ themeConfig: string | null;
24
+ frontendTheme: string | null;
21
25
  };
22
26
 
23
27
  function getSummaryRequestUrl(): string {
@@ -27,12 +31,14 @@ function getSummaryRequestUrl(): string {
27
31
  return url.toString();
28
32
  }
29
33
 
30
- function getSummaryRequestState(field: HTMLElement): SummaryRequestState {
34
+ function getSummaryRequestState(field: HTMLElement, form: HTMLFormElement): SummaryRequestState {
31
35
  const summaryTokenInput = field.querySelector('[data-formie-summary-token]') as HTMLInputElement | null;
32
36
  const accessToken = summaryTokenInput?.value?.trim() || null;
33
37
 
34
38
  return {
35
39
  accessToken,
40
+ themeConfig: form.getAttribute(THEME_CONFIG_ATTR)?.trim() || null,
41
+ frontendTheme: form.getAttribute(FRONTEND_THEME_ATTR)?.trim() || null,
36
42
  };
37
43
  }
38
44
 
@@ -47,6 +53,14 @@ async function requestSummaryHtml(form: HTMLFormElement, state: SummaryRequestSt
47
53
  formData.set('action', SUMMARY_ACTION);
48
54
  formData.set('accessToken', state.accessToken);
49
55
 
56
+ if (state.themeConfig) {
57
+ formData.set('themeConfig', state.themeConfig);
58
+ }
59
+
60
+ if (state.frontendTheme) {
61
+ formData.set('frontendTheme', state.frontendTheme);
62
+ }
63
+
50
64
  return requestText(getSummaryRequestUrl(), {
51
65
  method: 'POST',
52
66
  body: formData,
@@ -101,7 +115,7 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
101
115
  toggleThemeClasses(blocks, form, 'loading', false);
102
116
  };
103
117
 
104
- const initialState = getSummaryRequestState(field);
118
+ const initialState = getSummaryRequestState(field, form);
105
119
  setLoadingState(!!initialState.accessToken);
106
120
 
107
121
  const queueFetch = (): void => {
@@ -114,7 +128,7 @@ function initSummaryField(field: HTMLElement, root: Element): () => void {
114
128
  };
115
129
 
116
130
  const fetchSummary = debounce(async() => {
117
- const state = getSummaryRequestState(field);
131
+ const state = getSummaryRequestState(field, form);
118
132
 
119
133
  if (!getBlocks() || !state.accessToken) {
120
134
  debug.warn('Missing state for fetch.', state);
@@ -0,0 +1,50 @@
1
+ import surveyPresentationsCss from '#theme-css/fields/_survey-presentations.css?inline';
2
+
3
+ import type { FormieModuleDefinition } from '#contracts/modules';
4
+ import { ensureModuleStyles } from '#modules/styles';
5
+ import { createDebug } from '#utils/debug';
6
+
7
+ const FIELD_SELECTOR = '[data-formie-likert-field-layout]';
8
+ const MODULE_ID = 'survey-likert';
9
+ const debug = createDebug('fields', 'survey-likert');
10
+
11
+ ensureModuleStyles(MODULE_ID, [surveyPresentationsCss]);
12
+
13
+ export const surveyLikertModule: FormieModuleDefinition = {
14
+ id: MODULE_ID,
15
+ kind: 'field',
16
+ match: (ctx) => {
17
+ return ctx.target instanceof HTMLElement && (
18
+ ctx.target.matches(FIELD_SELECTOR) ||
19
+ !!ctx.target.querySelector(FIELD_SELECTOR)
20
+ );
21
+ },
22
+ setup: async(ctx) => {
23
+ if (!(ctx.target instanceof HTMLElement)) {
24
+ return;
25
+ }
26
+
27
+ const fields = ctx.target.matches(FIELD_SELECTOR)
28
+ ? [ctx.target]
29
+ : Array.from(ctx.target.querySelectorAll(FIELD_SELECTOR)).filter((field): field is HTMLElement => {
30
+ return field instanceof HTMLElement;
31
+ });
32
+
33
+ debug.log('Module setup.', {
34
+ fieldCount: fields.length,
35
+ });
36
+
37
+ await ctx.emit('formie:module:survey-likert:init', {
38
+ count: fields.length,
39
+ });
40
+
41
+ return {
42
+ destroy: () => {
43
+ debug.log('Module destroy.', {
44
+ fieldCount: fields.length,
45
+ });
46
+ void ctx.emit('formie:module:survey-likert:destroy', {});
47
+ },
48
+ };
49
+ },
50
+ };
@@ -0,0 +1,385 @@
1
+ import surveyPresentationsCss from '#theme-css/fields/_survey-presentations.css?inline';
2
+
3
+ import type { FormieModuleDefinition } from '#contracts/modules';
4
+ import { dispatchFieldEvent } from '#modules/fields/shared';
5
+ import { ensureModuleStyles } from '#modules/styles';
6
+ import { createDebug } from '#utils/debug';
7
+
8
+ const FIELD_SELECTOR = '[data-formie-survey-rank]';
9
+ const LIST_SELECTOR = '[data-formie-survey-rank-list]';
10
+ const ITEM_SELECTOR = '[data-formie-survey-rank-item]';
11
+ const HANDLE_SELECTOR = '[data-formie-rank-handle]';
12
+ const PLACEHOLDER_CLASS = 'formie-rank-placeholder';
13
+ /** Insert before a sibling once the ghost centre crosses this fraction of its height (below 0.5 swaps earlier). */
14
+ const PLACEHOLDER_SWAP_RATIO = 0.42;
15
+ const MODULE_ID = 'survey-rank';
16
+ const debug = createDebug('fields', 'survey-rank');
17
+
18
+ ensureModuleStyles(MODULE_ID, [surveyPresentationsCss]);
19
+
20
+ type PointerOffset = {
21
+ x: number;
22
+ y: number;
23
+ };
24
+
25
+ type GhostStyleSnapshot = {
26
+ position: string;
27
+ left: string;
28
+ top: string;
29
+ width: string;
30
+ zIndex: string;
31
+ pointerEvents: string;
32
+ margin: string;
33
+ };
34
+
35
+ function syncRankInputs(list: HTMLElement): void {
36
+ list.querySelectorAll(ITEM_SELECTOR).forEach((item, index) => {
37
+ if (!(item instanceof HTMLElement)) {
38
+ return;
39
+ }
40
+
41
+ item.querySelectorAll('input[data-formie-rank-input]').forEach((input) => {
42
+ if (input instanceof HTMLInputElement) {
43
+ input.dataset.formieRankOrder = String(index);
44
+ }
45
+ });
46
+ });
47
+ }
48
+
49
+ function getRankItems(list: HTMLElement): HTMLElement[] {
50
+ return Array.from(list.querySelectorAll(ITEM_SELECTOR)).filter((item): item is HTMLElement => {
51
+ return item instanceof HTMLElement;
52
+ });
53
+ }
54
+
55
+ function getPlaceholderIndex(list: HTMLElement, placeholder: HTMLElement): number {
56
+ return Array.from(list.children).indexOf(placeholder);
57
+ }
58
+
59
+ function getRankListSiblings(
60
+ list: HTMLElement,
61
+ placeholder: HTMLElement,
62
+ draggedItem: HTMLElement,
63
+ ): HTMLElement[] {
64
+ return Array.from(list.children).filter((child): child is HTMLElement => {
65
+ return child instanceof HTMLElement
66
+ && child !== placeholder
67
+ && child !== draggedItem;
68
+ });
69
+ }
70
+
71
+ function getVerticalOverlapHeight(ghostRect: DOMRect, targetRect: DOMRect): number {
72
+ return Math.max(0, Math.min(ghostRect.bottom, targetRect.bottom) - Math.max(ghostRect.top, targetRect.top));
73
+ }
74
+
75
+ function getGhostReferenceY(draggedItem: HTMLElement): number {
76
+ const rect = draggedItem.getBoundingClientRect();
77
+
78
+ return rect.top + (rect.height / 2);
79
+ }
80
+
81
+ function getPlaceholderTargetIndex(
82
+ draggedItem: HTMLElement,
83
+ list: HTMLElement,
84
+ placeholder: HTMLElement,
85
+ draggingDown: boolean,
86
+ ): number {
87
+ const ghostRect = draggedItem.getBoundingClientRect();
88
+ const referenceY = getGhostReferenceY(draggedItem);
89
+ const siblings = getRankListSiblings(list, placeholder, draggedItem);
90
+
91
+ for (let index = 0; index < siblings.length; index += 1) {
92
+ const rect = siblings[index].getBoundingClientRect();
93
+ const swapLine = rect.top + (rect.height * PLACEHOLDER_SWAP_RATIO);
94
+ const overlapRatio = getVerticalOverlapHeight(ghostRect, rect) / rect.height;
95
+
96
+ if (overlapRatio >= PLACEHOLDER_SWAP_RATIO) {
97
+ if (draggingDown) {
98
+ return Math.min(index + 1, siblings.length);
99
+ }
100
+
101
+ return index;
102
+ }
103
+
104
+ if (referenceY < swapLine) {
105
+ return index;
106
+ }
107
+ }
108
+
109
+ return siblings.length;
110
+ }
111
+
112
+ function movePlaceholderToIndex(
113
+ list: HTMLElement,
114
+ placeholder: HTMLElement,
115
+ draggedItem: HTMLElement,
116
+ targetIndex: number,
117
+ ): void {
118
+ const siblings = getRankListSiblings(list, placeholder, draggedItem);
119
+ const reference = siblings[targetIndex] ?? null;
120
+
121
+ if (reference) {
122
+ list.insertBefore(placeholder, reference);
123
+ return;
124
+ }
125
+
126
+ list.appendChild(placeholder);
127
+ }
128
+
129
+ function createPlaceholder(list: HTMLElement, item: HTMLElement): HTMLElement {
130
+ const placeholder = document.createElement('li');
131
+
132
+ placeholder.className = PLACEHOLDER_CLASS;
133
+ placeholder.setAttribute('data-formie-rank-placeholder', 'true');
134
+ placeholder.setAttribute('aria-hidden', 'true');
135
+ placeholder.style.height = `${item.offsetHeight}px`;
136
+ list.insertBefore(placeholder, item);
137
+
138
+ return placeholder;
139
+ }
140
+
141
+ function captureGhostStyles(item: HTMLElement): GhostStyleSnapshot {
142
+ return {
143
+ position: item.style.position,
144
+ left: item.style.left,
145
+ top: item.style.top,
146
+ width: item.style.width,
147
+ zIndex: item.style.zIndex,
148
+ pointerEvents: item.style.pointerEvents,
149
+ margin: item.style.margin,
150
+ };
151
+ }
152
+
153
+ function applyGhostStyles(item: HTMLElement, rect: DOMRect): void {
154
+ item.style.position = 'fixed';
155
+ item.style.left = `${rect.left}px`;
156
+ item.style.top = `${rect.top}px`;
157
+ item.style.width = `${rect.width}px`;
158
+ item.style.zIndex = '1000';
159
+ item.style.pointerEvents = 'none';
160
+ item.style.margin = '0';
161
+ }
162
+
163
+ function updateGhostPosition(item: HTMLElement, event: PointerEvent, offset: PointerOffset): void {
164
+ item.style.left = `${event.clientX - offset.x}px`;
165
+ item.style.top = `${event.clientY - offset.y}px`;
166
+ }
167
+
168
+ function restoreGhostStyles(item: HTMLElement, snapshot: GhostStyleSnapshot): void {
169
+ item.style.position = snapshot.position;
170
+ item.style.left = snapshot.left;
171
+ item.style.top = snapshot.top;
172
+ item.style.width = snapshot.width;
173
+ item.style.zIndex = snapshot.zIndex;
174
+ item.style.pointerEvents = snapshot.pointerEvents;
175
+ item.style.margin = snapshot.margin;
176
+ }
177
+
178
+ function bindRankField(field: HTMLElement): () => void {
179
+ const list = field.querySelector(LIST_SELECTOR);
180
+
181
+ if (!(list instanceof HTMLElement)) {
182
+ debug.warn('Missing rank list; skipping field.');
183
+ return () => {};
184
+ }
185
+
186
+ let draggedItem: HTMLElement | null = null;
187
+ let placeholder: HTMLElement | null = null;
188
+ let ghostStyleSnapshot: GhostStyleSnapshot | null = null;
189
+ let pointerOffset: PointerOffset | null = null;
190
+ let activeCaptureTarget: HTMLElement | null = null;
191
+ let activePointerId: number | null = null;
192
+ let lastTargetIndex: number | null = null;
193
+ let startPlaceholderIndex: number | null = null;
194
+ let lastGhostReferenceY: number | null = null;
195
+ let didReorder = false;
196
+ const cleanups: Array<() => void> = [];
197
+
198
+ const finishDrag = () => {
199
+ if (draggedItem && placeholder) {
200
+ list.insertBefore(draggedItem, placeholder);
201
+ placeholder.remove();
202
+ } else if (placeholder) {
203
+ placeholder.remove();
204
+ }
205
+
206
+ if (draggedItem && ghostStyleSnapshot) {
207
+ restoreGhostStyles(draggedItem, ghostStyleSnapshot);
208
+ draggedItem.removeAttribute('data-formie-rank-dragging');
209
+ }
210
+
211
+ if (activeCaptureTarget && activePointerId !== null) {
212
+ try {
213
+ activeCaptureTarget.releasePointerCapture(activePointerId);
214
+ } catch {
215
+ // Pointer capture may already be released.
216
+ }
217
+ }
218
+
219
+ draggedItem = null;
220
+ placeholder = null;
221
+ ghostStyleSnapshot = null;
222
+ pointerOffset = null;
223
+ activeCaptureTarget = null;
224
+ activePointerId = null;
225
+ lastTargetIndex = null;
226
+ startPlaceholderIndex = null;
227
+ lastGhostReferenceY = null;
228
+ list.removeAttribute('data-formie-rank-sorting');
229
+
230
+ if (didReorder) {
231
+ syncRankInputs(list);
232
+ dispatchFieldEvent(field, MODULE_ID, 'reorder', {
233
+ rankField: field,
234
+ });
235
+ }
236
+
237
+ didReorder = false;
238
+ };
239
+
240
+ const onPointerMove = (event: PointerEvent) => {
241
+ if (!draggedItem || !placeholder || !pointerOffset || event.pointerId !== activePointerId) {
242
+ return;
243
+ }
244
+
245
+ event.preventDefault();
246
+
247
+ updateGhostPosition(draggedItem, event, pointerOffset);
248
+
249
+ const referenceY = getGhostReferenceY(draggedItem);
250
+ const draggingDown = lastGhostReferenceY === null || referenceY >= lastGhostReferenceY;
251
+
252
+ lastGhostReferenceY = referenceY;
253
+
254
+ const targetIndex = getPlaceholderTargetIndex(draggedItem, list, placeholder, draggingDown);
255
+
256
+ if (targetIndex === lastTargetIndex) {
257
+ return;
258
+ }
259
+
260
+ lastTargetIndex = targetIndex;
261
+ movePlaceholderToIndex(list, placeholder, draggedItem, targetIndex);
262
+ };
263
+
264
+ const onPointerUp = (event: PointerEvent) => {
265
+ if (event.pointerId !== activePointerId) {
266
+ return;
267
+ }
268
+
269
+ document.removeEventListener('pointermove', onPointerMove);
270
+ document.removeEventListener('pointerup', onPointerUp);
271
+ document.removeEventListener('pointercancel', onPointerUp);
272
+
273
+ if (placeholder && startPlaceholderIndex !== null) {
274
+ didReorder = getPlaceholderIndex(list, placeholder) !== startPlaceholderIndex;
275
+ }
276
+
277
+ finishDrag();
278
+ };
279
+
280
+ getRankItems(list).forEach((item) => {
281
+ if (!item.querySelector(HANDLE_SELECTOR)) {
282
+ return;
283
+ }
284
+
285
+ const onPointerDown = (event: PointerEvent) => {
286
+ if (event.button !== 0) {
287
+ return;
288
+ }
289
+
290
+ if (event.target instanceof HTMLInputElement) {
291
+ return;
292
+ }
293
+
294
+ event.preventDefault();
295
+
296
+ const rect = item.getBoundingClientRect();
297
+
298
+ draggedItem = item;
299
+ activeCaptureTarget = item;
300
+ activePointerId = event.pointerId;
301
+ pointerOffset = {
302
+ x: event.clientX - rect.left,
303
+ y: event.clientY - rect.top,
304
+ };
305
+ ghostStyleSnapshot = captureGhostStyles(item);
306
+ placeholder = createPlaceholder(list, item);
307
+ startPlaceholderIndex = getPlaceholderIndex(list, placeholder);
308
+ didReorder = false;
309
+
310
+ applyGhostStyles(item, rect);
311
+ item.setAttribute('data-formie-rank-dragging', 'true');
312
+ list.setAttribute('data-formie-rank-sorting', 'true');
313
+ item.setPointerCapture(event.pointerId);
314
+ lastGhostReferenceY = getGhostReferenceY(item);
315
+ lastTargetIndex = getPlaceholderTargetIndex(item, list, placeholder, true);
316
+
317
+ document.addEventListener('pointermove', onPointerMove);
318
+ document.addEventListener('pointerup', onPointerUp);
319
+ document.addEventListener('pointercancel', onPointerUp);
320
+ };
321
+
322
+ item.addEventListener('pointerdown', onPointerDown);
323
+ cleanups.push(() => {
324
+ item.removeEventListener('pointerdown', onPointerDown);
325
+ });
326
+ });
327
+
328
+ syncRankInputs(list);
329
+
330
+ return () => {
331
+ document.removeEventListener('pointermove', onPointerMove);
332
+ document.removeEventListener('pointerup', onPointerUp);
333
+ document.removeEventListener('pointercancel', onPointerUp);
334
+ cleanups.forEach((cleanup) => {
335
+ cleanup();
336
+ });
337
+ finishDrag();
338
+ };
339
+ }
340
+
341
+ export const surveyRankModule: FormieModuleDefinition = {
342
+ id: MODULE_ID,
343
+ kind: 'field',
344
+ match: (ctx) => {
345
+ return ctx.target instanceof HTMLElement && (
346
+ ctx.target.matches(FIELD_SELECTOR) ||
347
+ !!ctx.target.querySelector(FIELD_SELECTOR)
348
+ );
349
+ },
350
+ setup: async(ctx) => {
351
+ if (!(ctx.target instanceof HTMLElement)) {
352
+ return;
353
+ }
354
+
355
+ const fields = ctx.target.matches(FIELD_SELECTOR)
356
+ ? [ctx.target]
357
+ : Array.from(ctx.target.querySelectorAll(FIELD_SELECTOR)).filter((field): field is HTMLElement => {
358
+ return field instanceof HTMLElement;
359
+ });
360
+
361
+ debug.log('Module setup.', {
362
+ fieldCount: fields.length,
363
+ });
364
+
365
+ const destroyBindings = fields.map((field) => {
366
+ return bindRankField(field);
367
+ });
368
+
369
+ await ctx.emit('formie:module:survey-rank:init', {
370
+ count: fields.length,
371
+ });
372
+
373
+ return {
374
+ destroy: () => {
375
+ destroyBindings.forEach((destroyBinding) => {
376
+ destroyBinding();
377
+ });
378
+ debug.log('Module destroy.', {
379
+ fieldCount: fields.length,
380
+ });
381
+ void ctx.emit('formie:module:survey-rank:destroy', {});
382
+ },
383
+ };
384
+ },
385
+ };