@form-engine-ts/react 1.1.0 → 2.1.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,623 @@ __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
+ var CHOICE_TYPES = ["select", "radio", "multi-select"];
41
+ function defaultIdFactory(kind, existingIds) {
42
+ const prefix = DEFAULT_PREFIXES[kind];
43
+ let id;
44
+ do
45
+ id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
46
+ while (existingIds.has(id));
47
+ return id;
48
+ }
49
+ function defaultCreateField(type, id) {
50
+ const base = { id, title: "New question", required: false };
51
+ if (type === "text" || type === "textarea" || type === "number" || type === "checkbox") return { ...base, type };
52
+ if (type === "rating") return { ...base, type, min: 1, max: 5 };
53
+ return { ...base, type, options: [] };
54
+ }
55
+ function defaultCreateOption(field, id) {
56
+ return { id, label: `Option ${"options" in field ? field.options.length + 1 : 1}` };
57
+ }
58
+ function defaultCreatePage(id, questionIds) {
59
+ return { id, title: "New page", questionIds };
60
+ }
61
+ function move(items, sourceIndex, targetIndex) {
62
+ if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
63
+ return void 0;
64
+ const result = [...items];
65
+ const [item] = result.splice(sourceIndex, 1);
66
+ if (item === void 0) return void 0;
67
+ result.splice(targetIndex, 0, item);
68
+ return result;
69
+ }
70
+ function withoutDisplayCondition(field) {
71
+ const { displayCondition: _displayCondition, ...rest } = field;
72
+ return rest;
73
+ }
74
+ function removeLocalizedProperty(translations, locale, property) {
75
+ const current = translations?.[locale];
76
+ if (current === void 0) return translations;
77
+ const { [property]: _removed, ...remaining } = current;
78
+ return { ...translations, [locale]: remaining };
79
+ }
80
+ function setTranslationMetadata(node, locale, property, metadata, remove) {
81
+ const localeMetadata = node.translationMetadata?.[locale];
82
+ if (metadata === void 0 && !remove) return node;
83
+ const nextLocaleMetadata = remove ? Object.fromEntries(Object.entries(localeMetadata ?? {}).filter(([key]) => key !== property)) : { ...localeMetadata, [property]: metadata ?? {} };
84
+ return { ...node, translationMetadata: { ...node.translationMetadata, [locale]: nextLocaleMetadata } };
85
+ }
86
+ function failedId(kind, result) {
87
+ return { success: false, error: result.error ?? { type: "invalid_id", kind, id: "" } };
88
+ }
89
+ function useFormBuilder({
90
+ schema,
91
+ onChange,
92
+ policy,
93
+ idFactory = defaultIdFactory,
94
+ factories = {}
95
+ }) {
96
+ const createId = (0, import_react.useCallback)(
97
+ (kind, existingIds) => {
98
+ const rawId = idFactory(kind, existingIds);
99
+ const id = rawId.trim();
100
+ return id.length > 0 && !existingIds.has(id) ? { id } : { error: { type: "invalid_id", kind, id: rawId } };
101
+ },
102
+ [idFactory]
103
+ );
104
+ const textPolicyError = (0, import_react.useCallback)(
105
+ (text) => policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength ? { success: false, error: { type: "max_text_length_exceeded", max: policy.maxTextLength } } : void 0,
106
+ [policy?.maxTextLength]
107
+ );
108
+ const updateField = (0, import_react.useCallback)(
109
+ (fieldId, updater) => {
110
+ const current = schema.fields.find((field) => field.id === fieldId);
111
+ if (current === void 0)
112
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
113
+ const updated = updater(current);
114
+ if (updated.id !== fieldId)
115
+ return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
116
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
117
+ return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
118
+ for (const text of [updated.title, updated.description]) {
119
+ if (text !== void 0) {
120
+ const error = textPolicyError(text);
121
+ if (error !== void 0) return error;
122
+ }
123
+ }
124
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
125
+ return { success: true };
126
+ },
127
+ [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
128
+ );
129
+ const updateOption = (0, import_react.useCallback)(
130
+ (fieldId, optionId, updater) => {
131
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
132
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
133
+ if (!("options" in field))
134
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
135
+ const current = field.options.find((option) => option.id === optionId);
136
+ if (current === void 0)
137
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
138
+ const updated = updater(current);
139
+ if (updated.id !== optionId)
140
+ return { success: false, error: { type: "invalid_id", kind: "option", id: updated.id } };
141
+ const error = textPolicyError(updated.label);
142
+ if (error !== void 0) return error;
143
+ onChange({
144
+ ...schema,
145
+ fields: schema.fields.map(
146
+ (candidate) => candidate.id === fieldId && "options" in candidate ? {
147
+ ...candidate,
148
+ options: candidate.options.map((option) => option.id === optionId ? updated : option)
149
+ } : candidate
150
+ )
151
+ });
152
+ return { success: true };
153
+ },
154
+ [onChange, schema, textPolicyError]
155
+ );
156
+ const updatePage = (0, import_react.useCallback)(
157
+ (pageId, updater) => {
158
+ const page = schema.pages?.find((candidate) => candidate.id === pageId);
159
+ if (page === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
160
+ const pages = schema.pages;
161
+ if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
162
+ const updated = updater(page);
163
+ if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
164
+ onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
165
+ return { success: true };
166
+ },
167
+ [onChange, schema]
168
+ );
169
+ const addField = (0, import_react.useCallback)(
170
+ (type, pageId) => {
171
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
172
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
173
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields)
174
+ return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
175
+ if (pageId !== void 0 && !schema.pages?.some((page) => page.id === pageId))
176
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
177
+ const fieldIds = new Set(schema.fields.map((field2) => field2.id));
178
+ const fieldId = createId("field", fieldIds);
179
+ if (fieldId.id === void 0) return failedId("field", fieldId);
180
+ let field = (factories.createField ?? defaultCreateField)(type, fieldId.id);
181
+ if (field.id !== fieldId.id || field.type !== type || fieldIds.has(field.id))
182
+ return { success: false, error: { type: "invalid_id", kind: "field", id: field.id } };
183
+ if (CHOICE_TYPES.includes(type) && "options" in field && field.options.length === 0) {
184
+ const optionIds = new Set(
185
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
186
+ );
187
+ const optionId = createId("option", optionIds);
188
+ if (optionId.id === void 0) return failedId("option", optionId);
189
+ const option = (factories.createOption ?? defaultCreateOption)(field, optionId.id);
190
+ if (option.id !== optionId.id || optionIds.has(option.id))
191
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
192
+ field = { ...field, options: [option] };
193
+ }
194
+ const pages = schema.pages?.map((page, index) => ({
195
+ ...page,
196
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
197
+ }));
198
+ onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
199
+ return { success: true };
200
+ },
201
+ [createId, factories, onChange, policy, schema]
202
+ );
203
+ const removeField = (0, import_react.useCallback)(
204
+ (fieldId) => {
205
+ if (!schema.fields.some((field) => field.id === fieldId))
206
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
207
+ if (schema.fields.length <= 1)
208
+ return { success: false, error: { type: "invalid_operation", message: "A form must contain one field." } };
209
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map((field) => field.displayCondition?.questionId === fieldId ? withoutDisplayCondition(field) : field);
210
+ const pages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
211
+ if (schema.pages !== void 0 && pages?.length === 0) {
212
+ const { pages: _pages, ...single } = schema;
213
+ onChange({ ...single, fields });
214
+ } else onChange({ ...schema, fields, ...pages === void 0 ? {} : { pages } });
215
+ return { success: true };
216
+ },
217
+ [onChange, schema]
218
+ );
219
+ const moveField = (0, import_react.useCallback)(
220
+ (fieldId, targetIndex) => {
221
+ const sourceIndex = schema.fields.findIndex((field) => field.id === fieldId);
222
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
223
+ const fields = move(schema.fields, sourceIndex, targetIndex);
224
+ if (fields === void 0)
225
+ return { success: false, error: { type: "invalid_operation", message: "Invalid field position." } };
226
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
227
+ onChange({
228
+ ...schema,
229
+ fields: fields.map((field, index) => {
230
+ const source = field.displayCondition?.questionId;
231
+ return source === void 0 || (indexById.get(source) ?? index) < index ? field : withoutDisplayCondition(field);
232
+ })
233
+ });
234
+ return { success: true };
235
+ },
236
+ [onChange, schema]
237
+ );
238
+ const addOption = (0, import_react.useCallback)(
239
+ (fieldId) => {
240
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
241
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
242
+ if (!("options" in field))
243
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
244
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
245
+ return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
246
+ const ids = new Set(
247
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
248
+ );
249
+ const id = createId("option", ids);
250
+ if (id.id === void 0) return failedId("option", id);
251
+ const option = (factories.createOption ?? defaultCreateOption)(field, id.id);
252
+ if (option.id !== id.id || ids.has(option.id))
253
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
254
+ onChange({
255
+ ...schema,
256
+ fields: schema.fields.map(
257
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: [...item.options, option] } : item
258
+ )
259
+ });
260
+ return { success: true };
261
+ },
262
+ [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
263
+ );
264
+ const removeOption = (0, import_react.useCallback)(
265
+ (fieldId, optionId) => {
266
+ const field = schema.fields.find((item) => item.id === fieldId);
267
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
268
+ if (!("options" in field) || !field.options.some((option) => option.id === optionId))
269
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
270
+ if (field.options.length <= 1)
271
+ return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
272
+ onChange({
273
+ ...schema,
274
+ fields: schema.fields.map(
275
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: item.options.filter((option) => option.id !== optionId) } : item
276
+ )
277
+ });
278
+ return { success: true };
279
+ },
280
+ [onChange, schema]
281
+ );
282
+ const moveOption = (0, import_react.useCallback)(
283
+ (fieldId, optionId, targetIndex) => {
284
+ const field = schema.fields.find((item) => item.id === fieldId);
285
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
286
+ if (!("options" in field))
287
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
288
+ const options = move(
289
+ field.options,
290
+ field.options.findIndex((option) => option.id === optionId),
291
+ targetIndex
292
+ );
293
+ if (options === void 0)
294
+ return { success: false, error: { type: "invalid_operation", message: "Invalid option position." } };
295
+ onChange({
296
+ ...schema,
297
+ fields: schema.fields.map(
298
+ (item) => item.id === fieldId && "options" in item ? { ...item, options } : item
299
+ )
300
+ });
301
+ return { success: true };
302
+ },
303
+ [onChange, schema]
304
+ );
305
+ const changeFieldType = (0, import_react.useCallback)(
306
+ (fieldId, type) => {
307
+ const field = schema.fields.find((item) => item.id === fieldId);
308
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
309
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
310
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
311
+ let transformed = (0, import_core.transformFieldType)(field, type);
312
+ if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
313
+ const ids = new Set(
314
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
315
+ );
316
+ const id = createId("option", ids);
317
+ if (id.id === void 0) return failedId("option", id);
318
+ const option = (factories.createOption ?? defaultCreateOption)(transformed, id.id);
319
+ if (option.id !== id.id || ids.has(option.id))
320
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
321
+ transformed = { ...transformed, options: [option] };
322
+ }
323
+ onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
324
+ return { success: true };
325
+ },
326
+ [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
327
+ );
328
+ const addPage = (0, import_react.useCallback)(
329
+ (questionId) => {
330
+ const ids = new Set(schema.pages?.map((page2) => page2.id) ?? []);
331
+ const id = createId("page", ids);
332
+ if (id.id === void 0) return failedId("page", id);
333
+ const questionIds = schema.pages === void 0 ? schema.fields.map((field) => field.id) : [questionId ?? schema.pages.find((page2) => page2.questionIds.length > 1)?.questionIds.at(-1)].filter(
334
+ (value) => value !== void 0
335
+ );
336
+ if (questionIds.length === 0)
337
+ return { success: false, error: { type: "invalid_operation", message: "No question can be moved." } };
338
+ const source = schema.pages?.find((page2) => page2.questionIds.includes(questionIds[0] ?? ""));
339
+ if (schema.pages !== void 0 && (source === void 0 || source.questionIds.length <= 1))
340
+ return { success: false, error: { type: "invalid_operation", message: "A page cannot be left empty." } };
341
+ const page = (factories.createPage ?? defaultCreatePage)(id.id, [...questionIds]);
342
+ if (page.id !== id.id || ids.has(page.id))
343
+ return { success: false, error: { type: "invalid_id", kind: "page", id: page.id } };
344
+ const pages = schema.pages === void 0 ? [page] : [
345
+ ...schema.pages.map((item) => ({
346
+ ...item,
347
+ questionIds: item.questionIds.filter((fieldId) => !questionIds.includes(fieldId))
348
+ })),
349
+ page
350
+ ];
351
+ onChange({ ...schema, pages });
352
+ return { success: true };
353
+ },
354
+ [createId, factories.createPage, onChange, schema]
355
+ );
356
+ const removePage = (0, import_react.useCallback)(
357
+ (pageId) => {
358
+ const index = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
359
+ const removed = schema.pages?.[index];
360
+ if (removed === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
361
+ const currentPages = schema.pages;
362
+ if (currentPages === void 0)
363
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
364
+ if ((schema.pages?.length ?? 0) === 1) {
365
+ const { pages: _pages, ...single } = schema;
366
+ onChange(single);
367
+ return { success: true };
368
+ }
369
+ const targetIndex = index === 0 ? 1 : index - 1;
370
+ onChange({
371
+ ...schema,
372
+ pages: currentPages.map(
373
+ (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
374
+ ).filter((page) => page.id !== pageId)
375
+ });
376
+ return { success: true };
377
+ },
378
+ [onChange, schema]
379
+ );
380
+ const movePage = (0, import_react.useCallback)(
381
+ (pageId, targetIndex) => {
382
+ const sourceIndex = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
383
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
384
+ const pages = move(schema.pages ?? [], sourceIndex, targetIndex);
385
+ if (pages === void 0)
386
+ return { success: false, error: { type: "invalid_operation", message: "Invalid page position." } };
387
+ const assignment = new Map(pages.flatMap((page, index) => page.questionIds.map((id) => [id, index])));
388
+ onChange({
389
+ ...schema,
390
+ pages: pages.map((page, index) => {
391
+ const source = page.displayCondition?.questionId;
392
+ if (source === void 0 || (assignment.get(source) ?? index) < index) return page;
393
+ const { displayCondition: _condition, ...rest } = page;
394
+ return rest;
395
+ })
396
+ });
397
+ return { success: true };
398
+ },
399
+ [onChange, schema]
400
+ );
401
+ const assignFieldToPage = (0, import_react.useCallback)(
402
+ (fieldId, pageId) => {
403
+ if (!schema.fields.some((field) => field.id === fieldId))
404
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
405
+ if (pageId === null) {
406
+ if (schema.pages !== void 0) {
407
+ const { pages: _pages, ...single } = schema;
408
+ onChange(single);
409
+ }
410
+ return { success: true };
411
+ }
412
+ if (!schema.pages?.some((page) => page.id === pageId))
413
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
414
+ const pages = schema.pages.map((page) => ({
415
+ ...page,
416
+ 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)
417
+ })).filter((page) => page.questionIds.length > 0);
418
+ onChange({ ...schema, pages });
419
+ return { success: true };
420
+ },
421
+ [onChange, schema]
422
+ );
423
+ const setDisplayCondition = (0, import_react.useCallback)(
424
+ (fieldId, condition) => {
425
+ const index = schema.fields.findIndex((field) => field.id === fieldId);
426
+ if (index < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
427
+ if (condition !== void 0) {
428
+ const sourceIndex = schema.fields.findIndex((field) => field.id === condition.questionId);
429
+ if (sourceIndex < 0 || sourceIndex >= index)
430
+ return {
431
+ success: false,
432
+ error: { type: "invalid_operation", message: "Conditions require an earlier field." }
433
+ };
434
+ }
435
+ onChange({
436
+ ...schema,
437
+ fields: schema.fields.map(
438
+ (field) => field.id !== fieldId ? field : condition === void 0 ? withoutDisplayCondition(field) : { ...field, displayCondition: condition }
439
+ )
440
+ });
441
+ return { success: true };
442
+ },
443
+ [onChange, schema]
444
+ );
445
+ const setSourceText = (0, import_react.useCallback)(
446
+ (target, property, text) => {
447
+ const error = textPolicyError(text);
448
+ if (error !== void 0) return error;
449
+ if (target.kind === "form") {
450
+ if (!["title", "description", "completionMessage"].includes(property))
451
+ return {
452
+ success: false,
453
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
454
+ };
455
+ if (property === "title") onChange({ ...schema, title: text });
456
+ else if (property === "description") {
457
+ const { description: _description, ...withoutDescription } = schema;
458
+ onChange(text.length === 0 ? withoutDescription : { ...schema, description: text });
459
+ } else {
460
+ const { completionMessage: _completionMessage, ...withoutCompletionMessage } = schema;
461
+ onChange(text.length === 0 ? withoutCompletionMessage : { ...schema, completionMessage: text });
462
+ }
463
+ return { success: true };
464
+ }
465
+ if (target.id === void 0)
466
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
467
+ if (target.kind === "field") {
468
+ if (!["title", "description"].includes(property))
469
+ return {
470
+ success: false,
471
+ error: { type: "invalid_operation", message: `Unsupported field property: ${property}` }
472
+ };
473
+ return updateField(target.id, (field) => {
474
+ if (property === "title") return { ...field, title: text };
475
+ const { description: _description, ...withoutDescription } = field;
476
+ return text.length === 0 ? withoutDescription : { ...field, description: text };
477
+ });
478
+ }
479
+ if (target.kind === "option") {
480
+ if (property !== "label")
481
+ return {
482
+ success: false,
483
+ error: { type: "invalid_operation", message: `Unsupported option property: ${property}` }
484
+ };
485
+ for (const field of schema.fields)
486
+ if ("options" in field && field.options.some((option) => option.id === target.id))
487
+ return updateOption(field.id, target.id, (option) => ({ ...option, label: text }));
488
+ return { success: false, error: { type: "node_not_found", kind: "option", id: target.id } };
489
+ }
490
+ if (!["title", "description"].includes(property))
491
+ return {
492
+ success: false,
493
+ error: { type: "invalid_operation", message: `Unsupported page property: ${property}` }
494
+ };
495
+ return updatePage(target.id, (page) => {
496
+ if (property === "title") {
497
+ const { title: _title, ...withoutTitle } = page;
498
+ return text.length === 0 ? withoutTitle : { ...page, title: text };
499
+ }
500
+ const { description: _description, ...withoutDescription } = page;
501
+ return text.length === 0 ? withoutDescription : { ...page, description: text };
502
+ });
503
+ },
504
+ [onChange, schema, textPolicyError, updateField, updateOption, updatePage]
505
+ );
506
+ const setLocaleTranslation = (0, import_react.useCallback)(
507
+ (locale, target, property, text, options = {}) => {
508
+ const normalized = locale.trim();
509
+ if (normalized.length === 0)
510
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
511
+ const error = textPolicyError(text);
512
+ if (error !== void 0) return error;
513
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], normalized])];
514
+ const remove = text.length === 0;
515
+ if (target.kind === "form") {
516
+ if (!["title", "description", "completionMessage"].includes(property))
517
+ return {
518
+ success: false,
519
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
520
+ };
521
+ const key = property;
522
+ const translations = remove ? removeLocalizedProperty(schema.translations, normalized, key) : { ...schema.translations, [normalized]: { ...schema.translations?.[normalized], [key]: text } };
523
+ onChange(
524
+ setTranslationMetadata(
525
+ { ...schema, supportedLocales, ...translations === void 0 ? {} : { translations } },
526
+ normalized,
527
+ property,
528
+ options.metadata,
529
+ remove
530
+ )
531
+ );
532
+ return { success: true };
533
+ }
534
+ if (target.id === void 0)
535
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
536
+ let found = false;
537
+ const fields = schema.fields.map((field) => {
538
+ if (target.kind === "field" && field.id === target.id && ["title", "description"].includes(property)) {
539
+ found = true;
540
+ const key = property;
541
+ const translations = remove ? removeLocalizedProperty(field.translations, normalized, key) : { ...field.translations, [normalized]: { ...field.translations?.[normalized], [key]: text } };
542
+ return setTranslationMetadata(
543
+ { ...field, ...translations === void 0 ? {} : { translations } },
544
+ normalized,
545
+ property,
546
+ options.metadata,
547
+ remove
548
+ );
549
+ }
550
+ if (target.kind !== "option" || property !== "label" || !("options" in field)) return field;
551
+ return {
552
+ ...field,
553
+ options: field.options.map((option) => {
554
+ if (option.id !== target.id) return option;
555
+ found = true;
556
+ const translations = remove ? Object.fromEntries(Object.entries(option.translations ?? {}).filter(([key]) => key !== normalized)) : { ...option.translations, [normalized]: text };
557
+ return setTranslationMetadata({ ...option, translations }, normalized, property, options.metadata, remove);
558
+ })
559
+ };
560
+ });
561
+ const pages = schema.pages?.map((page) => {
562
+ if (target.kind !== "page" || page.id !== target.id || !["title", "description"].includes(property))
563
+ return page;
564
+ found = true;
565
+ const key = property;
566
+ const translations = remove ? removeLocalizedProperty(page.translations, normalized, key) : { ...page.translations, [normalized]: { ...page.translations?.[normalized], [key]: text } };
567
+ return setTranslationMetadata(
568
+ { ...page, ...translations === void 0 ? {} : { translations } },
569
+ normalized,
570
+ property,
571
+ options.metadata,
572
+ remove
573
+ );
574
+ });
575
+ if (!found) return { success: false, error: { type: "node_not_found", kind: target.kind, id: target.id } };
576
+ onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
577
+ return { success: true };
578
+ },
579
+ [onChange, schema, textPolicyError]
580
+ );
581
+ const addLocale = (0, import_react.useCallback)(
582
+ (locale) => {
583
+ const normalized = locale.trim();
584
+ if (normalized.length === 0)
585
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
586
+ onChange({
587
+ ...schema,
588
+ supportedLocales: [
589
+ .../* @__PURE__ */ new Set([
590
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
591
+ ...schema.supportedLocales ?? [],
592
+ normalized
593
+ ])
594
+ ]
595
+ });
596
+ return { success: true };
597
+ },
598
+ [onChange, schema]
599
+ );
600
+ const setDefaultLocale = (0, import_react.useCallback)(
601
+ (locale) => {
602
+ const normalized = locale.trim();
603
+ if (normalized.length === 0)
604
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
605
+ onChange({
606
+ ...schema,
607
+ defaultLocale: normalized,
608
+ supportedLocales: [.../* @__PURE__ */ new Set([normalized, ...schema.supportedLocales ?? []])]
609
+ });
610
+ return { success: true };
611
+ },
612
+ [onChange, schema]
613
+ );
614
+ const validationIssues = (0, import_react.useMemo)(() => {
615
+ const result = (0, import_core.validateFormSchema)(schema, policy === void 0 ? {} : { policy });
616
+ return result.valid ? [] : result.issues;
617
+ }, [policy, schema]);
618
+ return {
619
+ schema,
620
+ addField,
621
+ removeField,
622
+ moveField,
623
+ updateField,
624
+ changeFieldType,
625
+ addOption,
626
+ updateOption,
627
+ removeOption,
628
+ moveOption,
629
+ addPage,
630
+ updatePage,
631
+ removePage,
632
+ movePage,
633
+ assignFieldToPage,
634
+ setDisplayCondition,
635
+ setSourceText,
636
+ setLocaleTranslation,
637
+ addLocale,
638
+ setDefaultLocale,
639
+ validationIssues
640
+ };
641
+ }
642
+
643
+ // src/builder.tsx
34
644
  var import_jsx_runtime = require("react/jsx-runtime");
