@form-engine-ts/react 1.0.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,12 +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");
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
33
411
  var import_jsx_runtime = require("react/jsx-runtime");
34
412
  var FIELD_TYPES = [
35
413
  "text",
@@ -67,6 +445,22 @@ var BUILDER_DEFAULTS = {
67
445
  "builder.conditionTrue": "true",
68
446
  "builder.conditionFalse": "false",
69
447
  "builder.addQuestion": "Add question",
448
+ "builder.pages": "Page manager",
449
+ "builder.enablePages": "Enable multi-step pages",
450
+ "builder.addPage": "Add page",
451
+ "builder.newPage": "New page",
452
+ "builder.pageTitle": "Page title",
453
+ "builder.pageDescription": "Page description",
454
+ "builder.pageQuestion": "Question to move to the new page",
455
+ "builder.questionPage": "Page",
456
+ "builder.pageCondition": "Page display condition",
457
+ "builder.localization": "Localization",
458
+ "builder.defaultLocale": "Default locale",
459
+ "builder.supportedLocales": "Supported locales",
460
+ "builder.addLocale": "Add locale",
461
+ "builder.editLocale": "Edit locale",
462
+ "builder.autoTranslate": "Translate all text",
463
+ "builder.translationUnavailable": "Provide an async translation adapter to enable automatic translation.",
70
464
  "builder.fieldType.text": "Text",
71
465
  "builder.fieldType.textarea": "Textarea",
72
466
  "builder.fieldType.number": "Number",
@@ -137,16 +531,23 @@ function withoutDisplayCondition(field) {
137
531
  return rest;
138
532
  }
139
533
  function sanitizeBuilderSchema(schema) {
140
- const sanitized = (0, import_core.sanitizeSchema)(schema);
534
+ const sanitized = (0, import_core2.sanitizeSchema)(schema);
141
535
  const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
536
+ const fields = sanitized.fields.map((field, index) => {
537
+ const sourceId = field.displayCondition?.questionId;
538
+ if (sourceId === void 0) return field;
539
+ const sourceIndex = indexById.get(sourceId);
540
+ return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
541
+ });
142
542
  return {
143
543
  ...sanitized,
144
- fields: sanitized.fields.map((field, index) => {
145
- const sourceId = field.displayCondition?.questionId;
146
- if (sourceId === void 0) return field;
147
- const sourceIndex = indexById.get(sourceId);
148
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
149
- })
544
+ fields,
545
+ ...sanitized.pages === void 0 ? {} : {
546
+ pages: sanitized.pages.map((page) => ({
547
+ ...page,
548
+ questionIds: fields.filter((field) => page.questionIds.includes(field.id)).map((field) => field.id)
549
+ }))
550
+ }
150
551
  };
151
552
  }
152
553
  function conditionWithValue(questionId, operator, value) {
@@ -190,15 +591,32 @@ function ConditionValueEditor({
190
591
  }
191
592
  );
192
593
  }
193
- function FormBuilder({ schema, onChange, locale = "en", translator }) {
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);
194
614
  const translate = (key, params = {}) => {
195
615
  const translated = translator?.translate(key, locale, params);
196
616
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
197
617
  };
198
618
  const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
199
- const updateField = (fieldId, update) => {
200
- emitSchema({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? update(field) : field) });
201
- };
619
+ const updateField = headless.updateField;
202
620
  const changeType = (fieldId, type) => {
203
621
  emitSchema({
204
622
  ...schema,
@@ -214,10 +632,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
214
632
  })
215
633
  });
216
634
  };
217
- const removeField = (fieldId) => {
218
- if (schema.fields.length === 1) return;
219
- emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
220
- };
635
+ const removeField = headless.removeField;
221
636
  const moveField = (index, offset) => {
222
637
  const target = index + offset;
223
638
  if (target < 0 || target >= schema.fields.length) return;
@@ -229,14 +644,396 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
229
644
  fields[target] = current;
230
645
  emitSchema({ ...schema, fields });
231
646
  };
