@form-engine-ts/react 1.1.0 → 2.0.0

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/dist/index.js CHANGED
@@ -4,6 +4,384 @@ import {
4
4
  sanitizeSchema
5
5
  } from "@form-engine-ts/core";
6
6
  import { useState } from "react";
7
+
8
+ // src/hooks/useFormBuilder.ts
9
+ import {
10
+ validateFormSchema
11
+ } from "@form-engine-ts/core";
12
+ import { useCallback, useMemo } from "react";
13
+ var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
14
+ function defaultIdFactory(kind, existingIds) {
15
+ const prefix = DEFAULT_PREFIXES[kind];
16
+ let id;
17
+ do {
18
+ id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
19
+ } while (existingIds.has(id));
20
+ return id;
21
+ }
22
+ function newField(type, id, optionId) {
23
+ const base = { id, title: "New question", required: false };
24
+ if (type === "text" || type === "textarea") return { ...base, type };
25
+ if (type === "number") return { ...base, type };
26
+ if (type === "rating") return { ...base, type, min: 1, max: 5 };
27
+ if (type === "checkbox") return { ...base, type };
28
+ return optionId === void 0 ? void 0 : { ...base, type, options: [{ id: optionId, label: "Option 1" }] };
29
+ }
30
+ function move(items, sourceIndex, targetIndex) {
31
+ if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
32
+ return void 0;
33
+ const result = [...items];
34
+ const [item] = result.splice(sourceIndex, 1);
35
+ if (item === void 0) return void 0;
36
+ result.splice(targetIndex, 0, item);
37
+ return result;
38
+ }
39
+ function addPolicyIssues(schema, policy, issues) {
40
+ if (policy?.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
41
+ issues.push({
42
+ path: "fields",
43
+ code: "max_fields_exceeded",
44
+ message: `At most ${policy.maxFields} fields are allowed.`
45
+ });
46
+ }
47
+ schema.fields.forEach((field, index) => {
48
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
49
+ issues.push({
50
+ path: `fields[${index}].type`,
51
+ code: "disallowed_field_type",
52
+ message: `Field type ${field.type} is not allowed.`
53
+ });
54
+ }
55
+ if (policy?.maxTextLength !== void 0) {
56
+ for (const [property, text] of [
57
+ ["title", field.title],
58
+ ["description", field.description]
59
+ ]) {
60
+ if (text !== void 0 && text.length > policy.maxTextLength) {
61
+ issues.push({
62
+ path: `fields[${index}].${property}`,
63
+ code: "max_text_length_exceeded",
64
+ message: `Text must be at most ${policy.maxTextLength} characters.`
65
+ });
66
+ }
67
+ }
68
+ }
69
+ if (policy?.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
70
+ issues.push({
71
+ path: `fields[${index}].options`,
72
+ code: "max_options_exceeded",
73
+ message: `At most ${policy.maxOptionsPerField} options are allowed.`
74
+ });
75
+ }
76
+ });
77
+ for (const locale of policy?.requiredLocales ?? []) {
78
+ if (!(schema.supportedLocales ?? []).includes(locale)) {
79
+ issues.push({
80
+ path: "supportedLocales",
81
+ code: "required_locale_missing",
82
+ message: `Required locale ${locale} is missing.`
83
+ });
84
+ }
85
+ if (locale === schema.defaultLocale) continue;
86
+ const requiredTranslations = [
87
+ { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title },
88
+ ...schema.fields.map((field, index) => ({
89
+ path: `fields[${index}].translations.${locale}.title`,
90
+ value: field.translations?.[locale]?.title
91
+ })),
92
+ ...schema.fields.flatMap(
93
+ (field, fieldIndex) => "options" in field ? field.options.map((option, optionIndex) => ({
94
+ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
95
+ value: option.translations?.[locale]
96
+ })) : []
97
+ ),
98
+ ...schema.pages?.flatMap(
99
+ (page, pageIndex) => page.title === void 0 ? [] : [{ path: `pages[${pageIndex}].translations.${locale}.title`, value: page.translations?.[locale]?.title }]
100
+ ) ?? []
101
+ ];
102
+ for (const translation of requiredTranslations) {
103
+ if (translation.value === void 0 || translation.value.trim().length === 0) {
104
+ issues.push({
105
+ path: translation.path,
106
+ code: "required_translation_missing",
107
+ message: `A translation for required locale ${locale} is missing.`
108
+ });
109
+ }
110
+ }
111
+ }
112
+ }
113
+ function useFormBuilder({
114
+ schema,
115
+ onChange,
116
+ policy,
117
+ idFactory = defaultIdFactory
118
+ }) {
119
+ const createId = useCallback(
120
+ (kind, existingIds) => {
121
+ const id = idFactory(kind, existingIds).trim();
122
+ return id.length > 0 && !existingIds.has(id) ? id : void 0;
123
+ },
124
+ [idFactory]
125
+ );
126
+ const addField = useCallback(
127
+ (type, pageId) => {
128
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type)) {
129
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
130
+ }
131
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields) {
132
+ return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
133
+ }
134
+ const fieldId = createId("field", new Set(schema.fields.map((field2) => field2.id)));
135
+ const needsOption = ["select", "radio", "multi-select"].includes(type);
136
+ const optionId = needsOption ? createId(
137
+ "option",
138
+ new Set(
139
+ schema.fields.flatMap((field2) => "options" in field2 ? field2.options.map((option) => option.id) : [])
140
+ )
141
+ ) : void 0;
142
+ if (fieldId === void 0 || needsOption && optionId === void 0) {
143
+ return {
144
+ success: false,
145
+ error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
146
+ };
147
+ }
148
+ const field = newField(type, fieldId, optionId);
149
+ if (field === void 0) {
150
+ return { success: false, error: { type: "invalid_operation", message: "Could not create the field." } };
151
+ }
152
+ const pages = schema.pages?.map((page, index) => ({
153
+ ...page,
154
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, fieldId] : page.questionIds
155
+ }));
156
+ if (schema.pages !== void 0 && !schema.pages.some((page) => page.id === pageId) && pageId !== void 0) {
157
+ return { success: false, error: { type: "invalid_operation", message: `Unknown page: ${pageId}` } };
158
+ }
159
+ onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
160
+ return { success: true };
161
+ },
162
+ [createId, onChange, policy, schema]
163
+ );
164
+ const removeField = useCallback(
165
+ (fieldId) => {
166
+ if (schema.fields.length <= 1 || !schema.fields.some((field) => field.id === fieldId)) return;
167
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map(
168
+ (field) => field.displayCondition?.questionId === fieldId ? (({ displayCondition: _condition, ...candidate }) => candidate)(field) : field
169
+ );
170
+ const remainingPages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
171
+ if (schema.pages !== void 0 && remainingPages?.length === 0) {
172
+ const { pages: _pages, ...singlePageSchema } = schema;
173
+ onChange({ ...singlePageSchema, fields });
174
+ } else {
175
+ onChange({ ...schema, fields, ...remainingPages === void 0 ? {} : { pages: remainingPages } });
176
+ }
177
+ },
178
+ [onChange, schema]
179
+ );
180
+ const moveField = useCallback(
181
+ (fieldId, targetIndex) => {
182
+ const fields = move(
183
+ schema.fields,
184
+ schema.fields.findIndex((field) => field.id === fieldId),
185
+ targetIndex
186
+ );
187
+ if (fields !== void 0) {
188
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
189
+ const safeFields = fields.map((field, index) => {
190
+ const sourceIndex = field.displayCondition === void 0 ? void 0 : indexById.get(field.displayCondition.questionId);
191
+ if (field.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < index) return field;
192
+ const { displayCondition: _condition, ...withoutCondition } = field;
193
+ return withoutCondition;
194
+ });
195
+ onChange({ ...schema, fields: safeFields });
196
+ }
197
+ },
198
+ [onChange, schema]
199
+ );
200
+ const updateField = useCallback(
201
+ (fieldId, updater) => {
202
+ const current = schema.fields.find((field) => field.id === fieldId);
203
+ if (current === void 0) return;
204
+ const updated = updater(current);
205
+ if (updated.id !== fieldId) return;
206
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type)) return;
207
+ const maxTextLength = policy?.maxTextLength;
208
+ if (maxTextLength !== void 0 && [updated.title, updated.description].some((text) => text !== void 0 && text.length > maxTextLength)) {
209
+ return;
210
+ }
211
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
212
+ },
213
+ [onChange, policy, schema]
214
+ );
215
+ const addOption = useCallback(
216
+ (fieldId) => {
217
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
218
+ if (field === void 0 || !("options" in field)) {
219
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
220
+ }
221
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField) {
222
+ return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
223
+ }
224
+ const existingIds = new Set(
225
+ schema.fields.flatMap(
226
+ (candidate) => "options" in candidate ? candidate.options.map((option2) => option2.id) : []
227
+ )
228
+ );
229
+ const optionId = createId("option", existingIds);
230
+ if (optionId === void 0) {
231
+ return {
232
+ success: false,
233
+ error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
234
+ };
235
+ }
236
+ const option = { id: optionId, label: `Option ${field.options.length + 1}` };
237
+ onChange({
238
+ ...schema,
239
+ fields: schema.fields.map(
240
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: [...candidate.options, option] } : candidate
241
+ )
242
+ });
243
+ return { success: true };
244
+ },
245
+ [createId, onChange, policy, schema]
246
+ );
247
+ const removeOption = useCallback(
248
+ (fieldId, optionId) => {
249
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
250
+ if (field === void 0 || !("options" in field) || field.options.length <= 1) return;
251
+ onChange({
252
+ ...schema,
253
+ fields: schema.fields.map(
254
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: candidate.options.filter((option) => option.id !== optionId) } : candidate
255
+ )
256
+ });
257
+ },
258
+ [onChange, schema]
259
+ );
260
+ const moveOption = useCallback(
261
+ (fieldId, optionId, targetIndex) => {
262
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
263
+ if (field === void 0 || !("options" in field)) return;
264
+ const options = move(
265
+ field.options,
266
+ field.options.findIndex((option) => option.id === optionId),
267
+ targetIndex
268
+ );
269
+ if (options === void 0) return;
270
+ onChange({
271
+ ...schema,
272
+ fields: schema.fields.map(
273
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options } : candidate
274
+ )
275
+ });
276
+ },
277
+ [onChange, schema]
278
+ );
279
+ const addPage = useCallback(
280
+ (questionId) => {
281
+ const existingIds = new Set(schema.pages?.map((page) => page.id) ?? []);
282
+ const pageId = createId("page", existingIds);
283
+ if (pageId === void 0) return;
284
+ if (schema.pages === void 0) {
285
+ onChange({ ...schema, pages: [{ id: pageId, questionIds: schema.fields.map((field) => field.id) }] });
286
+ return;
287
+ }
288
+ const movableQuestionId = questionId ?? schema.pages.find((page) => page.questionIds.length > 1)?.questionIds.at(-1);
289
+ if (movableQuestionId === void 0) return;
290
+ const sourcePage = schema.pages.find((page) => page.questionIds.includes(movableQuestionId));
291
+ if (sourcePage === void 0 || sourcePage.questionIds.length <= 1) return;
292
+ const pages = [
293
+ ...schema.pages.map((page) => ({
294
+ ...page,
295
+ questionIds: page.questionIds.filter((id) => id !== movableQuestionId)
296
+ })),
297
+ { id: pageId, questionIds: [movableQuestionId] }
298
+ ];
299
+ onChange({ ...schema, pages });
300
+ },
301
+ [createId, onChange, schema]
302
+ );
303
+ const removePage = useCallback(
304
+ (pageId) => {
305
+ if (schema.pages === void 0) return;
306
+ const index = schema.pages.findIndex((page) => page.id === pageId);
307
+ const removed = schema.pages[index];
308
+ if (removed === void 0) return;
309
+ if (schema.pages.length === 1) {
310
+ const { pages: _pages, ...singlePage } = schema;
311
+ onChange(singlePage);
312
+ return;
313
+ }
314
+ const targetIndex = index === 0 ? 1 : index - 1;
315
+ onChange({
316
+ ...schema,
317
+ pages: schema.pages.map(
318
+ (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
319
+ ).filter((page) => page.id !== pageId)
320
+ });
321
+ },
322
+ [onChange, schema]
323
+ );
324
+ const setLocaleTranslation = useCallback(
325
+ (locale, target, property, text) => {
326
+ if (locale.trim().length === 0 || policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength)
327
+ return;
328
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], locale])];
329
+ if (target === "form" && ["title", "description", "completionMessage"].includes(property)) {
330
+ onChange({
331
+ ...schema,
332
+ supportedLocales,
333
+ translations: { ...schema.translations, [locale]: { ...schema.translations?.[locale], [property]: text } }
334
+ });
335
+ return;
336
+ }
337
+ const fields = schema.fields.map((field) => {
338
+ if (field.id === target && ["title", "description"].includes(property)) {
339
+ return {
340
+ ...field,
341
+ translations: { ...field.translations, [locale]: { ...field.translations?.[locale], [property]: text } }
342
+ };
343
+ }
344
+ if (!("options" in field) || property !== "label") return field;
345
+ return {
346
+ ...field,
347
+ options: field.options.map(
348
+ (option) => option.id === target ? { ...option, translations: { ...option.translations, [locale]: text } } : option
349
+ )
350
+ };
351
+ });
352
+ const pages = schema.pages?.map(
353
+ (page) => page.id === target && ["title", "description"].includes(property) ? {
354
+ ...page,
355
+ translations: { ...page.translations, [locale]: { ...page.translations?.[locale], [property]: text } }
356
+ } : page
357
+ );
358
+ onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
359
+ },
360
+ [onChange, policy, schema]
361
+ );
362
+ const validationIssues = useMemo(() => {
363
+ const result = validateFormSchema(schema);
364
+ const issues = result.valid ? [] : [...result.issues];
365
+ addPolicyIssues(schema, policy, issues);
366
+ return issues;
367
+ }, [policy, schema]);
368
+ return {
369
+ schema,
370
+ addField,
371
+ removeField,
372
+ moveField,
373
+ updateField,
374
+ addOption,
375
+ removeOption,
376
+ moveOption,
377
+ addPage,
378
+ removePage,
379
+ setLocaleTranslation,
380
+ validationIssues
381
+ };
382
+ }
383
+
384
+ // src/builder.tsx
7
385
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
386
  var FIELD_TYPES = [
9
387
  "text",
@@ -187,7 +565,21 @@ function ConditionValueEditor({
187
565
  }
188
566
  );
189
567
  }