35
645
  var FIELD_TYPES = [
36
646
  "text",
@@ -51,6 +661,7 @@ var BUILDER_DEFAULTS = {
51
661
  "builder.questionTitle": "\u8CEA\u554F\u6587 / Question Title",
52
662
  "builder.questionTitlePlaceholder": "Example: Tell us what we could improve",
53
663
  "builder.newQuestionTitle": "New question",
664
+ "builder.completionMessage": "Completion message",
54
665
  "builder.type": "Type",
55
666
  "builder.required": "Required",
56
667
  "builder.minimum": "Minimum",
@@ -109,33 +720,6 @@ function fieldTypeKey(type) {
109
720
  function operatorKey(operator) {
110
721
  return `builder.operator.${operator}`;
111
722
  }
112
- function createUniqueId(prefix, existingIds) {
113
- let id;
114
- do {
115
- id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
116
- } while (existingIds.has(id));
117
- return id;
118
- }
119
- function baseField(field, type) {
120
- return {
121
- id: field.id,
122
- type,
123
- title: field.title,
124
- ...field.description === void 0 ? {} : { description: field.description },
125
- ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
126
- required: field.required,
127
- ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition }
128
- };
129
- }
130
- function normalizeField(field, type, newOptionLabel) {
131
- const base = baseField(field, type);
132
- if (type === "text" || type === "textarea") return { ...base, type };
133
- if (type === "number") return { ...base, type };
134
- if (type === "rating") return { ...base, type, min: 1, max: 5 };
135
- if (type === "checkbox") return { ...base, type };
136
- const options = "options" in field && field.options.length > 0 ? field.options : [{ id: createUniqueId("opt", /* @__PURE__ */ new Set()), label: newOptionLabel }];
137
- return { ...base, type, options };
138
- }
139
723
  function defaultConditionValue(field) {
140
724
  if (field.type === "checkbox") return true;
141
725
  if (field.type === "number" || field.type === "rating") return field.min ?? 1;
@@ -149,30 +733,6 @@ function conditionOperators(field) {
149
733
  }
150
734
  return ["equals", "not_equals", "not_empty"];
151
735
  }
152
- function withoutDisplayCondition(field) {
153
- const { displayCondition: _displayCondition, ...rest } = field;
154
- return rest;
155
- }
156
- function sanitizeBuilderSchema(schema) {
157
- const sanitized = (0, import_core.sanitizeSchema)(schema);
158
- const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
159
- const fields = sanitized.fields.map((field, index) => {
160
- const sourceId = field.displayCondition?.questionId;
161
- if (sourceId === void 0) return field;
162
- const sourceIndex = indexById.get(sourceId);
163
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
164
- });
165
- return {
166
- ...sanitized,
167
- fields,
168
- ...sanitized.pages === void 0 ? {} : {
169
- pages: sanitized.pages.map((page) => ({
170
- ...page,
171
- questionIds: fields.filter((field) => page.questionIds.includes(field.id)).map((field) => field.id)
172
- }))
173
- }
174
- };
175
- }
176
736
  function conditionWithValue(questionId, operator, value) {
177
737
  return operator === "not_empty" ? { questionId, operator } : { questionId, operator, value };
178
738
  }
@@ -214,77 +774,45 @@ function ConditionValueEditor({
214
774
  }
215
775
  );
216
776
  }
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);
777
+ function FormBuilder({
778
+ schema,
779
+ onChange,
780
+ locale = "en",
781
+ translator,
782
+ translationAdapter,
783
+ translationOptions,
784
+ onTranslationReport,
785
+ policy,
786
+ idFactory,
787
+ factories
788
+ }) {
789
+ const headless = useFormBuilder({
790
+ schema,
791
+ onChange,
792
+ ...policy === void 0 ? {} : { policy },
793
+ ...idFactory === void 0 ? {} : { idFactory },
794
+ ...factories === void 0 ? {} : { factories }
795
+ });
796
+ const [newPageQuestionId, setNewPageQuestionId] = (0, import_react2.useState)("");
797
+ const [newLocale, setNewLocale] = (0, import_react2.useState)("");
798
+ const [editingLocale, setEditingLocale] = (0, import_react2.useState)("");
799
+ const [isTranslating, setIsTranslating] = (0, import_react2.useState)(false);
800
+ const [translationError, setTranslationError] = (0, import_react2.useState)(null);
223
801
  const translate = (key, params = {}) => {
224
802
  const translated = translator?.translate(key, locale, params);
225
803
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
226
804
  };
227
- 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
- };
231
- const changeType = (fieldId, type) => {
232
- emitSchema({
233
- ...schema,
234
- fields: schema.fields.map((field) => {
235
- if (field.id === fieldId) {
236
- return normalizeField(field, type, translate("builder.newOptionLabel", { index: 1 }));
237
- }
238
- if (field.displayCondition?.questionId === fieldId) {
239
- const { displayCondition: _condition, ...withoutCondition } = field;
240
- return withoutCondition;
241
- }
242
- return field;
243
- })
244
- });
245
- };
246
- const removeField = (fieldId) => {
247
- if (schema.fields.length === 1) return;
248
- emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
249
- };
805
+ const updateField = headless.updateField;
806
+ const changeType = headless.changeFieldType;
807
+ const removeField = headless.removeField;
250
808
  const moveField = (index, offset) => {
251
809
  const target = index + offset;
252
- if (target < 0 || target >= schema.fields.length) return;
253
- const fields = [...schema.fields];
254
- const current = fields[index];
255
- const other = fields[target];
256
- if (current === void 0 || other === void 0) return;
257
- fields[index] = other;
258
- fields[target] = current;
259
- emitSchema({ ...schema, fields });
260
- };
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
- );
810
+ const field = schema.fields[index];
811
+ if (field !== void 0) headless.moveField(field.id, target);
275
812
  };
