@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.cjs CHANGED
@@ -24,13 +24,390 @@ __export(index_exports, {
24
24
  FormProvider: () => FormProvider,
25
25
  FormRenderer: () => FormRenderer,
26
26
  useField: () => useField,
27
- useForm: () => useForm
27
+ useForm: () => useForm,
28
+ useFormBuilder: () => useFormBuilder
28
29
  });
29
30
  module.exports = __toCommonJS(index_exports);
30
31
 
31
32
  // src/builder.tsx
33
+ var import_core2 = require("@form-engine-ts/core");
34
+ var import_react2 = require("react");
35
+
36
+ // src/hooks/useFormBuilder.ts
32
37
  var import_core = require("@form-engine-ts/core");
33
38
  var import_react = require("react");
39
+ var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
40
+ function defaultIdFactory(kind, existingIds) {
41
+ const prefix = DEFAULT_PREFIXES[kind];
42
+ let id;
43
+ do {
44
+ id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
45
+ } while (existingIds.has(id));
46
+ return id;
47
+ }
48
+ function newField(type, id, optionId) {
49
+ const base = { id, title: "New question", required: false };
50
+ if (type === "text" || type === "textarea") return { ...base, type };
51
+ if (type === "number") return { ...base, type };
52
+ if (type === "rating") return { ...base, type, min: 1, max: 5 };
53
+ if (type === "checkbox") return { ...base, type };
54
+ return optionId === void 0 ? void 0 : { ...base, type, options: [{ id: optionId, label: "Option 1" }] };
55
+ }
56
+ function move(items, sourceIndex, targetIndex) {
57
+ if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
58
+ return void 0;
59
+ const result = [...items];
60
+ const [item] = result.splice(sourceIndex, 1);
61
+ if (item === void 0) return void 0;
62
+ result.splice(targetIndex, 0, item);
63
+ return result;
64
+ }
65
+ function addPolicyIssues(schema, policy, issues) {
66
+ if (policy?.maxFields !== void 0 && schema.fields.length > policy.maxFields) {
67
+ issues.push({
68
+ path: "fields",
69
+ code: "max_fields_exceeded",
70
+ message: `At most ${policy.maxFields} fields are allowed.`
71
+ });
72
+ }
73
+ schema.fields.forEach((field, index) => {
74
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(field.type)) {
75
+ issues.push({
76
+ path: `fields[${index}].type`,
77
+ code: "disallowed_field_type",
78
+ message: `Field type ${field.type} is not allowed.`
79
+ });
80
+ }
81
+ if (policy?.maxTextLength !== void 0) {
82
+ for (const [property, text] of [
83
+ ["title", field.title],
84
+ ["description", field.description]
85
+ ]) {
86
+ if (text !== void 0 && text.length > policy.maxTextLength) {
87
+ issues.push({
88
+ path: `fields[${index}].${property}`,
89
+ code: "max_text_length_exceeded",
90
+ message: `Text must be at most ${policy.maxTextLength} characters.`
91
+ });
92
+ }
93
+ }
94
+ }
95
+ if (policy?.maxOptionsPerField !== void 0 && "options" in field && field.options.length > policy.maxOptionsPerField) {
96
+ issues.push({
97
+ path: `fields[${index}].options`,
98
+ code: "max_options_exceeded",
99
+ message: `At most ${policy.maxOptionsPerField} options are allowed.`
100
+ });
101
+ }
102
+ });
103
+ for (const locale of policy?.requiredLocales ?? []) {
104
+ if (!(schema.supportedLocales ?? []).includes(locale)) {
105
+ issues.push({
106
+ path: "supportedLocales",
107
+ code: "required_locale_missing",
108
+ message: `Required locale ${locale} is missing.`
109
+ });
110
+ }
111
+ if (locale === schema.defaultLocale) continue;
112
+ const requiredTranslations = [
113
+ { path: `translations.${locale}.title`, value: schema.translations?.[locale]?.title },
114
+ ...schema.fields.map((field, index) => ({
115
+ path: `fields[${index}].translations.${locale}.title`,
116
+ value: field.translations?.[locale]?.title
117
+ })),
118
+ ...schema.fields.flatMap(
119
+ (field, fieldIndex) => "options" in field ? field.options.map((option, optionIndex) => ({
120
+ path: `fields[${fieldIndex}].options[${optionIndex}].translations.${locale}`,
121
+ value: option.translations?.[locale]
122
+ })) : []
123
+ ),
124
+ ...schema.pages?.flatMap(
125
+ (page, pageIndex) => page.title === void 0 ? [] : [{ path: `pages[${pageIndex}].translations.${locale}.title`, value: page.translations?.[locale]?.title }]
126
+ ) ?? []
127
+ ];
128
+ for (const translation of requiredTranslations) {
129
+ if (translation.value === void 0 || translation.value.trim().length === 0) {
130
+ issues.push({
131
+ path: translation.path,
132
+ code: "required_translation_missing",
133
+ message: `A translation for required locale ${locale} is missing.`
134
+ });
135
+ }
136
+ }
137
+ }
138
+ }
139
+ function useFormBuilder({
140
+ schema,
141
+ onChange,
142
+ policy,
143
+ idFactory = defaultIdFactory
144
+ }) {
145
+ const createId = (0, import_react.useCallback)(
146
+ (kind, existingIds) => {
147
+ const id = idFactory(kind, existingIds).trim();
148
+ return id.length > 0 && !existingIds.has(id) ? id : void 0;
149
+ },
150
+ [idFactory]
151
+ );
152
+ const addField = (0, import_react.useCallback)(
153
+ (type, pageId) => {
154
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type)) {
155
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
156
+ }
157
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields) {
158
+ return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
159
+ }
160
+ const fieldId = createId("field", new Set(schema.fields.map((field2) => field2.id)));
161
+ const needsOption = ["select", "radio", "multi-select"].includes(type);
162
+ const optionId = needsOption ? createId(
163
+ "option",
164
+ new Set(
165
+ schema.fields.flatMap((field2) => "options" in field2 ? field2.options.map((option) => option.id) : [])
166
+ )
167
+ ) : void 0;
168
+ if (fieldId === void 0 || needsOption && optionId === void 0) {
169
+ return {
170
+ success: false,
171
+ error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
172
+ };
173
+ }
174
+ const field = newField(type, fieldId, optionId);
175
+ if (field === void 0) {
176
+ return { success: false, error: { type: "invalid_operation", message: "Could not create the field." } };
177
+ }
178
+ const pages = schema.pages?.map((page, index) => ({
179
+ ...page,
180
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, fieldId] : page.questionIds
181
+ }));
182
+ if (schema.pages !== void 0 && !schema.pages.some((page) => page.id === pageId) && pageId !== void 0) {
183
+ return { success: false, error: { type: "invalid_operation", message: `Unknown page: ${pageId}` } };
184
+ }
185
+ onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
186
+ return { success: true };
187
+ },
188
+ [createId, onChange, policy, schema]
189
+ );
190
+ const removeField = (0, import_react.useCallback)(
191
+ (fieldId) => {
192
+ if (schema.fields.length <= 1 || !schema.fields.some((field) => field.id === fieldId)) return;
193
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map(
194
+ (field) => field.displayCondition?.questionId === fieldId ? (({ displayCondition: _condition, ...candidate }) => candidate)(field) : field
195
+ );
196
+ const remainingPages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
197
+ if (schema.pages !== void 0 && remainingPages?.length === 0) {
198
+ const { pages: _pages, ...singlePageSchema } = schema;
199
+ onChange({ ...singlePageSchema, fields });
200
+ } else {
201
+ onChange({ ...schema, fields, ...remainingPages === void 0 ? {} : { pages: remainingPages } });
202
+ }
203
+ },
204
+ [onChange, schema]
205
+ );
206
+ const moveField = (0, import_react.useCallback)(
207
+ (fieldId, targetIndex) => {
208
+ const fields = move(
209
+ schema.fields,
210
+ schema.fields.findIndex((field) => field.id === fieldId),
211
+ targetIndex
212
+ );
213
+ if (fields !== void 0) {
214
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
215
+ const safeFields = fields.map((field, index) => {
216
+ const sourceIndex = field.displayCondition === void 0 ? void 0 : indexById.get(field.displayCondition.questionId);
217
+ if (field.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < index) return field;
218
+ const { displayCondition: _condition, ...withoutCondition } = field;
219
+ return withoutCondition;
220
+ });
221
+ onChange({ ...schema, fields: safeFields });
222
+ }
223
+ },
224
+ [onChange, schema]
225
+ );
226
+ const updateField = (0, import_react.useCallback)(
227
+ (fieldId, updater) => {
228
+ const current = schema.fields.find((field) => field.id === fieldId);
229
+ if (current === void 0) return;
230
+ const updated = updater(current);
231
+ if (updated.id !== fieldId) return;
232
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type)) return;
233
+ const maxTextLength = policy?.maxTextLength;
234
+ if (maxTextLength !== void 0 && [updated.title, updated.description].some((text) => text !== void 0 && text.length > maxTextLength)) {
235
+ return;
236
+ }
237
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
238
+ },
239
+ [onChange, policy, schema]
240
+ );
241
+ const addOption = (0, import_react.useCallback)(
242
+ (fieldId) => {
243
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
244
+ if (field === void 0 || !("options" in field)) {
245
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
246
+ }
247
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField) {
248
+ return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
249
+ }
250
+ const existingIds = new Set(
251
+ schema.fields.flatMap(
252
+ (candidate) => "options" in candidate ? candidate.options.map((option2) => option2.id) : []
253
+ )
254
+ );
255
+ const optionId = createId("option", existingIds);
256
+ if (optionId === void 0) {
257
+ return {
258
+ success: false,
259
+ error: { type: "invalid_operation", message: "idFactory returned a duplicate or empty ID." }
260
+ };
261
+ }
262
+ const option = { id: optionId, label: `Option ${field.options.length + 1}` };
263
+ onChange({
264
+ ...schema,
265
+ fields: schema.fields.map(
266
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: [...candidate.options, option] } : candidate
267
+ )
268
+ });
269
+ return { success: true };
270
+ },
271
+ [createId, onChange, policy, schema]
272
+ );
273
+ const removeOption = (0, import_react.useCallback)(
274
+ (fieldId, optionId) => {
275
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
276
+ if (field === void 0 || !("options" in field) || field.options.length <= 1) return;
277
+ onChange({
278
+ ...schema,
279
+ fields: schema.fields.map(
280
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options: candidate.options.filter((option) => option.id !== optionId) } : candidate
281
+ )
282
+ });
283
+ },
284
+ [onChange, schema]
285
+ );
286
+ const moveOption = (0, import_react.useCallback)(
287
+ (fieldId, optionId, targetIndex) => {
288
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
289
+ if (field === void 0 || !("options" in field)) return;
290
+ const options = move(
291
+ field.options,
292
+ field.options.findIndex((option) => option.id === optionId),
293
+ targetIndex
294
+ );
295
+ if (options === void 0) return;
296
+ onChange({
297
+ ...schema,
298
+ fields: schema.fields.map(
299
+ (candidate) => candidate.id === fieldId && "options" in candidate ? { ...candidate, options } : candidate
300
+ )
301
+ });
302
+ },
303
+ [onChange, schema]
304
+ );
305
+ const addPage = (0, import_react.useCallback)(
306
+ (questionId) => {
307
+ const existingIds = new Set(schema.pages?.map((page) => page.id) ?? []);
308
+ const pageId = createId("page", existingIds);
309
+ if (pageId === void 0) return;
310
+ if (schema.pages === void 0) {
311
+ onChange({ ...schema, pages: [{ id: pageId, questionIds: schema.fields.map((field) => field.id) }] });
312
+ return;
313
+ }
314
+ const movableQuestionId = questionId ?? schema.pages.find((page) => page.questionIds.length > 1)?.questionIds.at(-1);
315
+ if (movableQuestionId === void 0) return;
316
+ const sourcePage = schema.pages.find((page) => page.questionIds.includes(movableQuestionId));
317
+ if (sourcePage === void 0 || sourcePage.questionIds.length <= 1) return;
318
+ const pages = [
319
+ ...schema.pages.map((page) => ({
320
+ ...page,
321
+ questionIds: page.questionIds.filter((id) => id !== movableQuestionId)
322
+ })),
323
+ { id: pageId, questionIds: [movableQuestionId] }
324
+ ];
325
+ onChange({ ...schema, pages });
326
+ },
327
+ [createId, onChange, schema]
328
+ );
329
+ const removePage = (0, import_react.useCallback)(
330
+ (pageId) => {
331
+ if (schema.pages === void 0) return;
332
+ const index = schema.pages.findIndex((page) => page.id === pageId);
333
+ const removed = schema.pages[index];
334
+ if (removed === void 0) return;
335
+ if (schema.pages.length === 1) {
336
+ const { pages: _pages, ...singlePage } = schema;
337
+ onChange(singlePage);
338
+ return;
339
+ }
340
+ const targetIndex = index === 0 ? 1 : index - 1;
341
+ onChange({
342
+ ...schema,
343
+ pages: schema.pages.map(
344
+ (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
345
+ ).filter((page) => page.id !== pageId)
346
+ });
347
+ },
348
+ [onChange, schema]
349
+ );
350
+ const setLocaleTranslation = (0, import_react.useCallback)(
351
+ (locale, target, property, text) => {
352
+ if (locale.trim().length === 0 || policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength)
353
+ return;
354
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], locale])];
355
+ if (target === "form" && ["title", "description", "completionMessage"].includes(property)) {
356
+ onChange({
357
+ ...schema,
358
+ supportedLocales,
359
+ translations: { ...schema.translations, [locale]: { ...schema.translations?.[locale], [property]: text } }
360
+ });
361
+ return;
362
+ }
363
+ const fields = schema.fields.map((field) => {
364
+ if (field.id === target && ["title", "description"].includes(property)) {
365
+ return {
366
+ ...field,
367
+ translations: { ...field.translations, [locale]: { ...field.translations?.[locale], [property]: text } }
368
+ };
369
+ }
370
+ if (!("options" in field) || property !== "label") return field;
371
+ return {
372
+ ...field,
373
+ options: field.options.map(
374
+ (option) => option.id === target ? { ...option, translations: { ...option.translations, [locale]: text } } : option
375
+ )
376
+ };
377
+ });
378
+ const pages = schema.pages?.map(
379
+ (page) => page.id === target && ["title", "description"].includes(property) ? {
380
+ ...page,
381
+ translations: { ...page.translations, [locale]: { ...page.translations?.[locale], [property]: text } }
382
+ } : page
383
+ );
384
+ onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
385
+ },
386
+ [onChange, policy, schema]
387
+ );
388
+ const validationIssues = (0, import_react.useMemo)(() => {
389
+ const result = (0, import_core.validateFormSchema)(schema);
390
+ const issues = result.valid ? [] : [...result.issues];
391
+ addPolicyIssues(schema, policy, issues);
392
+ return issues;
393
+ }, [policy, schema]);
394
+ return {
395
+ schema,
396
+ addField,
397
+ removeField,
398
+ moveField,
399
+ updateField,
400
+ addOption,
401
+ removeOption,
402
+ moveOption,
403
+ addPage,
404
+ removePage,
405
+ setLocaleTranslation,
406
+ validationIssues
407
+ };
408
+ }
409
+
410
+ // src/builder.tsx
34
411
  var import_jsx_runtime = require("react/jsx-runtime");