190
- function FormBuilder({ schema, onChange, locale = "en", translator, translationAdapter }) {
568
+ function FormBuilder({
569
+ schema,
570
+ onChange,
571
+ locale = "en",
572
+ translator,
573
+ translationAdapter,
574
+ policy,
575
+ idFactory
576
+ }) {
577
+ const headless = useFormBuilder({
578
+ schema,
579
+ onChange,
580
+ ...policy === void 0 ? {} : { policy },
581
+ ...idFactory === void 0 ? {} : { idFactory }
582
+ });
191
583
  const [newPageQuestionId, setNewPageQuestionId] = useState("");
192
584
  const [newLocale, setNewLocale] = useState("");
193
585
  const [editingLocale, setEditingLocale] = useState("");
@@ -198,9 +590,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
198
590
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
199
591
  };
200
592
  const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
201
- const updateField = (fieldId, update) => {
202
- emitSchema({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? update(field) : field) });
203
- };
593
+ const updateField = headless.updateField;
204
594
  const changeType = (fieldId, type) => {
205
595
  emitSchema({
206
596
  ...schema,
@@ -216,10 +606,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
216
606
  })
217
607
  });
218
608
  };
219
- const removeField = (fieldId) => {
220
- if (schema.fields.length === 1) return;
221
- emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
222
- };
609
+ const removeField = headless.removeField;
223
610
  const moveField = (index, offset) => {
224
611
  const target = index + offset;
225
612
  if (target < 0 || target >= schema.fields.length) return;
@@ -231,21 +618,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
231
618
  fields[target] = current;
232
619
  emitSchema({ ...schema, fields });
233
620
  };