813
+ const addField = () => headless.addField("text");
276
814
  const enablePages = () => {
277
- if (schema.pages !== void 0) return;
278
- emitSchema({
279
- ...schema,
280
- pages: [
281
- {
282
- id: createUniqueId("page", /* @__PURE__ */ new Set()),
283
- title: translate("builder.newPage"),
284
- questionIds: schema.fields.map((field) => field.id)
285
- }
286
- ]
287
- });
815
+ if (schema.pages === void 0) headless.addPage();
288
816
  };
289
817
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
290
818
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -295,73 +823,28 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
295
823
  }
296
824
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
297
825
  if (questionId === void 0) return;
298
- const pageId = createUniqueId("page", new Set(schema.pages.map((page) => page.id)));
299
- emitSchema({
300
- ...schema,
301
- pages: [
302
- ...schema.pages.map((page) => ({
303
- ...page,
304
- questionIds: page.questionIds.filter((id) => id !== questionId)
305
- })),
306
- { id: pageId, title: translate("builder.newPage"), questionIds: [questionId] }
307
- ]
308
- });
826
+ headless.addPage(questionId);
309
827
  setNewPageQuestionId("");
310
828
  };
311
829
  const removePage = (pageIndex) => {
312
- if (schema.pages === void 0) return;
313
- const removed = schema.pages[pageIndex];
314
- if (removed === void 0) return;
315
- if (schema.pages.length === 1) {
316
- const { pages: _pages, ...singlePage } = schema;
317
- emitSchema(singlePage);
318
- return;
319
- }
320
- const targetIndex = pageIndex === 0 ? 1 : pageIndex - 1;
321
- emitSchema({
322
- ...schema,
323
- pages: schema.pages.map(
324
- (page, index) => index === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
325
- ).filter((_page, index) => index !== pageIndex)
326
- });
830
+ const page = schema.pages?.[pageIndex];
831
+ if (page !== void 0) headless.removePage(page.id);
327
832
  };