232
- const addField = () => {
233
- const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
647
+ const addField = () => headless.addField("text");
648
+ const enablePages = () => {
649
+ if (schema.pages !== void 0) return;
234
650
  emitSchema({
235
651
  ...schema,
236
- fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
652
+ pages: [
653
+ {
654
+ id: createUniqueId("page", /* @__PURE__ */ new Set()),
655
+ title: translate("builder.newPage"),
656
+ questionIds: schema.fields.map((field) => field.id)
657
+ }
658
+ ]
659
+ });
660
+ };
661
+ const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
662
+ const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
663
+ const addPage = () => {
664
+ if (schema.pages === void 0) {
665
+ enablePages();
666
+ return;
667
+ }
668
+ const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
669
+ if (questionId === void 0) return;
670
+ const pageId = createUniqueId("page", new Set(schema.pages.map((page) => page.id)));
671
+ emitSchema({
672
+ ...schema,
673
+ pages: [
674
+ ...schema.pages.map((page) => ({
675
+ ...page,
676
+ questionIds: page.questionIds.filter((id) => id !== questionId)
677
+ })),
678
+ { id: pageId, title: translate("builder.newPage"), questionIds: [questionId] }
679
+ ]
680
+ });
681
+ setNewPageQuestionId("");
682
+ };
683
+ const removePage = (pageIndex) => {
684
+ if (schema.pages === void 0) return;
685
+ const removed = schema.pages[pageIndex];
686
+ if (removed === void 0) return;
687
+ if (schema.pages.length === 1) {
688
+ const { pages: _pages, ...singlePage } = schema;
689
+ emitSchema(singlePage);
690
+ return;
691
+ }
692
+ const targetIndex = pageIndex === 0 ? 1 : pageIndex - 1;
693
+ emitSchema({
694
+ ...schema,
695
+ pages: schema.pages.map(
696
+ (page, index) => index === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
697
+ ).filter((_page, index) => index !== pageIndex)
698
+ });
699
+ };
700
+ const movePage = (pageIndex, offset) => {
701
+ if (schema.pages === void 0) return;
702
+ const target = pageIndex + offset;
703
+ if (target < 0 || target >= schema.pages.length) return;
704
+ const pages = [...schema.pages];
705
+ const current = pages[pageIndex];
706
+ const other = pages[target];
707
+ if (current === void 0 || other === void 0) return;
708
+ pages[pageIndex] = other;
709
+ pages[target] = current;
710
+ emitSchema({ ...schema, pages });
711
+ };
712
+ const updatePage = (pageId, update) => {
713
+ if (schema.pages === void 0) return;
714
+ emitSchema({ ...schema, pages: schema.pages.map((page) => page.id === pageId ? update(page) : page) });
715
+ };
716
+ const assignFieldToPage = (fieldId, pageId) => {
717
+ if (schema.pages === void 0) return;
718
+ emitSchema({
719
+ ...schema,
720
+ pages: schema.pages.map((page) => ({
721
+ ...page,
722
+ questionIds: page.id === pageId ? schema.fields.filter((field) => page.questionIds.includes(field.id) || field.id === fieldId).map((field) => field.id) : page.questionIds.filter((id) => id !== fieldId)
723
+ })).filter((page) => page.questionIds.length > 0)
724
+ });
725
+ };
726
+ const addLocale = () => {
727
+ const normalized = newLocale.trim();
728
+ if (normalized.length === 0) return;
729
+ const supportedLocales = [
730
+ .../* @__PURE__ */ new Set([
731
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
732
+ ...schema.supportedLocales ?? [],
733
+ normalized
734
+ ])
735
+ ];
736
+ emitSchema({ ...schema, supportedLocales });
737
+ setEditingLocale(normalized);
738
+ setNewLocale("");
739
+ };
740
+ const translateAll = async () => {
741
+ if (translationAdapter === void 0 || editingLocale.length === 0) return;
742
+ setIsTranslating(true);
743
+ setTranslationError(null);
744
+ try {
745
+ const populated = await (0, import_core2.populateSchemaTranslations)(schema, [editingLocale], translationAdapter, {
746
+ overwrite: "all"
747
+ });
748
+ onChange(populated.schema);
749
+ } catch (cause) {
750
+ setTranslationError(cause instanceof Error ? cause.message : String(cause));
751
+ } finally {
752
+ setIsTranslating(false);
753
+ }
754
+ };
755
+ const updateFormTranslation = (key, value) => {
756
+ if (editingLocale.length === 0) return;
757
+ const current = schema.translations?.[editingLocale];
758
+ const next = key === "title" ? value.length === 0 ? { description: current?.description } : { ...current, title: value } : value.length === 0 ? { title: current?.title } : { ...current, description: value };
759
+ emitSchema({
760
+ ...schema,
761
+ translations: {
762
+ ...schema.translations,
763
+ [editingLocale]: {
764
+ ...next.title === void 0 ? {} : { title: next.title },
765
+ ...next.description === void 0 ? {} : { description: next.description }
766
+ }
767
+ }
237
768
  });
238
769
  };
239
770
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
771
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
772
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-pages-heading", children: translate("builder.pages") }),
773
+ schema.pages === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: enablePages, children: translate("builder.enablePages") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
774
+ schema.pages.map((page, pageIndex) => {
775
+ const priorQuestionIds = new Set(schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds));
776
+ const availableSources = schema.fields.filter((field) => priorQuestionIds.has(field.id));
777
+ const source = schema.fields.find((field) => field.id === page.displayCondition?.questionId);
778
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("fieldset", { className: "form-engine-builder__page", children: [
779
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("legend", { children: page.title ?? `${translate("builder.newPage")} ${pageIndex + 1}` }),
780
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__toolbar", children: [
781
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
782
+ "button",
783
+ {
784
+ type: "button",
785
+ disabled: pageIndex === 0,
786
+ "aria-label": translate("builder.moveUp", { title: page.title ?? page.id }),
787
+ onClick: () => movePage(pageIndex, -1),
788
+ children: "\u2191"
789
+ }
790
+ ),
791
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
792
+ "button",
793
+ {
794
+ type: "button",
795
+ disabled: pageIndex === (schema.pages?.length ?? 0) - 1,
796
+ "aria-label": translate("builder.moveDown", { title: page.title ?? page.id }),
797
+ onClick: () => movePage(pageIndex, 1),
798
+ children: "\u2193"
799
+ }
800
+ ),
801
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: () => removePage(pageIndex), children: translate("builder.deleteAction") })
802
+ ] }),
803
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
804
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
805
+ translate("builder.pageTitle"),
806
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
807
+ "input",
808
+ {
809
+ value: page.title ?? "",
810
+ onChange: (event) => {
811
+ const value = event.currentTarget.value;
812
+ updatePage(page.id, (current) => {
813
+ if (value.length > 0) return { ...current, title: value };
814
+ const { title: _title, ...withoutTitle } = current;
815
+ return withoutTitle;
816
+ });
817
+ }
818
+ }
819
+ )
820
+ ] }),
821
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
822
+ translate("builder.pageDescription"),
823
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
824
+ "input",
825
+ {
826
+ value: page.description ?? "",
827
+ onChange: (event) => {
828
+ const value = event.currentTarget.value;
829
+ updatePage(page.id, (current) => {
830
+ if (value.length > 0) return { ...current, description: value };
831
+ const { description: _description, ...withoutDescription } = current;
832
+ return withoutDescription;
833
+ });
834
+ }
835
+ }
836
+ )
837
+ ] })
838
+ ] }),
839
+ editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
840
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: editingLocale }),
841
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
842
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
843
+ translate("builder.pageTitle"),
844
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
845
+ "input",
846
+ {
847
+ value: page.translations?.[editingLocale]?.title ?? "",
848
+ onChange: (event) => {
849
+ const value = event.currentTarget.value;
850
+ updatePage(page.id, (current) => ({
851
+ ...current,
852
+ translations: {
853
+ ...current.translations,
854
+ [editingLocale]: {
855
+ ...value.length === 0 ? {} : { title: value },
856
+ ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
857
+ }
858
+ }
859
+ }));
860
+ }
861
+ }
862
+ )
863
+ ] }),
864
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
865
+ translate("builder.pageDescription"),
866
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
867
+ "input",
868
+ {
869
+ value: page.translations?.[editingLocale]?.description ?? "",
870
+ onChange: (event) => {
871
+ const value = event.currentTarget.value;
872
+ updatePage(page.id, (current) => ({
873
+ ...current,
874
+ translations: {
875
+ ...current.translations,
876
+ [editingLocale]: {
877
+ ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
878
+ ...value.length === 0 ? {} : { description: value }
879
+ }
880
+ }
881
+ }));
882
+ }
883
+ }
884
+ )
885
+ ] })
886
+ ] })
887
+ ] }),
888
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__condition", children: [
889
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
890
+ translate("builder.pageCondition"),
891
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
892
+ "select",
893
+ {
894
+ value: page.displayCondition?.questionId ?? "",
895
+ onChange: (event) => {
896
+ const selected = schema.fields.find((field) => field.id === event.currentTarget.value);
897
+ updatePage(page.id, (current) => {
898
+ if (selected === void 0) {
899
+ const { displayCondition: _condition, ...withoutCondition } = current;
900
+ return withoutCondition;
901
+ }
902
+ return {
903
+ ...current,
904
+ displayCondition: conditionWithValue(
905
+ selected.id,
906
+ conditionOperators(selected)[0] ?? "not_empty",
907
+ defaultConditionValue(selected)
908
+ )
909
+ };
910
+ });
911
+ },
912
+ children: [
913
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: translate("builder.alwaysVisible") }),
914
+ availableSources.map((field) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: field.id, children: field.title }, field.id))
915
+ ]
916
+ }
917
+ )
918
+ ] }),
919
+ page.displayCondition !== void 0 && source !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
920
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
921
+ "select",
922
+ {
923
+ "aria-label": translate("builder.conditionOperator"),
924
+ value: page.displayCondition.operator,
925
+ onChange: (event) => {
926
+ const operator = event.currentTarget.value;
927
+ updatePage(page.id, (current) => ({
928
+ ...current,
929
+ displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
930
+ }));
931
+ },
932
+ children: conditionOperators(source).map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
933
+ }
934
+ ),
935
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
936
+ ConditionValueEditor,
937
+ {
938
+ source,
939
+ condition: page.displayCondition,
940
+ onChange: (condition) => updatePage(page.id, (current) => ({ ...current, displayCondition: condition })),
941
+ translate
942
+ }
943
+ )
944
+ ] }) : null
945
+ ] })
946
+ ] }, page.id);
947
+ }),
948
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__page-add", children: [
949
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
950
+ translate("builder.pageQuestion"),
951
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
952
+ "select",
953
+ {
954
+ value: newPageQuestionId,
955
+ disabled: movablePageQuestions.length === 0,
956
+ onChange: (event) => setNewPageQuestionId(event.currentTarget.value),
957
+ children: [
958
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: "\u2014" }),
959
+ schema.fields.filter((field) => movablePageQuestions.includes(field.id)).map((field) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: field.id, children: field.title }, field.id))
960
+ ]
961
+ }
962
+ )
963
+ ] }),
964
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", disabled: movablePageQuestions.length === 0, onClick: addPage, children: translate("builder.addPage") })
965
+ ] })
966
+ ] })
967
+ ] }),
968
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
969
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
970
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
971
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
972
+ translate("builder.defaultLocale"),
973
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
974
+ "input",
975
+ {
976
+ value: schema.defaultLocale ?? "",
977
+ onChange: (event) => {
978
+ const value = event.currentTarget.value.trim();
979
+ emitSchema(
980
+ value.length === 0 ? schema : {
981
+ ...schema,
982
+ defaultLocale: value,
983
+ supportedLocales: [.../* @__PURE__ */ new Set([value, ...schema.supportedLocales ?? []])]
984
+ }
985
+ );
986
+ }
987
+ }
988
+ )
989
+ ] }),
990
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
991
+ translate("builder.addLocale"),
992
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", { value: newLocale, onChange: (event) => setNewLocale(event.currentTarget.value) })
993
+ ] }),
994
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", onClick: addLocale, children: translate("builder.addLocale") }),
995
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
996
+ translate("builder.editLocale"),
997
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("select", { value: editingLocale, onChange: (event) => setEditingLocale(event.currentTarget.value), children: [
998
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: "\u2014" }),
999
+ (schema.supportedLocales ?? []).filter((item) => item !== schema.defaultLocale).map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: item, children: item }, item))
1000
+ ] })
1001
+ ] }),
1002
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1003
+ "button",
1004
+ {
1005
+ type: "button",
1006
+ disabled: translationAdapter === void 0 || editingLocale.length === 0 || isTranslating,
1007
+ onClick: () => void translateAll(),
1008
+ children: translate("builder.autoTranslate")
1009
+ }
1010
+ )
1011
+ ] }),
1012
+ translationAdapter === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: translate("builder.translationUnavailable") }) : null,
1013
+ translationError === null ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "form-engine-builder__error", children: translationError }),
1014
+ editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
1015
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1016
+ translate("builder.questionTitle"),
1017
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1018
+ "input",
1019
+ {
1020
+ value: schema.translations?.[editingLocale]?.title ?? "",
1021
+ onChange: (event) => updateFormTranslation("title", event.currentTarget.value)
1022
+ }
1023
+ )
1024
+ ] }),
1025
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1026
+ translate("builder.pageDescription"),
1027
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1028
+ "input",
1029
+ {
1030
+ value: schema.translations?.[editingLocale]?.description ?? "",
1031
+ onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
1032
+ }
1033
+ )
1034
+ ] })
1035
+ ] })
1036
+ ] }),
240
1037
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "form-engine-builder__list", children: schema.fields.map((field, index) => {
241
1038
  const condition = field.displayCondition;
242
1039
  const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
@@ -297,7 +1094,9 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
297
1094
  {
298
1095
  value: field.type,
299
1096
  onChange: (event) => changeType(field.id, event.currentTarget.value),
300
- 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))
301
1100
  }