35
412
  var FIELD_TYPES = [
36
413
  "text",
@@ -154,7 +531,7 @@ function withoutDisplayCondition(field) {
154
531
  return rest;
155
532
  }
156
533
  function sanitizeBuilderSchema(schema) {
157
- const sanitized = (0, import_core.sanitizeSchema)(schema);
534
+ const sanitized = (0, import_core2.sanitizeSchema)(schema);
158
535
  const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
159
536
  const fields = sanitized.fields.map((field, index) => {
160
537
  const sourceId = field.displayCondition?.questionId;
@@ -214,20 +591,32 @@ function ConditionValueEditor({
214
591
  }
215
592
  );
216
593
  }
217
- function FormBuilder({ schema, onChange, locale = "en", translator, translationAdapter }) {
218
- const [newPageQuestionId, setNewPageQuestionId] = (0, import_react.useState)("");
219
- const [newLocale, setNewLocale] = (0, import_react.useState)("");
220
- const [editingLocale, setEditingLocale] = (0, import_react.useState)("");
221
- const [isTranslating, setIsTranslating] = (0, import_react.useState)(false);
222
- const [translationError, setTranslationError] = (0, import_react.useState)(null);
594
+ function FormBuilder({
595
+ schema,
596
+ onChange,
597
+ locale = "en",
598
+ translator,
599
+ translationAdapter,
600
+ policy,
601
+ idFactory
602
+ }) {
603
+ const headless = useFormBuilder({
604
+ schema,
605
+ onChange,
606
+ ...policy === void 0 ? {} : { policy },
607
+ ...idFactory === void 0 ? {} : { idFactory }
608
+ });
609
+ const [newPageQuestionId, setNewPageQuestionId] = (0, import_react2.useState)("");
610
+ const [newLocale, setNewLocale] = (0, import_react2.useState)("");
611
+ const [editingLocale, setEditingLocale] = (0, import_react2.useState)("");
612
+ const [isTranslating, setIsTranslating] = (0, import_react2.useState)(false);
613
+ const [translationError, setTranslationError] = (0, import_react2.useState)(null);
223
614
  const translate = (key, params = {}) => {
224
615
  const translated = translator?.translate(key, locale, params);
225
616
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
226
617
  };
227
618
  const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
228
- const updateField = (fieldId, update) => {
229
- emitSchema({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? update(field) : field) });
230
- };
619
+ const updateField = headless.updateField;
231
620
  const changeType = (fieldId, type) => {
232
621
  emitSchema({
233
622
  ...schema,
@@ -243,10 +632,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
243
632
  })
244
633
  });
245
634
  };
246
- const removeField = (fieldId) => {
247
- if (schema.fields.length === 1) return;
248
- emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
249
- };
635
+ const removeField = headless.removeField;
250
636
  const moveField = (index, offset) => {
251
637
  const target = index + offset;
252
638
  if (target < 0 || target >= schema.fields.length) return;
@@ -258,21 +644,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
258
644
  fields[target] = current;
259
645
  emitSchema({ ...schema, fields });
260
646
  };
261
- const addField = () => {
262
- const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
263
- const nextSchema = {
264
- ...schema,
265
- fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
266
- };
267
- emitSchema(
268
- schema.pages === void 0 ? nextSchema : {
269
- ...nextSchema,
270
- pages: schema.pages.map(
271
- (page, index) => index === (schema.pages?.length ?? 0) - 1 ? { ...page, questionIds: [...page.questionIds, id] } : page
272
- )
273
- }
274
- );
275
- };
647
+ const addField = () => headless.addField("text");
276
648
  const enablePages = () => {
277
649
  if (schema.pages !== void 0) return;
278
650
  emitSchema({
@@ -370,7 +742,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
370
742
  setIsTranslating(true);
371
743
  setTranslationError(null);
372
744
  try {
373
- onChange(await (0, import_core.populateSchemaTranslations)(schema, [editingLocale], translationAdapter));
745
+ const populated = await (0, import_core2.populateSchemaTranslations)(schema, [editingLocale], translationAdapter, {
746
+ overwrite: "all"
747
+ });
748
+ onChange(populated.schema);
374
749
  } catch (cause) {
375
750
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
376
751
  } finally {
@@ -719,7 +1094,9 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
719
1094
  {
720
1095
  value: field.type,
721
1096
  onChange: (event) => changeType(field.id, event.currentTarget.value),
722
- children: FIELD_TYPES.map((type) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
1097
+ children: FIELD_TYPES.filter(
1098
+ (type) => policy?.allowedFieldTypes === void 0 || policy.allowedFieldTypes.includes(type)
1099
+ ).map((type) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
723
1100
  }
724
1101
  )
725
1102
  ] }),
@@ -892,13 +1269,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
892
1269
  {
893
1270
  type: "button",
894
1271
  disabled: field.options.length === 1,
895
- onClick: () => updateField(
896
- field.id,
897
- (current) => "options" in current ? {
898
- ...current,
899
- options: current.options.filter((_item, itemIndex) => itemIndex !== optionIndex)
900
- } : current
901
- ),
1272
+ onClick: () => headless.removeOption(field.id, option.id),
902
1273
  children: translate("builder.remove")
903
1274
  }
904
1275
  )
@@ -907,22 +1278,8 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
907
1278
  "button",
908
1279
  {
909
1280
  type: "button",
910
- onClick: () => updateField(
911
- field.id,
912
- (current) => "options" in current ? (() => {
913
- const id = createUniqueId("opt", new Set(current.options.map((option) => option.id)));
914
- return {
915
- ...current,
916
- options: [
917
- ...current.options,
918
- {
919
- id,
920
- label: translate("builder.newOptionLabel", { index: current.options.length + 1 })
921
- }
922
- ]
923
- };
924
- })() : current
925
- ),
1281
+ disabled: policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField,
1282
+ onClick: () => headless.addOption(field.id),
926
1283
  children: translate("builder.addOption")
927
1284
  }
928
1285
  )
@@ -984,15 +1341,24 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
984
1341
  ] })