328
833
  const movePage = (pageIndex, offset) => {
329
- if (schema.pages === void 0) return;
330
834
  const target = pageIndex + offset;
331
- if (target < 0 || target >= schema.pages.length) return;
332
- const pages = [...schema.pages];
333
- const current = pages[pageIndex];
334
- const other = pages[target];
335
- if (current === void 0 || other === void 0) return;
336
- pages[pageIndex] = other;
337
- pages[target] = current;
338
- emitSchema({ ...schema, pages });
835
+ const page = schema.pages?.[pageIndex];
836
+ if (page !== void 0) headless.movePage(page.id, target);
339
837
  };
340
838
  const updatePage = (pageId, update) => {
341
- if (schema.pages === void 0) return;
342
- emitSchema({ ...schema, pages: schema.pages.map((page) => page.id === pageId ? update(page) : page) });
839
+ headless.updatePage(pageId, update);
343
840
  };
344
841
  const assignFieldToPage = (fieldId, pageId) => {
345
- if (schema.pages === void 0) return;
346
- emitSchema({
347
- ...schema,
348
- pages: schema.pages.map((page) => ({
349
- ...page,
350
- 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)
351
- })).filter((page) => page.questionIds.length > 0)
352
- });
842
+ headless.assignFieldToPage(fieldId, pageId);
353
843
  };