302
1101
  )
303
1102
  ] }),
@@ -313,6 +1112,98 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
313
1112
  translate("builder.required")
314
1113
  ] })
315
1114
  ] }),
1115
+ schema.pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1116
+ translate("builder.questionPage"),
1117
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1118
+ "select",
1119
+ {
1120
+ value: pageForField(field.id)?.id ?? "",
1121
+ onChange: (event) => assignFieldToPage(field.id, event.currentTarget.value),
1122
+ children: schema.pages.map((page, pageIndex) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: page.id, children: page.title ?? `${translate("builder.newPage")} ${pageIndex + 1}` }, page.id))
1123
+ }
1124
+ )
1125
+ ] }),
1126
+ editingLocale.length === 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__translation-editor", children: [
1127
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: editingLocale }),
1128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
1129
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1130
+ translate("builder.questionTitle"),
1131
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1132
+ "input",
1133
+ {
1134
+ value: field.translations?.[editingLocale]?.title ?? "",
1135
+ onChange: (event) => {
1136
+ const value = event.currentTarget.value;
1137
+ updateField(field.id, (current) => ({
1138
+ ...current,
1139
+ translations: {
1140
+ ...current.translations,
1141
+ [editingLocale]: {
1142
+ ...value.length === 0 ? {} : { title: value },
1143
+ ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
1144
+ }
1145
+ }
1146
+ }));
1147
+ }
1148
+ }
1149
+ )
1150
+ ] }),
1151
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1152
+ translate("builder.pageDescription"),
1153
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1154
+ "input",
1155
+ {
1156
+ value: field.translations?.[editingLocale]?.description ?? "",
1157
+ onChange: (event) => {
1158
+ const value = event.currentTarget.value;
1159
+ updateField(field.id, (current) => ({
1160
+ ...current,
1161
+ translations: {
1162
+ ...current.translations,
1163
+ [editingLocale]: {
1164
+ ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
1165
+ ...value.length === 0 ? {} : { description: value }
1166
+ }
1167
+ }
1168
+ }));
1169
+ }
1170
+ }
1171
+ )
1172
+ ] })
1173
+ ] }),
1174
+ "options" in field ? field.options.map((option, optionIndex) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1175
+ translate("builder.optionLabel", { index: optionIndex + 1 }),
1176
+ " (",
1177
+ editingLocale,
1178
+ ")",
1179
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1180
+ "input",
1181
+ {
1182
+ value: option.translations?.[editingLocale] ?? "",
1183
+ onChange: (event) => {
1184
+ const value = event.currentTarget.value;
1185
+ updateField(field.id, (current) => {
1186
+ if (!("options" in current)) return current;
1187
+ return {
1188
+ ...current,
1189
+ options: current.options.map(
1190
+ (candidate) => candidate.id === option.id ? {
1191
+ ...candidate,
1192
+ translations: Object.fromEntries([
1193
+ ...Object.entries(candidate.translations ?? {}).filter(
1194
+ ([localeKey]) => localeKey !== editingLocale
1195
+ ),
1196
+ ...value.length === 0 ? [] : [[editingLocale, value]]
1197
+ ])
1198
+ } : candidate
1199
+ )
1200
+ };
1201
+ });
1202
+ }
1203
+ }
1204
+ )
1205
+ ] }, option.id)) : null
1206
+ ] }),
316
1207
  field.type === "rating" ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