234
- const addField = () => {
235
- const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
236
- const nextSchema = {
237
- ...schema,
238
- fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
239
- };
240
- emitSchema(
241
- schema.pages === void 0 ? nextSchema : {
242
- ...nextSchema,
243
- pages: schema.pages.map(
244
- (page, index) => index === (schema.pages?.length ?? 0) - 1 ? { ...page, questionIds: [...page.questionIds, id] } : page
245
- )
246
- }
247
- );
248
- };
621
+ const addField = () => headless.addField("text");
249
622
  const enablePages = () => {
250
623
  if (schema.pages !== void 0) return;
251
624
  emitSchema({
@@ -343,7 +716,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
343
716
  setIsTranslating(true);
344
717
  setTranslationError(null);
345
718
  try {
346
- onChange(await populateSchemaTranslations(schema, [editingLocale], translationAdapter));
719
+ const populated = await populateSchemaTranslations(schema, [editingLocale], translationAdapter, {
720
+ overwrite: "all"
721
+ });
722
+ onChange(populated.schema);
347
723
  } catch (cause) {
348
724
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
349
725
  } finally {
@@ -692,7 +1068,9 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
692
1068
  {
693
1069
  value: field.type,
694
1070
  onChange: (event) => changeType(field.id, event.currentTarget.value),
695
- children: FIELD_TYPES.map((type) => /* @__PURE__ */ jsx("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
1071
+ children: FIELD_TYPES.filter(
1072
+ (type) => policy?.allowedFieldTypes === void 0 || policy.allowedFieldTypes.includes(type)
1073
+ ).map((type) => /* @__PURE__ */ jsx("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
696
1074
  }
697
1075
  )
698
1076
  ] }),
@@ -865,13 +1243,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
865
1243
  {
866
1244
  type: "button",
867
1245
  disabled: field.options.length === 1,
868
- onClick: () => updateField(
869
- field.id,
870
- (current) => "options" in current ? {
871
- ...current,
872
- options: current.options.filter((_item, itemIndex) => itemIndex !== optionIndex)
873
- } : current
874
- ),
1246
+ onClick: () => headless.removeOption(field.id, option.id),
875
1247
  children: translate("builder.remove")
876
1248
  }
877
1249
  )
@@ -880,22 +1252,8 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
880
1252
  "button",
881
1253
  {
882
1254
  type: "button",
883
- onClick: () => updateField(
884
- field.id,
885
- (current) => "options" in current ? (() => {
886
- const id = createUniqueId("opt", new Set(current.options.map((option) => option.id)));
887
- return {
888
- ...current,
889
- options: [
890
- ...current.options,
891
- {
892
- id,
893
- label: translate("builder.newOptionLabel", { index: current.options.length + 1 })
894
- }
895
- ]
896
- };
897
- })() : current
898
- ),
1255
+ disabled: policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField,
1256
+ onClick: () => headless.addOption(field.id),
899
1257
  children: translate("builder.addOption")
900
1258
  }
901
1259
  )
@@ -957,7 +1315,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
957
1315
  ] })