354
844
  const addLocale = () => {
355
845
  const normalized = newLocale.trim();
356
846
  if (normalized.length === 0) return;
357
- const supportedLocales = [
358
- .../* @__PURE__ */ new Set([
359
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
360
- ...schema.supportedLocales ?? [],
361
- normalized
362
- ])
363
- ];
364
- emitSchema({ ...schema, supportedLocales });
847
+ headless.addLocale(normalized);
365
848
  setEditingLocale(normalized);
366
849
  setNewLocale("");
367
850
  };
@@ -370,7 +853,14 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
370
853
  setIsTranslating(true);
371
854
  setTranslationError(null);
372
855
  try {
373
- onChange(await (0, import_core.populateSchemaTranslations)(schema, [editingLocale], translationAdapter));
856
+ const populated = await (0, import_core2.populateSchemaTranslations)(
857
+ schema,
858
+ [editingLocale],
859
+ translationAdapter,
860
+ translationOptions ?? { overwrite: "all" }
861
+ );
862
+ onChange(populated.schema);
863
+ onTranslationReport?.(populated.report);
374
864
  } catch (cause) {
375
865
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
376
866
  } finally {
@@ -379,18 +869,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
379
869
  };
380
870
  const updateFormTranslation = (key, value) => {
381
871
  if (editingLocale.length === 0) return;
382
- const current = schema.translations?.[editingLocale];
383
- const next = key === "title" ? value.length === 0 ? { description: current?.description } : { ...current, title: value } : value.length === 0 ? { title: current?.title } : { ...current, description: value };
384
- emitSchema({
385
- ...schema,
386
- translations: {
387
- ...schema.translations,
388
- [editingLocale]: {
389
- ...next.title === void 0 ? {} : { title: next.title },
390
- ...next.description === void 0 ? {} : { description: next.description }
391
- }
392
- }
393
- });
872
+ headless.setLocaleTranslation(editingLocale, { kind: "form" }, key, value);
394
873
  };
395
874
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
396
875
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
@@ -470,19 +949,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
470
949
  "input",
471
950
  {
472
951
  value: page.translations?.[editingLocale]?.title ?? "",
473
- onChange: (event) => {
474
- const value = event.currentTarget.value;
475
- updatePage(page.id, (current) => ({
476
- ...current,
477
- translations: {
478
- ...current.translations,
479
- [editingLocale]: {
480
- ...value.length === 0 ? {} : { title: value },
481
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
482
- }
483
- }
484
- }));
485
- }
952
+ onChange: (event) => headless.setLocaleTranslation(
953
+ editingLocale,
954
+ { kind: "page", id: page.id },
955
+ "title",
956
+ event.currentTarget.value
957
+ )
486
958
  }
487
959
  )
488
960
  ] }),
@@ -492,19 +964,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
492
964
  "input",
493
965
  {
494
966
  value: page.translations?.[editingLocale]?.description ?? "",
495
- onChange: (event) => {
496
- const value = event.currentTarget.value;
497
- updatePage(page.id, (current) => ({
498
- ...current,
499
- translations: {
500
- ...current.translations,
501
- [editingLocale]: {
502
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
503
- ...value.length === 0 ? {} : { description: value }
504
- }
505
- }
506
- }));
507
- }
967
+ onChange: (event) => headless.setLocaleTranslation(
968
+ editingLocale,
969
+ { kind: "page", id: page.id },
970
+ "description",
971
+ event.currentTarget.value
972
+ )
508
973
  }
509
974
  )
510
975
  ] })
@@ -592,6 +1057,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
592
1057
  ] }),
593
1058
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
594
1059
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1060
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1061
+ translate("builder.completionMessage"),
1062
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1063
+ "input",
1064
+ {
1065
+ value: schema.completionMessage ?? "",
1066
+ onChange: (event) => headless.setSourceText({ kind: "form" }, "completionMessage", event.currentTarget.value)
1067
+ }
1068
+ )
1069
+ ] }),
595
1070
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
596
1071
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
597
1072
  translate("builder.defaultLocale"),
@@ -599,16 +1074,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
599
1074
  "input",
600
1075
  {
601
1076
  value: schema.defaultLocale ?? "",
602
- onChange: (event) => {
603
- const value = event.currentTarget.value.trim();
604
- emitSchema(
605
- value.length === 0 ? schema : {
606
- ...schema,
607
- defaultLocale: value,
608
- supportedLocales: [.../* @__PURE__ */ new Set([value, ...schema.supportedLocales ?? []])]
609
- }
610
- );
611
- }
1077
+ onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
612
1078
  }
613
1079
  )
614
1080
  ] }),
@@ -656,6 +1122,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
656
1122
  onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
657
1123
  }
658
1124
  )
1125
+ ] }),
1126
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
1127
+ translate("builder.completionMessage"),
1128
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1129
+ "input",
1130
+ {
1131
+ value: schema.translations?.[editingLocale]?.completionMessage ?? "",
1132
+ onChange: (event) => updateFormTranslation("completionMessage", event.currentTarget.value)
1133
+ }
1134
+ )
659
1135
  ] })
660
1136
  ] })
661
1137
  ] }),
@@ -719,7 +1195,9 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
719
1195
  {
720
1196
  value: field.type,
721
1197
  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))
1198
+ children: FIELD_TYPES.filter(
1199
+ (type) => policy?.allowedFieldTypes === void 0 || policy.allowedFieldTypes.includes(type)
1200
+ ).map((type) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
723
1201
  }
724
1202
  )
725
1203
  ] }),
@@ -755,19 +1233,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
755
1233
  "input",
756
1234
  {
757
1235
  value: field.translations?.[editingLocale]?.title ?? "",
758
- onChange: (event) => {
759
- const value = event.currentTarget.value;
760
- updateField(field.id, (current) => ({
761
- ...current,
762
- translations: {
763
- ...current.translations,
764
- [editingLocale]: {
765
- ...value.length === 0 ? {} : { title: value },
766
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
767
- }
768
- }
769
- }));
770
- }
1236
+ onChange: (event) => headless.setLocaleTranslation(
1237
+ editingLocale,
1238
+ { kind: "field", id: field.id },
1239
+ "title",
1240
+ event.currentTarget.value
1241
+ )
771
1242
  }