317
1208
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
318
1209
  translate("builder.minimum"),
@@ -378,13 +1269,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
378
1269
  {
379
1270
  type: "button",
380
1271
  disabled: field.options.length === 1,
381
- onClick: () => updateField(
382
- field.id,
383
- (current) => "options" in current ? {
384
- ...current,
385
- options: current.options.filter((_item, itemIndex) => itemIndex !== optionIndex)
386
- } : current
387
- ),
1272
+ onClick: () => headless.removeOption(field.id, option.id),
388
1273
  children: translate("builder.remove")
389
1274
  }
390
1275
  )
@@ -393,22 +1278,8 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
393
1278
  "button",
394
1279
  {
395
1280
  type: "button",
396
- onClick: () => updateField(
397
- field.id,
398
- (current) => "options" in current ? (() => {
399
- const id = createUniqueId("opt", new Set(current.options.map((option) => option.id)));
400
- return {
401
- ...current,
402
- options: [
403
- ...current.options,
404
- {
405
- id,
406
- label: translate("builder.newOptionLabel", { index: current.options.length + 1 })
407
- }
408
- ]
409
- };
410
- })() : current
411
- ),
1281
+ disabled: policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField,
1282
+ onClick: () => headless.addOption(field.id),
412
1283
  children: translate("builder.addOption")
413
1284
  }
