@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.
- package/CHANGELOG.md +12 -0
- package/dist/chunks/_survey-presentations-CFsgToRT.js +4 -0
- package/dist/chunks/{api-DztsCJ4r.js → api-C8wyLC-s.js} +184 -150
- package/dist/chunks/{bpoint-2qDxlKD9.js → bpoint-CwSw_p7A.js} +1 -1
- package/dist/chunks/{eway-CImXrB4g.js → eway-B30BMlDp.js} +1 -1
- package/dist/chunks/{go-cardless-Bz7HHtH0.js → go-cardless-BiF3iLel.js} +1 -1
- package/dist/chunks/{mollie-BtnB3KGh.js → mollie-D__SwtcH.js} +1 -1
- package/dist/chunks/{moneris-Bd4vUk8b.js → moneris-KJS1BKx7.js} +1 -1
- package/dist/chunks/{opayo-B-eRIXo-.js → opayo-A_LIkc-v.js} +1 -1
- package/dist/chunks/{paddle-BUFeEU4d.js → paddle-BjbOOb0m.js} +1 -1
- package/dist/chunks/{paypal-8Euw7Hrn.js → paypal-CyjHIV8v.js} +1 -1
- package/dist/chunks/{payway-dV0SzJxy.js → payway-a_cyrr3j.js} +1 -1
- package/dist/chunks/{square-reyJukb7.js → square-Bga6WXHV.js} +1 -1
- package/dist/chunks/{stripe-BC-2PBvY.js → stripe-BlqVocl3.js} +1 -1
- package/dist/chunks/{summary-D3AjxpYN.js → summary-Dks0gXS3.js} +42 -38
- package/dist/chunks/survey-likert-DmJ3klhP.js +20 -0
- package/dist/chunks/survey-rank-DeUenftw.js +127 -0
- package/dist/chunks/survey-rating-BPUYDgBJ.js +88 -0
- package/dist/css/formie-theme.css +1 -0
- package/dist/css/theme/fields/_survey-presentations.css +396 -0
- package/dist/index.js +469 -465
- package/dist/js/contracts/schema.d.ts +5 -0
- package/dist/js/contracts/schema.d.ts.map +1 -1
- package/dist/js/core/create-formie-client.d.ts.map +1 -1
- package/dist/js/core/page-client-event.d.ts +8 -4
- package/dist/js/core/page-client-event.d.ts.map +1 -1
- package/dist/js/core/submit-result-state.d.ts.map +1 -1
- package/dist/js/modules/fields/index.d.ts.map +1 -1
- package/dist/js/modules/fields/summary.d.ts.map +1 -1
- package/dist/js/modules/fields/survey-likert.d.ts +3 -0
- package/dist/js/modules/fields/survey-likert.d.ts.map +1 -0
- package/dist/js/modules/fields/survey-rank.d.ts +3 -0
- package/dist/js/modules/fields/survey-rank.d.ts.map +1 -0
- package/dist/js/modules/fields/survey-rating.d.ts +3 -0
- package/dist/js/modules/fields/survey-rating.d.ts.map +1 -0
- package/dist/js/transport/forms-api.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/css/formie-theme.css +1 -0
- package/src/css/theme/fields/_survey-presentations.css +396 -0
- package/src/js/contracts/schema.ts +6 -0
- package/src/js/core/create-formie-client.ts +14 -1
- package/src/js/core/page-client-event.ts +86 -18
- package/src/js/core/submit-result-state.ts +6 -2
- package/src/js/modules/fields/index.ts +3 -0
- package/src/js/modules/fields/summary.ts +17 -3
- package/src/js/modules/fields/survey-likert.ts +50 -0
- package/src/js/modules/fields/survey-rank.ts +385 -0
- package/src/js/modules/fields/survey-rating.ts +182 -0
- 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
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
+
};
|