772
1243
  )
773
1244
  ] }),
@@ -777,19 +1248,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
777
1248
  "input",
778
1249
  {
779
1250
  value: field.translations?.[editingLocale]?.description ?? "",
780
- onChange: (event) => {
781
- const value = event.currentTarget.value;
782
- updateField(field.id, (current) => ({
783
- ...current,
784
- translations: {
785
- ...current.translations,
786
- [editingLocale]: {
787
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
788
- ...value.length === 0 ? {} : { description: value }
789
- }
790
- }
791
- }));
792
- }
1251
+ onChange: (event) => headless.setLocaleTranslation(
1252
+ editingLocale,
1253
+ { kind: "field", id: field.id },
1254
+ "description",
1255
+ event.currentTarget.value
1256
+ )
793
1257
  }
794
1258
  )
795
1259
  ] })
@@ -803,26 +1267,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
803
1267
  "input",
804
1268
  {
805
1269
  value: option.translations?.[editingLocale] ?? "",
806
- onChange: (event) => {
807
- const value = event.currentTarget.value;
808
- updateField(field.id, (current) => {
809
- if (!("options" in current)) return current;
810
- return {
811
- ...current,
812
- options: current.options.map(
813
- (candidate) => candidate.id === option.id ? {
814
- ...candidate,
815
- translations: Object.fromEntries([
816
- ...Object.entries(candidate.translations ?? {}).filter(
817
- ([localeKey]) => localeKey !== editingLocale
818
- ),
819
- ...value.length === 0 ? [] : [[editingLocale, value]]
820
- ])
821
- } : candidate
822
- )
823
- };
824
- });
825
- }
1270
+ onChange: (event) => headless.setLocaleTranslation(
1271
+ editingLocale,
1272
+ { kind: "option", id: option.id },
1273
+ "label",
1274
+ event.currentTarget.value
1275
+ )
826
1276
  }
827
1277
  )
828
1278
  ] }, option.id)) : null
@@ -874,17 +1324,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
874
1324
  "aria-label": translate("builder.optionLabel", { index: optionIndex + 1 }),
875
1325
  value: option.label,
876
1326
  placeholder: translate("builder.optionLabelPlaceholder"),
877
- onChange: (event) => updateField(field.id, (current) => {
878
- if (!("options" in current)) return current;
879
- const label = event.currentTarget.value;
880
- if (label.trim().length === 0) return current;
881
- return {
882
- ...current,
883
- options: current.options.map(
884
- (item, itemIndex) => itemIndex === optionIndex ? { ...item, label } : item
885
- )
886
- };
887
- })
1327
+ onChange: (event) => event.currentTarget.value.trim().length === 0 ? void 0 : headless.updateOption(field.id, option.id, (item) => ({
1328
+ ...item,
1329
+ label: event.currentTarget.value
1330
+ }))
888
1331
  }
889
1332
  ),
890
1333
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -892,13 +1335,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
892
1335
  {
893
1336
  type: "button",
894
1337
  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
- ),
1338
+ onClick: () => headless.removeOption(field.id, option.id),
902
1339
  children: translate("builder.remove")
903
1340
  }
904
1341
  )
@@ -907,22 +1344,8 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
907
1344
  "button",
908
1345
  {
909
1346
  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
- ),
1347
+ disabled: policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField,
1348
+ onClick: () => headless.addOption(field.id),
926
1349
  children: translate("builder.addOption")
927
1350
  }
928
1351
  )
@@ -936,17 +1359,14 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
936
1359
  value: condition?.questionId ?? "",
937
1360
  onChange: (event) => {
938
1361
  const selected = schema.fields.find((item) => item.id === event.currentTarget.value);
939
- updateField(field.id, (current) => {
940
- if (selected === void 0) {
941
- const { displayCondition: _condition, ...withoutCondition } = current;
942
- return withoutCondition;
943
- }
944
- const operator = conditionOperators(selected)[0] ?? "not_empty";
945
- return {
946
- ...current,
947
- displayCondition: conditionWithValue(selected.id, operator, defaultConditionValue(selected))
948
- };
949
- });
1362
+ headless.setDisplayCondition(
1363
+ field.id,
1364
+ selected === void 0 ? void 0 : conditionWithValue(
1365
+ selected.id,
1366
+ conditionOperators(selected)[0] ?? "not_empty",
1367
+ defaultConditionValue(selected)
1368
+ )
1369
+ );
950
1370
  },
951
1371
  children: [
952
1372
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: "", children: translate("builder.alwaysVisible") }),
@@ -963,10 +1383,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
963
1383
  value: condition.operator,
964
1384
  onChange: (event) => {
965
1385
  const operator = event.currentTarget.value;
966
- updateField(field.id, (current) => ({
967
- ...current,
968
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
969
- }));
1386
+ headless.setDisplayCondition(
1387
+ field.id,
1388
+ conditionWithValue(source.id, operator, defaultConditionValue(source))
1389
+ );
970
1390
  },
971
1391
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
972
1392
  }
@@ -976,7 +1396,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
976
1396
  {
977
1397
  source,
978
1398
  condition,
979
- onChange: (next) => updateField(field.id, (current) => ({ ...current, displayCondition: next })),
1399
+ onChange: (next) => headless.setDisplayCondition(field.id, next),
980
1400
  translate
981
1401
  }
982
1402
  )
@@ -984,15 +1404,24 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
984
1404
  ] })
985
1405
  ] }, field.id);
986
1406
  }) }),
987
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { className: "form-engine-builder__add", type: "button", onClick: addField, children: translate("builder.addQuestion") })
1407
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1408
+ "button",
1409
+ {
1410
+ className: "form-engine-builder__add",
1411
+ type: "button",
1412
+ disabled: policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields,
1413
+ onClick: addField,
1414
+ children: translate("builder.addQuestion")
1415
+ }
1416
+ )
988
1417
  ] });
989
1418
  }
990
1419
 
991
1420
  // src/context.tsx
992
- var import_core2 = require("@form-engine-ts/core");
993
- var import_react2 = require("react");
1421
+ var import_core3 = require("@form-engine-ts/core");
1422
+ var import_react3 = require("react");
994
1423
  var import_jsx_runtime2 = require("react/jsx-runtime");