414
1285
  )
@@ -470,15 +1341,24 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
470
1341
  ] })
471
1342
  ] }, field.id);
472
1343
  }) }),
473
- /* @__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
+ )
474
1354
  ] });
475
1355
  }
476
1356
 
477
1357
  // src/context.tsx
478
- var import_core2 = require("@form-engine-ts/core");
479
- var import_react = require("react");
1358
+ var import_core3 = require("@form-engine-ts/core");
1359
+ var import_react3 = require("react");
480
1360
  var import_jsx_runtime2 = require("react/jsx-runtime");
481
- var FormContext = (0, import_react.createContext)(null);
1361
+ var FormContext = (0, import_react3.createContext)(null);
482
1362
  function issuesByField(issues) {
483
1363
  const result = {};
484
1364
  for (const issue of issues) result[issue.fieldId] ??= issue;
@@ -493,26 +1373,30 @@ function FormProvider({
493
1373
  onSubmit,
494
1374
  children
495
1375
  }) {
496
- const validSchema = (0, import_react.useMemo)(() => {
497
- (0, import_core2.assertValidFormSchema)(schema);
498
- return schema;
499
- }, [schema]);
500
- const [values, setValues] = (0, import_react.useState)(() => ({ ...initialValues }));
501
- const [errors, setErrors] = (0, import_react.useState)({});
502
- const [submitStatus, setSubmitStatus] = (0, import_react.useState)("idle");
503
- const [submitError, setSubmitError] = (0, import_react.useState)(null);
504
- const visibility = (0, import_react.useMemo)(() => (0, import_core2.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
505
- (0, import_react.useEffect)(() => {
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);
1380
+ return localized;
1381
+ }, [locale, schema]);
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)(() => {
506
1390
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
507
1391
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
508
1392
  }, [validSchema]);
509
- const setValue = (0, import_react.useCallback)(
1393
+ const setValue = (0, import_react3.useCallback)(
510
1394
  (fieldId, value) => {
511
1395
  setValues((current) => {
512
1396
  const next = { ...current, [fieldId]: value };
513
1397
  setErrors((currentErrors) => {
514
1398
  if (Object.keys(currentErrors).length === 0) return currentErrors;
515
- const result = (0, import_core2.validateAnswers)(validSchema, next);
1399
+ const result = validationPageIndex === null ? (0, import_core3.validateAnswers)(validSchema, next) : (0, import_core3.validatePageAnswers)(validSchema, validationPageIndex, next);
516
1400
  return issuesByField(result.issues);
517
1401
  });
518
1402
  return next;
@@ -520,52 +1404,89 @@ function FormProvider({
520
1404
  setSubmitStatus((current) => current === "success" || current === "error" ? "idle" : current);
521
1405
  setSubmitError(null);
522
1406
  },
1407
+ [validSchema, validationPageIndex]
1408
+ );
1409
+ const restoreValues = (0, import_react3.useCallback)(
1410
+ (restoredValues) => {
1411
+ const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1412
+ setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
1413
+ setErrors({});
1414
+ setValidationPageIndex(null);
1415
+ setSubmitStatus("idle");
1416
+ setSubmitError(null);
1417
+ },
523
1418
  [validSchema]
524
1419
  );
525
- const reset = (0, import_react.useCallback)(() => {
1420
+ const validatePage = (0, import_react3.useCallback)(
1421
+ (pageIndex) => {
1422
+ const result = (0, import_core3.validatePageAnswers)(validSchema, pageIndex, values);
1423
+ setErrors(issuesByField(result.issues));
1424
+ setValidationPageIndex(result.valid ? null : pageIndex);
1425
+ setSubmitStatus("idle");
1426
+ setSubmitError(null);
1427
+ return result;
1428
+ },
1429
+ [validSchema, values]
1430
+ );
1431
+ const reset = (0, import_react3.useCallback)(() => {
526
1432
  setValues({ ...initialValues });
527
1433
  setErrors({});
1434
+ setValidationPageIndex(null);
528
1435
  setSubmitStatus("idle");
529
1436
  setSubmitError(null);
530
1437
  }, [initialValues]);
531
- const submit = (0, import_react.useCallback)(async () => {
532
- const validation = (0, import_core2.validateAnswers)(validSchema, values);
533
- if (!validation.valid) {
534
- setErrors(issuesByField(validation.issues));
535
- setSubmitStatus("error");
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({});
1449
+ setValidationPageIndex(null);
536
1450
  setSubmitError(null);
537
- return false;
538
- }
539
- setErrors({});
540
- setSubmitStatus("submitting");
541
- setSubmitError(null);
542
- try {
543
- await onSubmit((0, import_core2.selectVisibleAnswers)(validSchema, values));
544
- if (resetOnSuccess) setValues({ ...initialValues });
545
- setSubmitStatus("success");
546
- return true;
547
- } catch (cause) {
548
- setSubmitError(cause instanceof Error ? cause : new Error(String(cause)));
549
- setSubmitStatus("error");
550
- return false;
551
- }
552
- }, [initialValues, onSubmit, resetOnSuccess, validSchema, values]);
553
- const translate = (0, import_react.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)(
554
1472
  (key, params) => translator.translate(key, locale, params),
555
1473
  [locale, translator]
556
1474
  );
557
- const contextValue = (0, import_react.useMemo)(
1475
+ const contextValue = (0, import_react3.useMemo)(
558
1476
  () => ({
559
1477
  schema: validSchema,
560
1478
  locale,
561
1479
  translator,
562
1480
  values,
563
1481
  visibility,
1482
+ pageVisibility,
564
1483
  errors,
565
1484
  submitStatus,
566
1485
  submitError,
567
1486
  isSubmitting: submitStatus === "submitting",
568
1487
  setValue,
1488
+ restoreValues,
1489
+ validatePage,
569
1490
  reset,
570
1491
  submit,
571
1492
  translate
@@ -573,13 +1494,16 @@ function FormProvider({
573
1494
  [
574
1495
  errors,
575
1496
  locale,
1497
+ pageVisibility,
576
1498
  reset,
1499
+ restoreValues,
577
1500
  setValue,
578
1501
  submit,
579
1502
  submitError,
580
1503
  submitStatus,
581
1504
  translate,
582
1505
  translator,
1506
+ validatePage,
583
1507
  validSchema,
584
1508
  values,
585
1509
  visibility
@@ -588,7 +1512,7 @@ function FormProvider({
588
1512
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FormContext.Provider, { value: contextValue, children });
589
1513
  }
590
1514
  function useForm() {
591
- const context = (0, import_react.useContext)(FormContext);
1515
+ const context = (0, import_react3.useContext)(FormContext);
592
1516
  if (context === null) throw new Error("useForm must be called inside a FormProvider.");
593
1517
  return context;
594
1518
  }
@@ -596,13 +1520,13 @@ function useField(fieldId) {
596
1520
  const form = useForm();
597
1521
  const field = form.schema.fields.find((item) => item.id === fieldId);
598
1522
  if (field === void 0) throw new Error(`Unknown form field: ${fieldId}`);
599
- const setValue = (0, import_react.useCallback)((value) => form.setValue(fieldId, value), [fieldId, form]);
1523
+ const setValue = (0, import_react3.useCallback)((value) => form.setValue(fieldId, value), [fieldId, form]);
600
1524
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
601
1525
  }
602
1526
 
603
1527
  // src/renderer.tsx
604
- var import_core3 = require("@form-engine-ts/core");
605
- var import_react2 = require("react");
1528
+ var import_core4 = require("@form-engine-ts/core");
1529
+ var import_react4 = require("react");
606
1530
  var import_jsx_runtime3 = require("react/jsx-runtime");
607
1531
  function describedBy(field, error, helpId, errorId) {
608
1532
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
@@ -781,60 +1705,288 @@ function DefaultField(props) {
781
1705
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
782
1706
  ] });
783
1707
  }
784
- function FormRenderer({
1708
+ function isRecord(value) {
1709
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1710
+ }
1711
+ function isFormValue(value) {
1712
+ return value === void 0 || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || Array.isArray(value) && value.every((item) => typeof item === "string");
1713
+ }
1714
+ function parseDraft(serialized) {
1715
+ try {
1716
+ const value = JSON.parse(serialized);
1717
+ if (!isRecord(value) || typeof value.formId !== "string" || typeof value.formVersion !== "number" || !Number.isInteger(value.formVersion) || typeof value.savedAt !== "string" || !isRecord(value.values) || !Object.values(value.values).every(isFormValue)) {
1718
+ return null;
1719
+ }
1720
+ return {
1721
+ formId: value.formId,
1722
+ formVersion: value.formVersion,
1723
+ savedAt: value.savedAt,
1724
+ values: Object.fromEntries(
1725
+ Object.entries(value.values).filter((entry) => isFormValue(entry[1]))
1726
+ )
1727
+ };
1728
+ } catch {
1729
+ return null;
1730
+ }
1731
+ }
1732
+ function ContextFormRenderer({
785
1733
  components = {},
786
1734
  className = "",
787
1735
  successMessageKey,
788
- errorMessageKey
1736
+ errorMessageKey,
1737
+ autoSaveKey,
1738
+ beforeSubmit,
1739
+ onDraftSave,
1740
+ slots = {}
789
1741
  }) {
790
1742
  const form = useForm();
791
- const prefix = (0, import_react2.useId)().replace(/:/g, "");
792
- const handleSubmit = async (event) => {
793
- event.preventDefault();
794
- const formElement = event.currentTarget;
795
- const validation = (0, import_core3.validateAnswers)(form.schema, form.values);
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);
1749
+ const pages = form.schema.pages;
1750
+ const visiblePageIndexes = (0, import_react4.useMemo)(
1751
+ () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1752
+ [form.pageVisibility, pages]
1753
+ );
1754
+ const activePage = pages?.[currentPageIndex];
1755
+ const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
1756
+ const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
1757
+ (0, import_react4.useEffect)(() => {
1758
+ if (pages === void 0 || visiblePageIndexes.length === 0) {
1759
+ setCurrentPageIndex(0);
1760
+ return;
1761
+ }
1762
+ if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1763
+ }, [currentPageIndex, pages, visiblePageIndexes]);
1764
+ (0, import_react4.useEffect)(() => {
1765
+ if (focusFieldId === null) return;
1766
+ const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
1767
+ (element) => element.dataset.fieldId === focusFieldId
1768
+ );
1769
+ const control = fieldContainer?.querySelector("input, select, textarea");
1770
+ if (control !== void 0 && control !== null) {
1771
+ control.focus();
1772
+ setFocusFieldId(null);
1773
+ }
1774
+ }, [focusFieldId]);
1775
+ (0, import_react4.useEffect)(() => {
1776
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1777
+ const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
1778
+ if (loadedDraftKey.current === loadIdentity) return;
1779
+ loadedDraftKey.current = loadIdentity;
1780
+ const serialized = globalThis.localStorage.getItem(autoSaveKey);
1781
+ if (serialized === null) return;
1782
+ const draft = parseDraft(serialized);
1783
+ if (draft === null || draft.formId !== form.schema.id || draft.formVersion !== form.schema.version) return;
1784
+ form.restoreValues(draft.values);
1785
+ setDraftRestored(true);
1786
+ }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1787
+ (0, import_react4.useEffect)(() => {
1788
+ if (form.submitStatus === "success") return;
1789
+ const timeout = globalThis.setTimeout(() => {
1790
+ onDraftSave?.(form.values);
1791
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1792
+ const draft = {
1793
+ formId: form.schema.id,
1794
+ formVersion: form.schema.version,
1795
+ values: form.values,
1796
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
1797
+ };
1798
+ globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1799
+ }, 500);
1800
+ return () => globalThis.clearTimeout(timeout);
1801
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values, onDraftSave]);
1802
+ const focusFirstIssue = (fieldId) => {
1803
+ if (fieldId !== void 0) setFocusFieldId(fieldId);
1804
+ };
1805
+ const handleNext = () => {
1806
+ const result = form.validatePage(currentPageIndex);
1807
+ if (!result.valid) {
1808
+ focusFirstIssue(result.issues[0]?.fieldId);
1809
+ return;
1810
+ }
1811
+ const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1812
+ if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1813
+ };
1814
+ const submitValues = async () => {
1815
+ const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
796
1816
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
797
- const valid = await form.submit();
798
- if (!valid && firstInvalidFieldId !== void 0) {
799
- queueMicrotask(() => {
800
- const fieldContainer = [...formElement.querySelectorAll("[data-field-id]")].find(
801
- (element) => element.dataset.fieldId === firstInvalidFieldId
802
- );
803
- fieldContainer?.querySelector("input, select, textarea")?.focus();
804
- });
1817
+ const result = await form.submit(beforeSubmit);
1818
+ if (result.status === "invalid") {
1819
+ const invalidPageIndex = pages?.findIndex(
1820
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1821
+ );
1822
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1823
+ focusFirstIssue(firstInvalidFieldId);
1824
+ return result;
1825
+ }
1826
+ if (result.status !== "success") return result;
1827
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1828
+ globalThis.localStorage.removeItem(autoSaveKey);
1829
+ setDraftRestored(false);
805
1830
  }
1831
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1832
+ return result;
1833
+ };
1834
+ const handleSubmit = (event) => {
1835
+ event.preventDefault();
1836
+ void submitValues();
806
1837
  };
807
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
808
- /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("header", { className: "fe-header", children: [
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") });
1842
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, 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: [
809
1847
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { children: form.schema.title }),
810
- form.schema.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: form.schema.description })
1848
+ form.schema.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: form.schema.description }),
1849
+ pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-progress", children: [
1850
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1851
+ "div",
1852
+ {
1853
+ className: "form-progress-bar",
1854
+ role: "progressbar",
1855
+ "aria-valuemin": 1,
1856
+ "aria-valuemax": visiblePageIndexes.length,
1857
+ "aria-valuenow": activeVisibleIndex + 1,
1858
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1859
+ "div",
1860
+ {
1861
+ className: "form-progress-fill",
1862
+ style: { width: `${(activeVisibleIndex + 1) / visiblePageIndexes.length * 100}%` }
1863
+ }
1864
+ )
1865
+ }
1866
+ ),
1867
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { children: form.translate("form.step", { current: activeVisibleIndex + 1, total: visiblePageIndexes.length }) })
1868
+ ] }),
1869
+ draftRestored ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
811
1870
  ] }),
812
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true).map((field) => {
1871
+ activePage?.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1872
+ activePage?.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description }),
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];
813
1875
  const props = {
814
1876
  field,
815
1877
  value: form.values[field.id],
816
- error: form.errors[field.id],
1878
+ error,
817
1879
  setValue: (value) => form.setValue(field.id, value),
818
1880
  translate: form.translate,
819
1881
  inputId: `${prefix}-${field.id}`,
820
1882
  errorId: `${prefix}-${field.id}-error`,
821
1883
  helpId: `${prefix}-${field.id}-help`
822
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
+ }
823
1895
  const Component = components[field.type];
824
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);
825
1897
  }) }),
826
- /* @__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()
1935
+ ] }),
827
1936
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-status", "aria-live": "polite", children: [
828
- 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,
829
1940
  form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
830
1941
  ] })
831
1942
  ] });
832
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
+ }
833
1984
  // Annotate the CommonJS export names for ESM import in node:
834
1985
  0 && (module.exports = {
835
1986
  FormBuilder,
836
1987
  FormProvider,
837
1988
  FormRenderer,
838
1989
  useField,
839
- useForm
1990
+ useForm,
1991
+ useFormBuilder
840
1992
  });