958
1316
  ] }, field.id);
959
1317
  }) }),
960
- /* @__PURE__ */ jsx("button", { className: "form-engine-builder__add", type: "button", onClick: addField, children: translate("builder.addQuestion") })
1318
+ /* @__PURE__ */ jsx(
1319
+ "button",
1320
+ {
1321
+ className: "form-engine-builder__add",
1322
+ type: "button",
1323
+ disabled: policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields,
1324
+ onClick: addField,
1325
+ children: translate("builder.addQuestion")
1326
+ }
1327
+ )
961
1328
  ] });
962
1329
  }
963
1330
 
@@ -971,7 +1338,7 @@ import {
971
1338
  validateAnswers,
972
1339
  validatePageAnswers
973
1340
  } from "@form-engine-ts/core";
974
- import { createContext, useCallback, useContext, useEffect, useMemo, useState as useState2 } from "react";
1341
+ import { createContext, useCallback as useCallback2, useContext, useEffect, useMemo as useMemo2, useState as useState2 } from "react";
975
1342
  import { jsx as jsx2 } from "react/jsx-runtime";
976
1343
  var FormContext = createContext(null);
977
1344
  function issuesByField(issues) {
@@ -988,7 +1355,7 @@ function FormProvider({
988
1355
  onSubmit,
989
1356
  children
990
1357
  }) {
991
- const validSchema = useMemo(() => {
1358
+ const validSchema = useMemo2(() => {
992
1359
  assertValidFormSchema(schema);
993
1360
  const localized = resolveLocalizedSchema(schema, locale);
994
1361
  assertValidFormSchema(localized);
@@ -999,13 +1366,13 @@ function FormProvider({
999
1366
  const [submitStatus, setSubmitStatus] = useState2("idle");
1000
1367
  const [submitError, setSubmitError] = useState2(null);
1001
1368
  const [validationPageIndex, setValidationPageIndex] = useState2(null);
1002
- const visibility = useMemo(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
1003
- const pageVisibility = useMemo(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
1369
+ const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
1370
+ const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
1004
1371
  useEffect(() => {
1005
1372
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1006
1373
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
1007
1374
  }, [validSchema]);
1008
- const setValue = useCallback(
1375
+ const setValue = useCallback2(
1009
1376
  (fieldId, value) => {
1010
1377
  setValues((current) => {
1011
1378
  const next = { ...current, [fieldId]: value };
@@ -1021,7 +1388,7 @@ function FormProvider({
1021
1388
  },
1022
1389
  [validSchema, validationPageIndex]
1023
1390
  );
1024
- const restoreValues = useCallback(
1391
+ const restoreValues = useCallback2(
1025
1392
  (restoredValues) => {
1026
1393
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1027
1394
  setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
@@ -1032,7 +1399,7 @@ function FormProvider({
1032
1399
  },
1033
1400
  [validSchema]
1034
1401
  );
1035
- const validatePage = useCallback(
1402
+ const validatePage = useCallback2(
1036
1403
  (pageIndex) => {
1037
1404
  const result = validatePageAnswers(validSchema, pageIndex, values);
1038
1405
  setErrors(issuesByField(result.issues));
@@ -1043,42 +1410,51 @@ function FormProvider({
1043
1410
  },
1044
1411
  [validSchema, values]
1045
1412
  );
1046
- const reset = useCallback(() => {
1413
+ const reset = useCallback2(() => {
1047
1414
  setValues({ ...initialValues });
1048
1415
  setErrors({});
1049
1416
  setValidationPageIndex(null);
1050
1417
  setSubmitStatus("idle");
1051
1418
  setSubmitError(null);
1052
1419
  }, [initialValues]);
1053
- const submit = useCallback(async () => {
1054
- const validation = validateAnswers(validSchema, values);
1055
- if (!validation.valid) {
1056
- setErrors(issuesByField(validation.issues));
1420
+ const submit = useCallback2(
1421
+ async (beforeSubmit) => {
1422
+ const validation = validateAnswers(validSchema, values);
1423
+ if (!validation.valid) {
1424
+ setErrors(issuesByField(validation.issues));
1425
+ setValidationPageIndex(null);
1426
+ setSubmitStatus("error");
1427
+ setSubmitError(null);
1428
+ return { status: "invalid", issues: validation.issues };
1429
+ }
1430
+ setErrors({});
1057
1431
  setValidationPageIndex(null);
1058
- setSubmitStatus("error");
1059
1432
  setSubmitError(null);
1060
- return false;
1061
- }
1062
- setErrors({});
1063
- setValidationPageIndex(null);
1064
- setSubmitStatus("submitting");
1065
- setSubmitError(null);
1066
- try {
1067
- await onSubmit(selectVisibleAnswers(validSchema, values));
1068
- if (resetOnSuccess) setValues({ ...initialValues });
1069
- setSubmitStatus("success");
1070
- return true;
1071
- } catch (cause) {
1072
- setSubmitError(cause instanceof Error ? cause : new Error(String(cause)));
1073
- setSubmitStatus("error");
1074
- return false;
1075
- }
1076
- }, [initialValues, onSubmit, resetOnSuccess, validSchema, values]);
1077
- const translate = useCallback(
1433
+ const visibleValues = selectVisibleAnswers(validSchema, values);
1434
+ try {
1435
+ if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
1436
+ setSubmitStatus("idle");
1437
+ return { status: "cancelled" };
1438
+ }
1439
+ setSubmitStatus("submitting");
1440
+ await onSubmit(visibleValues);
1441
+ if (resetOnSuccess) setValues({ ...initialValues });
1442
+ setSubmitStatus("success");
1443
+ return { status: "success" };
1444
+ } catch (cause) {
1445
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1446
+ setSubmitError(error);
1447
+ setSubmitStatus("error");
1448
+ return { status: "error", error };
1449
+ }
1450
+ },
1451
+ [initialValues, onSubmit, resetOnSuccess, validSchema, values]
1452
+ );
1453
+ const translate = useCallback2(
1078
1454
  (key, params) => translator.translate(key, locale, params),
1079
1455
  [locale, translator]
1080
1456
  );
1081
- const contextValue = useMemo(
1457
+ const contextValue = useMemo2(
1082
1458
  () => ({
1083
1459
  schema: validSchema,
1084
1460
  locale,
@@ -1126,7 +1502,7 @@ function useField(fieldId) {
1126
1502
  const form = useForm();
1127
1503
  const field = form.schema.fields.find((item) => item.id === fieldId);
1128
1504
  if (field === void 0) throw new Error(`Unknown form field: ${fieldId}`);
1129
- const setValue = useCallback((value) => form.setValue(fieldId, value), [fieldId, form]);
1505
+ const setValue = useCallback2((value) => form.setValue(fieldId, value), [fieldId, form]);
1130
1506
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
1131
1507
  }
1132
1508
 
@@ -1134,8 +1510,15 @@ function useField(fieldId) {
1134
1510
  import {
1135
1511
  validateAnswers as validateAnswers2
1136
1512
  } from "@form-engine-ts/core";
1137
- import { useEffect as useEffect2, useId, useMemo as useMemo2, useRef, useState as useState3 } from "react";
1138
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1513
+ import {
1514
+ Fragment as Fragment2,
1515
+ useEffect as useEffect2,
1516
+ useId,
1517
+ useMemo as useMemo3,
1518
+ useRef,
1519
+ useState as useState3
1520
+ } from "react";
1521
+ import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1139
1522
  function describedBy(field, error, helpId, errorId) {
1140
1523
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
1141
1524
  Boolean
@@ -1149,7 +1532,7 @@ function RequiredMark({ required }) {
1149
1532
  ] }) : null;
1150
1533
  }
1151
1534
  function FieldMessage({ props }) {
1152
- return /* @__PURE__ */ jsxs2(Fragment2, { children: [
1535
+ return /* @__PURE__ */ jsxs2(Fragment3, { children: [
1153
1536
  props.field.description === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.helpId, className: "fe-help", children: props.field.description }),
1154
1537
  props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
1155
1538
  ] });
@@ -1337,12 +1720,15 @@ function parseDraft(serialized) {
1337
1720
  return null;
1338
1721
  }
1339
1722
  }
1340
- function FormRenderer({
1723
+ function ContextFormRenderer({
1341
1724
  components = {},
1342
1725
  className = "",
1343
1726
  successMessageKey,
1344
1727
  errorMessageKey,
1345
- autoSaveKey
1728
+ autoSaveKey,
1729
+ beforeSubmit,
1730
+ onDraftSave,
1731
+ slots = {}
1346
1732
  }) {
1347
1733
  const form = useForm();
1348
1734
  const prefix = useId().replace(/:/g, "");
@@ -1352,7 +1738,7 @@ function FormRenderer({
1352
1738
  const [currentPageIndex, setCurrentPageIndex] = useState3(0);
1353
1739
  const [focusFieldId, setFocusFieldId] = useState3(null);
1354
1740
  const pages = form.schema.pages;
1355
- const visiblePageIndexes = useMemo2(
1741
+ const visiblePageIndexes = useMemo3(
1356
1742
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1357
1743
  [form.pageVisibility, pages]
1358
1744
  );
@@ -1390,9 +1776,10 @@ function FormRenderer({
1390
1776
  setDraftRestored(true);
1391
1777
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1392
1778
  useEffect2(() => {
1393
- if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1394
1779
  if (form.submitStatus === "success") return;
1395
1780
  const timeout = globalThis.setTimeout(() => {
1781
+ onDraftSave?.(form.values);
1782
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1396
1783
  const draft = {
1397
1784
  formId: form.schema.id,
1398
1785
  formVersion: form.schema.version,
@@ -1402,7 +1789,7 @@ function FormRenderer({
1402
1789
  globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1403
1790
  }, 500);
1404
1791
  return () => globalThis.clearTimeout(timeout);
1405
- }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values]);
1792
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values, onDraftSave]);
1406
1793
  const focusFirstIssue = (fieldId) => {
1407
1794
  if (fieldId !== void 0) setFocusFieldId(fieldId);
1408
1795
  };
@@ -1415,27 +1802,39 @@ function FormRenderer({
1415
1802
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1416
1803
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1417
1804
  };
1418
- const handleSubmit = async (event) => {
1419
- event.preventDefault();
1805
+ const submitValues = async () => {
1420
1806
  const validation = validateAnswers2(form.schema, form.values);
1421
1807
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
1422
- const valid = await form.submit();
1423
- if (!valid) {
1808
+ const result = await form.submit(beforeSubmit);
1809
+ if (result.status === "invalid") {
1424
1810
  const invalidPageIndex = pages?.findIndex(
1425
1811
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1426
1812
  );
1427
1813
  if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1428
1814
  focusFirstIssue(firstInvalidFieldId);
1429
- return;
1815
+ return result;
1430
1816
  }
1817
+ if (result.status !== "success") return result;
1431
1818
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1432
1819
  globalThis.localStorage.removeItem(autoSaveKey);
1433
1820
  setDraftRestored(false);
1434
1821
  }
1435
1822
  setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1823
+ return result;
1824
+ };
1825
+ const handleSubmit = (event) => {
1826
+ event.preventDefault();
1827
+ void submitValues();
1436
1828
  };
1829
+ const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
1830
+ const canPrev = pages !== void 0 && activeVisibleIndex > 0;
1831
+ const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
1832
+ const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: form.isSubmitting, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
1437
1833
  return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
1438
- /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
1834
+ slots.renderHeader?.({
1835
+ title: form.schema.title,
1836
+ ...form.schema.description === void 0 ? {} : { description: form.schema.description }
1837
+ }) ?? /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
1439
1838
  /* @__PURE__ */ jsx3("h1", { children: form.schema.title }),
1440
1839
  form.schema.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { children: form.schema.description }),
1441
1840
  pages === void 0 ? null : /* @__PURE__ */ jsxs2("div", { className: "fe-progress", children: [
@@ -1463,41 +1862,121 @@ function FormRenderer({
1463
1862
  activePage?.title === void 0 ? null : /* @__PURE__ */ jsx3("h2", { className: "fe-page-title", children: activePage.title }),
1464
1863
  activePage?.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { className: "fe-page-description", children: activePage.description }),
1465
1864
  /* @__PURE__ */ jsx3("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true && (fieldIds === void 0 || fieldIds.has(field.id))).map((field) => {
1865
+ const error = form.errors[field.id];
1466
1866
  const props = {
1467
1867
  field,
1468
1868
  value: form.values[field.id],
1469
- error: form.errors[field.id],
1869
+ error,
1470
1870
  setValue: (value) => form.setValue(field.id, value),
1471
1871
  translate: form.translate,
1472
1872
  inputId: `${prefix}-${field.id}`,
1473
1873
  errorId: `${prefix}-${field.id}-error`,
1474
1874
  helpId: `${prefix}-${field.id}-help`
1475
1875
  };
1876
+ if (slots.renderField !== void 0) {
1877
+ return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
1878
+ question: field,
1879
+ value: form.values[field.id],
1880
+ onChange: (value) => {
1881
+ if (isFormValue(value)) form.setValue(field.id, value);
1882
+ },
1883
+ ...error === void 0 ? {} : { error }
1884
+ }) }, field.id);
1885
+ }
1476
1886
  const Component = components[field.type];
1477
1887
  return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
1478
1888
  }) }),
1479
- pages === void 0 ? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") }) : /* @__PURE__ */ jsxs2("div", { className: "form-step-navigation", children: [
1480
- activeVisibleIndex > 0 ? /* @__PURE__ */ jsx3(
1481
- "button",
1482
- {
1483
- className: "btn-prev",
1484
- type: "button",
1485
- onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1486
- children: form.translate("form.back")
1487
- }
1488
- ) : null,
1489
- activeVisibleIndex < visiblePageIndexes.length - 1 ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") })
1889
+ validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
1890
+ validationIssues.length,
1891
+ " validation error",
1892
+ validationIssues.length === 1 ? "" : "s",
1893
+ "."
1894
+ ] }),
1895
+ pages === void 0 ? /* @__PURE__ */ jsxs2(Fragment3, { children: [
1896
+ slots.renderNavigation?.({
1897
+ currentPage: 0,
1898
+ totalPages: 1,
1899
+ canPrev: false,
1900
+ canNext: false,
1901
+ onPrev: () => void 0,
1902
+ onNext: () => void 0
1903
+ }),
1904
+ renderSubmitButton()
1905
+ ] }) : /* @__PURE__ */ jsxs2("div", { className: "form-step-navigation", children: [
1906
+ slots.renderNavigation?.({
1907
+ currentPage: activeVisibleIndex,
1908
+ totalPages: visiblePageIndexes.length,
1909
+ canPrev,
1910
+ canNext,
1911
+ onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1912
+ onNext: handleNext
1913
+ }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
1914
+ canPrev ? /* @__PURE__ */ jsx3(
1915
+ "button",
1916
+ {
1917
+ className: "btn-prev",
1918
+ type: "button",
1919
+ onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1920
+ children: form.translate("form.back")
1921
+ }
1922
+ ) : null,
1923
+ canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
1924
+ ] }),
1925
+ canNext ? null : renderSubmitButton()
1490
1926
  ] }),
1491
1927
  /* @__PURE__ */ jsxs2("div", { className: "fe-status", "aria-live": "polite", children: [
1492
- form.submitStatus === "success" && successMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "status", children: form.translate(successMessageKey) }) : null,
1928
+ form.submitStatus === "success" ? slots.renderCompletion?.({
1929
+ message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
1930
+ }) ?? /* @__PURE__ */ jsx3("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
1493
1931
  form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
1494
1932
  ] })
1495
1933
  ] });
1496
1934
  }
1935
+ var RENDERER_MESSAGES = {
1936
+ "form.submit": "Submit",
1937
+ "form.back": "Back",
1938
+ "form.next": "Next",
1939
+ "form.step": "Step {{current}} / {{total}}",
1940
+ "form.draftRestored": "Draft restored",
1941
+ "validation.required": "This field is required."
1942
+ };
1943
+ var defaultRendererTranslator = {
1944
+ translate(key, _locale, params = {}) {
1945
+ return (RENDERER_MESSAGES[key] ?? key).replace(
1946
+ /\{\{(\w+)\}\}/g,
1947
+ (token, name) => Object.hasOwn(params, name) ? String(params[name]) : token
1948
+ );
1949
+ }
1950
+ };
1951
+ function FormRenderer(props) {
1952
+ if (!("schema" in props)) return /* @__PURE__ */ jsx3(ContextFormRenderer, { ...props });
1953
+ const {
1954
+ schema,
1955
+ locale = schema.defaultLocale ?? "en",
1956
+ translator = defaultRendererTranslator,
1957
+ initialValues,
1958
+ resetOnSuccess,
1959
+ onSubmit,
1960
+ ...rendererProps
1961
+ } = props;
1962
+ return /* @__PURE__ */ jsx3(
1963
+ FormProvider,
1964
+ {
1965
+ schema,
1966
+ locale,
1967
+ translator,
1968
+ onSubmit,
1969
+ ...initialValues === void 0 ? {} : { initialValues },
1970
+ ...resetOnSuccess === void 0 ? {} : { resetOnSuccess },
1971
+ children: /* @__PURE__ */ jsx3(ContextFormRenderer, { ...rendererProps })
1972
+ }
1973
+ );
1974
+ }
1497
1975
  export {
1498
1976
  FormBuilder,
1499
1977
  FormProvider,
1500
1978
  FormRenderer,
1501
1979
  useField,
1502
- useForm
1980
+ useForm,
1981
+ useFormBuilder
1503
1982
  };