985
1342
  ] }, field.id);
986
1343
  }) }),
987
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "form-engine-builder__add", type: "button", onClick: addField, children: translate("builder.addQuestion") })
1344
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1345
+ "button",
1346
+ {
1347
+ className: "form-engine-builder__add",
1348
+ type: "button",
1349
+ disabled: policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields,
1350
+ onClick: addField,
1351
+ children: translate("builder.addQuestion")
1352
+ }
1353
+ )
988
1354
  ] });
989
1355
  }
990
1356
 
991
1357
  // src/context.tsx
992
- var import_core2 = require("@form-engine-ts/core");
993
- var import_react2 = require("react");
1358
+ var import_core3 = require("@form-engine-ts/core");
1359
+ var import_react3 = require("react");
994
1360
  var import_jsx_runtime2 = require("react/jsx-runtime");
995
- var FormContext = (0, import_react2.createContext)(null);
1361
+ var FormContext = (0, import_react3.createContext)(null);
996
1362
  function issuesByField(issues) {
997
1363
  const result = {};
998
1364
  for (const issue of issues) result[issue.fieldId] ??= issue;
@@ -1007,30 +1373,30 @@ function FormProvider({
1007
1373
  onSubmit,
1008
1374
  children
1009
1375
  }) {
1010
- const validSchema = (0, import_react2.useMemo)(() => {
1011
- (0, import_core2.assertValidFormSchema)(schema);
1012
- const localized = (0, import_core2.resolveLocalizedSchema)(schema, locale);
1013
- (0, import_core2.assertValidFormSchema)(localized);
1376
+ const validSchema = (0, import_react3.useMemo)(() => {
1377
+ (0, import_core3.assertValidFormSchema)(schema);
1378
+ const localized = (0, import_core3.resolveLocalizedSchema)(schema, locale);
1379
+ (0, import_core3.assertValidFormSchema)(localized);
1014
1380
  return localized;
1015
1381
  }, [locale, schema]);
1016
- const [values, setValues] = (0, import_react2.useState)(() => ({ ...initialValues }));
1017
- const [errors, setErrors] = (0, import_react2.useState)({});
1018
- const [submitStatus, setSubmitStatus] = (0, import_react2.useState)("idle");
1019
- const [submitError, setSubmitError] = (0, import_react2.useState)(null);
1020
- const [validationPageIndex, setValidationPageIndex] = (0, import_react2.useState)(null);
1021
- const visibility = (0, import_react2.useMemo)(() => (0, import_core2.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
1022
- const pageVisibility = (0, import_react2.useMemo)(() => (0, import_core2.calculatePageVisibility)(validSchema, values), [validSchema, values]);
1023
- (0, import_react2.useEffect)(() => {
1382
+ const [values, setValues] = (0, import_react3.useState)(() => ({ ...initialValues }));
1383
+ const [errors, setErrors] = (0, import_react3.useState)({});
1384
+ const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
1385
+ const [submitError, setSubmitError] = (0, import_react3.useState)(null);
1386
+ const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
1387
+ const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
1388
+ const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
1389
+ (0, import_react3.useEffect)(() => {
1024
1390
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1025
1391
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
1026
1392
  }, [validSchema]);
1027
- const setValue = (0, import_react2.useCallback)(
1393
+ const setValue = (0, import_react3.useCallback)(
1028
1394
  (fieldId, value) => {
1029
1395
  setValues((current) => {
1030
1396
  const next = { ...current, [fieldId]: value };
1031
1397
  setErrors((currentErrors) => {
1032
1398
  if (Object.keys(currentErrors).length === 0) return currentErrors;
1033
- const result = validationPageIndex === null ? (0, import_core2.validateAnswers)(validSchema, next) : (0, import_core2.validatePageAnswers)(validSchema, validationPageIndex, next);
1399
+ const result = validationPageIndex === null ? (0, import_core3.validateAnswers)(validSchema, next) : (0, import_core3.validatePageAnswers)(validSchema, validationPageIndex, next);
1034
1400
  return issuesByField(result.issues);
1035
1401
  });
1036
1402
  return next;
@@ -1040,7 +1406,7 @@ function FormProvider({
1040
1406
  },
1041
1407
  [validSchema, validationPageIndex]
1042
1408
  );
1043
- const restoreValues = (0, import_react2.useCallback)(
1409
+ const restoreValues = (0, import_react3.useCallback)(
1044
1410
  (restoredValues) => {
1045
1411
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1046
1412
  setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
@@ -1051,9 +1417,9 @@ function FormProvider({
1051
1417
  },
1052
1418
  [validSchema]
1053
1419
  );
1054
- const validatePage = (0, import_react2.useCallback)(
1420
+ const validatePage = (0, import_react3.useCallback)(
1055
1421
  (pageIndex) => {
1056
- const result = (0, import_core2.validatePageAnswers)(validSchema, pageIndex, values);
1422
+ const result = (0, import_core3.validatePageAnswers)(validSchema, pageIndex, values);
1057
1423
  setErrors(issuesByField(result.issues));
1058
1424
  setValidationPageIndex(result.valid ? null : pageIndex);
1059
1425
  setSubmitStatus("idle");
@@ -1062,42 +1428,51 @@ function FormProvider({
1062
1428
  },
1063
1429
  [validSchema, values]
1064
1430
  );
1065
- const reset = (0, import_react2.useCallback)(() => {
1431
+ const reset = (0, import_react3.useCallback)(() => {
1066
1432
  setValues({ ...initialValues });
1067
1433
  setErrors({});
1068
1434
  setValidationPageIndex(null);
1069
1435
  setSubmitStatus("idle");
1070
1436
  setSubmitError(null);
1071
1437
  }, [initialValues]);
1072
- const submit = (0, import_react2.useCallback)(async () => {
1073
- const validation = (0, import_core2.validateAnswers)(validSchema, values);
1074
- if (!validation.valid) {
1075
- setErrors(issuesByField(validation.issues));
1438
+ const submit = (0, import_react3.useCallback)(
1439
+ async (beforeSubmit) => {
1440
+ const validation = (0, import_core3.validateAnswers)(validSchema, values);
1441
+ if (!validation.valid) {
1442
+ setErrors(issuesByField(validation.issues));
1443
+ setValidationPageIndex(null);
1444
+ setSubmitStatus("error");
1445
+ setSubmitError(null);
1446
+ return { status: "invalid", issues: validation.issues };
1447
+ }
1448
+ setErrors({});
1076
1449
  setValidationPageIndex(null);
1077
- setSubmitStatus("error");
1078
1450
  setSubmitError(null);
1079
- return false;
1080
- }
1081
- setErrors({});
1082
- setValidationPageIndex(null);
1083
- setSubmitStatus("submitting");
1084
- setSubmitError(null);
1085
- try {
1086
- await onSubmit((0, import_core2.selectVisibleAnswers)(validSchema, values));
1087
- if (resetOnSuccess) setValues({ ...initialValues });
1088
- setSubmitStatus("success");
1089
- return true;
1090
- } catch (cause) {
1091
- setSubmitError(cause instanceof Error ? cause : new Error(String(cause)));
1092
- setSubmitStatus("error");
1093
- return false;
1094
- }
1095
- }, [initialValues, onSubmit, resetOnSuccess, validSchema, values]);
1096
- const translate = (0, import_react2.useCallback)(
1451
+ const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
1452
+ try {
1453
+ if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
1454
+ setSubmitStatus("idle");
1455
+ return { status: "cancelled" };
1456
+ }
1457
+ setSubmitStatus("submitting");
1458
+ await onSubmit(visibleValues);
1459
+ if (resetOnSuccess) setValues({ ...initialValues });
1460
+ setSubmitStatus("success");
1461
+ return { status: "success" };
1462
+ } catch (cause) {
1463
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1464
+ setSubmitError(error);
1465
+ setSubmitStatus("error");
1466
+ return { status: "error", error };
1467
+ }
1468
+ },
1469
+ [initialValues, onSubmit, resetOnSuccess, validSchema, values]
1470
+ );
1471
+ const translate = (0, import_react3.useCallback)(
1097
1472
  (key, params) => translator.translate(key, locale, params),
1098
1473
  [locale, translator]
1099
1474
  );
1100
- const contextValue = (0, import_react2.useMemo)(
1475
+ const contextValue = (0, import_react3.useMemo)(
1101
1476
  () => ({
1102
1477
  schema: validSchema,
1103
1478
  locale,
@@ -1137,7 +1512,7 @@ function FormProvider({
1137
1512
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FormContext.Provider, { value: contextValue, children });
1138
1513
  }
1139
1514
  function useForm() {
1140
- const context = (0, import_react2.useContext)(FormContext);
1515
+ const context = (0, import_react3.useContext)(FormContext);
1141
1516
  if (context === null) throw new Error("useForm must be called inside a FormProvider.");
1142
1517
  return context;
1143
1518
  }
@@ -1145,13 +1520,13 @@ function useField(fieldId) {
1145
1520
  const form = useForm();
1146
1521
  const field = form.schema.fields.find((item) => item.id === fieldId);
1147
1522
  if (field === void 0) throw new Error(`Unknown form field: ${fieldId}`);
1148
- const setValue = (0, import_react2.useCallback)((value) => form.setValue(fieldId, value), [fieldId, form]);
1523
+ const setValue = (0, import_react3.useCallback)((value) => form.setValue(fieldId, value), [fieldId, form]);
1149
1524
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
1150
1525
  }
1151
1526
 
1152
1527
  // src/renderer.tsx
1153
- var import_core3 = require("@form-engine-ts/core");
1154
- var import_react3 = require("react");
1528
+ var import_core4 = require("@form-engine-ts/core");
1529
+ var import_react4 = require("react");
1155
1530
  var import_jsx_runtime3 = require("react/jsx-runtime");
1156
1531
  function describedBy(field, error, helpId, errorId) {
1157
1532
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
@@ -1354,36 +1729,39 @@ function parseDraft(serialized) {
1354
1729
  return null;
1355
1730
  }
1356
1731
  }
1357
- function FormRenderer({
1732
+ function ContextFormRenderer({
1358
1733
  components = {},
1359
1734
  className = "",
1360
1735
  successMessageKey,
1361
1736
  errorMessageKey,
1362
- autoSaveKey
1737
+ autoSaveKey,
1738
+ beforeSubmit,
1739
+ onDraftSave,
1740
+ slots = {}
1363
1741
  }) {
1364
1742
  const form = useForm();
1365
- const prefix = (0, import_react3.useId)().replace(/:/g, "");
1366
- const formRef = (0, import_react3.useRef)(null);
1367
- const loadedDraftKey = (0, import_react3.useRef)(null);
1368
- const [draftRestored, setDraftRestored] = (0, import_react3.useState)(false);
1369
- const [currentPageIndex, setCurrentPageIndex] = (0, import_react3.useState)(0);
1370
- const [focusFieldId, setFocusFieldId] = (0, import_react3.useState)(null);
1743
+ const prefix = (0, import_react4.useId)().replace(/:/g, "");
1744
+ const formRef = (0, import_react4.useRef)(null);
1745
+ const loadedDraftKey = (0, import_react4.useRef)(null);
1746
+ const [draftRestored, setDraftRestored] = (0, import_react4.useState)(false);
1747
+ const [currentPageIndex, setCurrentPageIndex] = (0, import_react4.useState)(0);
1748
+ const [focusFieldId, setFocusFieldId] = (0, import_react4.useState)(null);
1371
1749
  const pages = form.schema.pages;
1372
- const visiblePageIndexes = (0, import_react3.useMemo)(
1750
+ const visiblePageIndexes = (0, import_react4.useMemo)(
1373
1751
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1374
1752
  [form.pageVisibility, pages]
1375
1753
  );
1376
1754
  const activePage = pages?.[currentPageIndex];
1377
1755
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
1378
1756
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
1379
- (0, import_react3.useEffect)(() => {
1757
+ (0, import_react4.useEffect)(() => {
1380
1758
  if (pages === void 0 || visiblePageIndexes.length === 0) {
1381
1759
  setCurrentPageIndex(0);
1382
1760
  return;
1383
1761
  }
1384
1762
  if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1385
1763
  }, [currentPageIndex, pages, visiblePageIndexes]);
1386
- (0, import_react3.useEffect)(() => {
1764
+ (0, import_react4.useEffect)(() => {
1387
1765
  if (focusFieldId === null) return;
1388
1766
  const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
1389
1767
  (element) => element.dataset.fieldId === focusFieldId
@@ -1394,7 +1772,7 @@ function FormRenderer({
1394
1772
  setFocusFieldId(null);
1395
1773
  }
1396
1774
  }, [focusFieldId]);
1397
- (0, import_react3.useEffect)(() => {
1775
+ (0, import_react4.useEffect)(() => {
1398
1776
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1399
1777
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
1400
1778
  if (loadedDraftKey.current === loadIdentity) return;
@@ -1406,10 +1784,11 @@ function FormRenderer({
1406
1784
  form.restoreValues(draft.values);
1407
1785
  setDraftRestored(true);
1408
1786
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1409
- (0, import_react3.useEffect)(() => {
1410
- if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1787
+ (0, import_react4.useEffect)(() => {
1411
1788
  if (form.submitStatus === "success") return;
1412
1789
  const timeout = globalThis.setTimeout(() => {
1790
+ onDraftSave?.(form.values);
1791
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1413
1792
  const draft = {
1414
1793
  formId: form.schema.id,
1415
1794
  formVersion: form.schema.version,
@@ -1419,7 +1798,7 @@ function FormRenderer({
1419
1798
  globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1420
1799
  }, 500);
1421
1800
  return () => globalThis.clearTimeout(timeout);
1422
- }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values]);
1801
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values, onDraftSave]);
1423
1802
  const focusFirstIssue = (fieldId) => {
1424
1803
  if (fieldId !== void 0) setFocusFieldId(fieldId);
1425
1804
  };
@@ -1432,27 +1811,39 @@ function FormRenderer({
1432
1811
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1433
1812
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1434
1813
  };
1435
- const handleSubmit = async (event) => {
1436
- event.preventDefault();
1437
- const validation = (0, import_core3.validateAnswers)(form.schema, form.values);
1814
+ const submitValues = async () => {
1815
+ const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
1438
1816
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
1439
- const valid = await form.submit();
1440
- if (!valid) {
1817
+ const result = await form.submit(beforeSubmit);
1818
+ if (result.status === "invalid") {
1441
1819
  const invalidPageIndex = pages?.findIndex(
1442
1820
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1443
1821
  );
1444
1822
  if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1445
1823
  focusFirstIssue(firstInvalidFieldId);
1446
- return;
1824
+ return result;
1447
1825
  }
1826
+ if (result.status !== "success") return result;
1448
1827
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1449
1828
  globalThis.localStorage.removeItem(autoSaveKey);
1450
1829
  setDraftRestored(false);
1451
1830
  }
1452
1831
  setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1832
+ return result;
1453
1833
  };
1834
+ const handleSubmit = (event) => {
1835
+ event.preventDefault();
1836
+ void submitValues();
1837
+ };
1838
+ const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
1839
+ const canPrev = pages !== void 0 && activeVisibleIndex > 0;
1840
+ const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
1841
+ const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: form.isSubmitting, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
1454
1842
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
1455
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("header", { className: "fe-header", children: [
1843
+ slots.renderHeader?.({
1844
+ title: form.schema.title,
1845
+ ...form.schema.description === void 0 ? {} : { description: form.schema.description }
1846
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("header", { className: "fe-header", children: [
1456
1847
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { children: form.schema.title }),
1457
1848
  form.schema.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: form.schema.description }),
1458
1849
  pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-progress", children: [
@@ -1480,42 +1871,122 @@ function FormRenderer({
1480
1871
  activePage?.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1481
1872
  activePage?.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description }),
1482
1873
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true && (fieldIds === void 0 || fieldIds.has(field.id))).map((field) => {
1874
+ const error = form.errors[field.id];
1483
1875
  const props = {
1484
1876
  field,
1485
1877
  value: form.values[field.id],
1486
- error: form.errors[field.id],
1878
+ error,
1487
1879
  setValue: (value) => form.setValue(field.id, value),
1488
1880
  translate: form.translate,
1489
1881
  inputId: `${prefix}-${field.id}`,
1490
1882
  errorId: `${prefix}-${field.id}-error`,
1491
1883
  helpId: `${prefix}-${field.id}-help`
1492
1884
  };
1885
+ if (slots.renderField !== void 0) {
1886
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react4.Fragment, { children: slots.renderField({
1887
+ question: field,
1888
+ value: form.values[field.id],
1889
+ onChange: (value) => {
1890
+ if (isFormValue(value)) form.setValue(field.id, value);
1891
+ },
1892
+ ...error === void 0 ? {} : { error }
1893
+ }) }, field.id);
1894
+ }
1493
1895
  const Component = components[field.type];
1494
1896
  return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DefaultField, { ...props }, field.id) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
1495
1897
  }) }),
1496
- pages === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "form-step-navigation", children: [
1497
- activeVisibleIndex > 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1498
- "button",
1499
- {
1500
- className: "btn-prev",
1501
- type: "button",
1502
- onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1503
- children: form.translate("form.back")
1504
- }
1505
- ) : null,
1506
- activeVisibleIndex < visiblePageIndexes.length - 1 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") })
1898
+ validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
1899
+ validationIssues.length,
1900
+ " validation error",
1901
+ validationIssues.length === 1 ? "" : "s",
1902
+ "."
1903
+ ] }),
1904
+ pages === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1905
+ slots.renderNavigation?.({
1906
+ currentPage: 0,
1907
+ totalPages: 1,
1908
+ canPrev: false,
1909
+ canNext: false,
1910
+ onPrev: () => void 0,
1911
+ onNext: () => void 0
1912
+ }),
1913
+ renderSubmitButton()
1914
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "form-step-navigation", children: [
1915
+ slots.renderNavigation?.({
1916
+ currentPage: activeVisibleIndex,
1917
+ totalPages: visiblePageIndexes.length,
1918
+ canPrev,
1919
+ canNext,
1920
+ onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1921
+ onNext: handleNext
1922
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1923
+ canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1924
+ "button",
1925
+ {
1926
+ className: "btn-prev",
1927
+ type: "button",
1928
+ onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1929
+ children: form.translate("form.back")
1930
+ }
1931
+ ) : null,
1932
+ canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
1933
+ ] }),
1934
+ canNext ? null : renderSubmitButton()
1507
1935
  ] }),
1508
1936
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-status", "aria-live": "polite", children: [
1509
- form.submitStatus === "success" && successMessageKey !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "status", children: form.translate(successMessageKey) }) : null,
1937
+ form.submitStatus === "success" ? slots.renderCompletion?.({
1938
+ message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
1939
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
1510
1940
  form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
1511
1941
  ] })
1512
1942
  ] });
1513
1943
  }
1944
+ var RENDERER_MESSAGES = {
1945
+ "form.submit": "Submit",
1946
+ "form.back": "Back",
1947
+ "form.next": "Next",
1948
+ "form.step": "Step {{current}} / {{total}}",
1949
+ "form.draftRestored": "Draft restored",
1950
+ "validation.required": "This field is required."
1951
+ };
1952
+ var defaultRendererTranslator = {
1953
+ translate(key, _locale, params = {}) {
1954
+ return (RENDERER_MESSAGES[key] ?? key).replace(
1955
+ /\{\{(\w+)\}\}/g,
1956
+ (token, name) => Object.hasOwn(params, name) ? String(params[name]) : token
1957
+ );
1958
+ }
1959
+ };
1960
+ function FormRenderer(props) {
1961
+ if (!("schema" in props)) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ContextFormRenderer, { ...props });
1962
+ const {
1963
+ schema,
1964
+ locale = schema.defaultLocale ?? "en",
1965
+ translator = defaultRendererTranslator,
1966
+ initialValues,
1967
+ resetOnSuccess,
1968
+ onSubmit,
1969
+ ...rendererProps
1970
+ } = props;
1971
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1972
+ FormProvider,
1973
+ {
1974
+ schema,
1975
+ locale,
1976
+ translator,
1977
+ onSubmit,
1978
+ ...initialValues === void 0 ? {} : { initialValues },
1979
+ ...resetOnSuccess === void 0 ? {} : { resetOnSuccess },
1980
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ContextFormRenderer, { ...rendererProps })
1981
+ }
1982
+ );
1983
+ }
1514
1984
  // Annotate the CommonJS export names for ESM import in node:
1515
1985
  0 && (module.exports = {
1516
1986
  FormBuilder,
1517
1987
  FormProvider,
1518
1988
  FormRenderer,
1519
1989
  useField,
1520
- useForm
1990
+ useForm,
1991
+ useFormBuilder
1521
1992
  });