995
- var FormContext = (0, import_react2.createContext)(null);
1424
+ var FormContext = (0, import_react3.createContext)(null);
996
1425
  function issuesByField(issues) {
997
1426
  const result = {};
998
1427
  for (const issue of issues) result[issue.fieldId] ??= issue;
@@ -1007,30 +1436,30 @@ function FormProvider({
1007
1436
  onSubmit,
1008
1437
  children
1009
1438
  }) {
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);
1439
+ const validSchema = (0, import_react3.useMemo)(() => {
1440
+ (0, import_core3.assertValidFormSchema)(schema);
1441
+ const localized = (0, import_core3.resolveLocalizedSchema)(schema, locale);
1442
+ (0, import_core3.assertValidFormSchema)(localized);
1014
1443
  return localized;
1015
1444
  }, [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)(() => {
1445
+ const [values, setValues] = (0, import_react3.useState)(() => ({ ...initialValues }));
1446
+ const [errors, setErrors] = (0, import_react3.useState)({});
1447
+ const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
1448
+ const [submitError, setSubmitError] = (0, import_react3.useState)(null);
1449
+ const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
1450
+ const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
1451
+ const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
1452
+ (0, import_react3.useEffect)(() => {
1024
1453
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1025
1454
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
1026
1455
  }, [validSchema]);
1027
- const setValue = (0, import_react2.useCallback)(
1456
+ const setValue = (0, import_react3.useCallback)(
1028
1457
  (fieldId, value) => {
1029
1458
  setValues((current) => {
1030
1459
  const next = { ...current, [fieldId]: value };
1031
1460
  setErrors((currentErrors) => {
1032
1461
  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);
1462
+ const result = validationPageIndex === null ? (0, import_core3.validateAnswers)(validSchema, next) : (0, import_core3.validatePageAnswers)(validSchema, validationPageIndex, next);
1034
1463
  return issuesByField(result.issues);
1035
1464
  });
1036
1465
  return next;
@@ -1040,7 +1469,7 @@ function FormProvider({
1040
1469
  },
1041
1470
  [validSchema, validationPageIndex]
1042
1471
  );
1043
- const restoreValues = (0, import_react2.useCallback)(
1472
+ const restoreValues = (0, import_react3.useCallback)(
1044
1473
  (restoredValues) => {
1045
1474
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1046
1475
  setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
@@ -1051,9 +1480,9 @@ function FormProvider({
1051
1480
  },
1052
1481
  [validSchema]
1053
1482
  );
1054
- const validatePage = (0, import_react2.useCallback)(
1483
+ const validatePage = (0, import_react3.useCallback)(
1055
1484
  (pageIndex) => {
1056
- const result = (0, import_core2.validatePageAnswers)(validSchema, pageIndex, values);
1485
+ const result = (0, import_core3.validatePageAnswers)(validSchema, pageIndex, values);
1057
1486
  setErrors(issuesByField(result.issues));
1058
1487
  setValidationPageIndex(result.valid ? null : pageIndex);
1059
1488
  setSubmitStatus("idle");
@@ -1062,42 +1491,51 @@ function FormProvider({
1062
1491
  },
1063
1492
  [validSchema, values]
1064
1493
  );
1065
- const reset = (0, import_react2.useCallback)(() => {
1494
+ const reset = (0, import_react3.useCallback)(() => {
1066
1495
  setValues({ ...initialValues });
1067
1496
  setErrors({});
1068
1497
  setValidationPageIndex(null);
1069
1498
  setSubmitStatus("idle");
1070
1499
  setSubmitError(null);
1071
1500
  }, [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));
1501
+ const submit = (0, import_react3.useCallback)(
1502
+ async (beforeSubmit) => {
1503
+ const validation = (0, import_core3.validateAnswers)(validSchema, values);
1504
+ if (!validation.valid) {
1505
+ setErrors(issuesByField(validation.issues));
1506
+ setValidationPageIndex(null);
1507
+ setSubmitStatus("error");
1508
+ setSubmitError(null);
1509
+ return { status: "invalid", issues: validation.issues };
1510
+ }
1511
+ setErrors({});
1076
1512
  setValidationPageIndex(null);
1077
- setSubmitStatus("error");
1078
1513
  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)(
1514
+ const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
1515
+ try {
1516
+ if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
1517
+ setSubmitStatus("idle");
1518
+ return { status: "cancelled" };
1519
+ }
1520
+ setSubmitStatus("submitting");
1521
+ await onSubmit(visibleValues);
1522
+ if (resetOnSuccess) setValues({ ...initialValues });
1523
+ setSubmitStatus("success");
1524
+ return { status: "success" };
1525
+ } catch (cause) {
1526
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1527
+ setSubmitError(error);
1528
+ setSubmitStatus("error");
1529
+ return { status: "error", error };
1530
+ }
1531
+ },
1532
+ [initialValues, onSubmit, resetOnSuccess, validSchema, values]
1533
+ );
1534
+ const translate = (0, import_react3.useCallback)(
1097
1535
  (key, params) => translator.translate(key, locale, params),
1098
1536
  [locale, translator]
1099
1537
  );
1100
- const contextValue = (0, import_react2.useMemo)(
1538
+ const contextValue = (0, import_react3.useMemo)(
1101
1539
  () => ({
1102
1540
  schema: validSchema,
1103
1541
  locale,
@@ -1137,7 +1575,7 @@ function FormProvider({
1137
1575
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FormContext.Provider, { value: contextValue, children });
1138
1576
  }
1139
1577
  function useForm() {
1140
- const context = (0, import_react2.useContext)(FormContext);
1578
+ const context = (0, import_react3.useContext)(FormContext);
1141
1579
  if (context === null) throw new Error("useForm must be called inside a FormProvider.");
1142
1580
  return context;
1143
1581
  }
@@ -1145,13 +1583,13 @@ function useField(fieldId) {
1145
1583
  const form = useForm();
1146
1584
  const field = form.schema.fields.find((item) => item.id === fieldId);
1147
1585
  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]);
1586
+ const setValue = (0, import_react3.useCallback)((value) => form.setValue(fieldId, value), [fieldId, form]);
1149
1587
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
1150
1588
  }
1151
1589
 
1152
1590
  // src/renderer.tsx
1153
- var import_core3 = require("@form-engine-ts/core");
1154
- var import_react3 = require("react");
1591
+ var import_core4 = require("@form-engine-ts/core");
1592
+ var import_react4 = require("react");
1155
1593
  var import_jsx_runtime3 = require("react/jsx-runtime");
1156
1594
  function describedBy(field, error, helpId, errorId) {
1157
1595
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
@@ -1354,36 +1792,39 @@ function parseDraft(serialized) {
1354
1792
  return null;
1355
1793
  }
1356
1794
  }
1357
- function FormRenderer({
1795
+ function ContextFormRenderer({
1358
1796
  components = {},
1359
1797
  className = "",
1360
1798
  successMessageKey,
1361
1799
  errorMessageKey,
1362
- autoSaveKey
1800
+ autoSaveKey,
1801
+ beforeSubmit,
1802
+ onDraftSave,
1803
+ slots = {}
1363
1804
  }) {
1364
1805
  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);
1806
+ const prefix = (0, import_react4.useId)().replace(/:/g, "");
1807
+ const formRef = (0, import_react4.useRef)(null);
1808
+ const loadedDraftKey = (0, import_react4.useRef)(null);
1809
+ const [draftRestored, setDraftRestored] = (0, import_react4.useState)(false);
1810
+ const [currentPageIndex, setCurrentPageIndex] = (0, import_react4.useState)(0);
1811
+ const [focusFieldId, setFocusFieldId] = (0, import_react4.useState)(null);
1371
1812
  const pages = form.schema.pages;
1372
- const visiblePageIndexes = (0, import_react3.useMemo)(
1813
+ const visiblePageIndexes = (0, import_react4.useMemo)(
1373
1814
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1374
1815
  [form.pageVisibility, pages]
1375
1816
  );
1376
1817
  const activePage = pages?.[currentPageIndex];
1377
1818
  const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
1378
1819
  const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
1379
- (0, import_react3.useEffect)(() => {
1820
+ (0, import_react4.useEffect)(() => {
1380
1821
  if (pages === void 0 || visiblePageIndexes.length === 0) {
1381
1822
  setCurrentPageIndex(0);
1382
1823
  return;
1383
1824
  }
1384
1825
  if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1385
1826
  }, [currentPageIndex, pages, visiblePageIndexes]);
1386
- (0, import_react3.useEffect)(() => {
1827
+ (0, import_react4.useEffect)(() => {
1387
1828
  if (focusFieldId === null) return;
1388
1829
  const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
1389
1830
  (element) => element.dataset.fieldId === focusFieldId
@@ -1394,7 +1835,7 @@ function FormRenderer({
1394
1835
  setFocusFieldId(null);
1395
1836
  }
1396
1837
  }, [focusFieldId]);
1397
- (0, import_react3.useEffect)(() => {
1838
+ (0, import_react4.useEffect)(() => {
1398
1839
  if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1399
1840
  const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
1400
1841
  if (loadedDraftKey.current === loadIdentity) return;
@@ -1406,10 +1847,11 @@ function FormRenderer({
1406
1847
  form.restoreValues(draft.values);
1407
1848
  setDraftRestored(true);
1408
1849
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1409
- (0, import_react3.useEffect)(() => {
1410
- if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1850
+ (0, import_react4.useEffect)(() => {
1411
1851
  if (form.submitStatus === "success") return;
1412
1852
  const timeout = globalThis.setTimeout(() => {
1853
+ onDraftSave?.(form.values);
1854
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1413
1855
  const draft = {
1414
1856
  formId: form.schema.id,
1415
1857
  formVersion: form.schema.version,
@@ -1419,7 +1861,7 @@ function FormRenderer({
1419
1861
  globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1420
1862
  }, 500);
1421
1863
  return () => globalThis.clearTimeout(timeout);
1422
- }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values]);
1864
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values, onDraftSave]);
1423
1865
  const focusFirstIssue = (fieldId) => {
1424
1866
  if (fieldId !== void 0) setFocusFieldId(fieldId);
1425
1867
  };
@@ -1432,27 +1874,39 @@ function FormRenderer({
1432
1874
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1433
1875
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1434
1876
  };
1435
- const handleSubmit = async (event) => {
1436
- event.preventDefault();
1437
- const validation = (0, import_core3.validateAnswers)(form.schema, form.values);
1877
+ const submitValues = async () => {
1878
+ const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
1438
1879
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
1439
- const valid = await form.submit();
1440
- if (!valid) {
1880
+ const result = await form.submit(beforeSubmit);
1881
+ if (result.status === "invalid") {
1441
1882
  const invalidPageIndex = pages?.findIndex(
1442
1883
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1443
1884
  );
1444
1885
  if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1445
1886
  focusFirstIssue(firstInvalidFieldId);
1446
- return;
1887
+ return result;
1447
1888
  }
1889
+ if (result.status !== "success") return result;
1448
1890
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1449
1891
  globalThis.localStorage.removeItem(autoSaveKey);
1450
1892
  setDraftRestored(false);
1451
1893
  }
1452
1894
  setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1895
+ return result;
1896
+ };
1897
+ const handleSubmit = (event) => {
1898
+ event.preventDefault();
1899
+ void submitValues();
1453
1900
  };
1901
+ const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
1902
+ const canPrev = pages !== void 0 && activeVisibleIndex > 0;
1903
+ const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
1904
+ 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
1905
  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: [
1906
+ slots.renderHeader?.({
1907
+ title: form.schema.title,
1908
+ ...form.schema.description === void 0 ? {} : { description: form.schema.description }
1909
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("header", { className: "fe-header", children: [
1456
1910
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { children: form.schema.title }),
1457
1911
  form.schema.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: form.schema.description }),
1458
1912
  pages === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-progress", children: [
@@ -1477,45 +1931,131 @@ function FormRenderer({
1477
1931
  ] }),
1478
1932
  draftRestored ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
1479
1933
  ] }),
1480
- activePage?.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1481
- activePage?.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description }),
1934
+ activePage === void 0 ? null : slots.renderPageHeader?.({
1935
+ page: activePage,
1936
+ pageIndex: activeVisibleIndex,
1937
+ totalPages: visiblePageIndexes.length
1938
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-page-header", children: [
1939
+ activePage.title === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h2", { className: "fe-page-title", children: activePage.title }),
1940
+ activePage.description === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "fe-page-description", children: activePage.description })
1941
+ ] }),
1482
1942
  /* @__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) => {
1943
+ const error = form.errors[field.id];
1483
1944
  const props = {
1484
1945
  field,
1485
1946
  value: form.values[field.id],
1486
- error: form.errors[field.id],
1947
+ error,
1487
1948
  setValue: (value) => form.setValue(field.id, value),
1488
1949
  translate: form.translate,
1489
1950
  inputId: `${prefix}-${field.id}`,
1490
1951
  errorId: `${prefix}-${field.id}-error`,
1491
1952
  helpId: `${prefix}-${field.id}-help`
1492
1953
  };
1954
+ if (slots.renderField !== void 0) {
1955
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react4.Fragment, { children: slots.renderField({
1956
+ question: field,
1957
+ value: form.values[field.id],
1958
+ onChange: (value) => {
1959
+ if (isFormValue(value)) form.setValue(field.id, value);
1960
+ },
1961
+ ...error === void 0 ? {} : { error }
1962
+ }) }, field.id);
1963
+ }
1493
1964
  const Component = components[field.type];
1494
1965
  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
1966
  }) }),
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") })
1967
+ validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
1968
+ validationIssues.length,
1969
+ " validation error",
1970
+ validationIssues.length === 1 ? "" : "s",
1971
+ "."
1972
+ ] }),
1973
+ pages === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1974
+ slots.renderNavigation?.({
1975
+ currentPage: 0,
1976
+ totalPages: 1,
1977
+ canPrev: false,
1978
+ canNext: false,
1979
+ onPrev: () => void 0,
1980
+ onNext: () => void 0
1981
+ }),
1982
+ renderSubmitButton()
1983
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "form-step-navigation", children: [
1984
+ slots.renderNavigation?.({
1985
+ currentPage: activeVisibleIndex,
1986
+ totalPages: visiblePageIndexes.length,
1987
+ canPrev,
1988
+ canNext,
1989
+ onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1990
+ onNext: handleNext
1991
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1992
+ canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
1993
+ "button",
1994
+ {
1995
+ className: "btn-prev",
1996
+ type: "button",
1997
+ onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1998
+ children: form.translate("form.back")
1999
+ }
2000
+ ) : null,
2001
+ canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
2002
+ ] }),
2003
+ canNext ? null : renderSubmitButton()
1507
2004
  ] }),
1508
2005
  /* @__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,
1510
- form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
2006
+ form.submitStatus === "success" ? slots.renderCompletion?.({
2007
+ message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
2008
+ }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
2009
+ form.submitStatus === "error" && form.submitError !== null ? slots.renderSubmitError?.({ error: form.submitError, onRetry: () => void submitValues() }) ?? (errorMessageKey === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: form.translate(errorMessageKey) })) : null
1511
2010
  ] })
1512
2011
  ] });
1513
2012
  }
2013
+ var RENDERER_MESSAGES = {
2014
+ "form.submit": "Submit",
2015
+ "form.back": "Back",
2016
+ "form.next": "Next",
2017
+ "form.step": "Step {{current}} / {{total}}",
2018
+ "form.draftRestored": "Draft restored",
2019
+ "validation.required": "This field is required."
2020
+ };
2021
+ var defaultRendererTranslator = {
2022
+ translate(key, _locale, params = {}) {
2023
+ return (RENDERER_MESSAGES[key] ?? key).replace(
2024
+ /\{\{(\w+)\}\}/g,
2025
+ (token, name) => Object.hasOwn(params, name) ? String(params[name]) : token
2026
+ );
2027
+ }
2028
+ };
2029
+ function FormRenderer(props) {
2030
+ if (!("schema" in props)) return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ContextFormRenderer, { ...props });
2031
+ const {
2032
+ schema,
2033
+ locale = schema.defaultLocale ?? "en",
2034
+ translator = defaultRendererTranslator,
2035
+ initialValues,
2036
+ resetOnSuccess,
2037
+ onSubmit,
2038
+ ...rendererProps
2039
+ } = props;
2040
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
2041
+ FormProvider,
2042
+ {
2043
+ schema,
2044
+ locale,
2045
+ translator,
2046
+ onSubmit,
2047
+ ...initialValues === void 0 ? {} : { initialValues },
2048
+ ...resetOnSuccess === void 0 ? {} : { resetOnSuccess },
2049
+ children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(ContextFormRenderer, { ...rendererProps })
2050
+ }
2051
+ );
2052
+ }
1514
2053
  // Annotate the CommonJS export names for ESM import in node:
1515
2054
  0 && (module.exports = {
1516
2055
  FormBuilder,
1517
2056
  FormProvider,
1518
2057
  FormRenderer,
1519
2058
  useField,
1520
- useForm
2059
+ useForm,
2060
+ useFormBuilder
1521
2061
  });