@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.js CHANGED
@@ -1,9 +1,620 @@
1
1
  // src/builder.tsx
2
2
  import {
3
- populateSchemaTranslations,
4
- sanitizeSchema
3
+ populateSchemaTranslations
5
4
  } from "@form-engine-ts/core";
6
5
  import { useState } from "react";
6
+
7
+ // src/hooks/useFormBuilder.ts
8
+ import {
9
+ transformFieldType,
10
+ validateFormSchema
11
+ } from "@form-engine-ts/core";
12
+ import { useCallback, useMemo } from "react";
13
+ var DEFAULT_PREFIXES = { field: "q", option: "opt", page: "page" };
14
+ var CHOICE_TYPES = ["select", "radio", "multi-select"];
15
+ function defaultIdFactory(kind, existingIds) {
16
+ const prefix = DEFAULT_PREFIXES[kind];
17
+ let id;
18
+ do
19
+ id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
20
+ while (existingIds.has(id));
21
+ return id;
22
+ }
23
+ function defaultCreateField(type, id) {
24
+ const base = { id, title: "New question", required: false };
25
+ if (type === "text" || type === "textarea" || type === "number" || type === "checkbox") return { ...base, type };
26
+ if (type === "rating") return { ...base, type, min: 1, max: 5 };
27
+ return { ...base, type, options: [] };
28
+ }
29
+ function defaultCreateOption(field, id) {
30
+ return { id, label: `Option ${"options" in field ? field.options.length + 1 : 1}` };
31
+ }
32
+ function defaultCreatePage(id, questionIds) {
33
+ return { id, title: "New page", questionIds };
34
+ }
35
+ function move(items, sourceIndex, targetIndex) {
36
+ if (sourceIndex < 0 || targetIndex < 0 || targetIndex >= items.length || sourceIndex === targetIndex)
37
+ return void 0;
38
+ const result = [...items];
39
+ const [item] = result.splice(sourceIndex, 1);
40
+ if (item === void 0) return void 0;
41
+ result.splice(targetIndex, 0, item);
42
+ return result;
43
+ }
44
+ function withoutDisplayCondition(field) {
45
+ const { displayCondition: _displayCondition, ...rest } = field;
46
+ return rest;
47
+ }
48
+ function removeLocalizedProperty(translations, locale, property) {
49
+ const current = translations?.[locale];
50
+ if (current === void 0) return translations;
51
+ const { [property]: _removed, ...remaining } = current;
52
+ return { ...translations, [locale]: remaining };
53
+ }
54
+ function setTranslationMetadata(node, locale, property, metadata, remove) {
55
+ const localeMetadata = node.translationMetadata?.[locale];
56
+ if (metadata === void 0 && !remove) return node;
57
+ const nextLocaleMetadata = remove ? Object.fromEntries(Object.entries(localeMetadata ?? {}).filter(([key]) => key !== property)) : { ...localeMetadata, [property]: metadata ?? {} };
58
+ return { ...node, translationMetadata: { ...node.translationMetadata, [locale]: nextLocaleMetadata } };
59
+ }
60
+ function failedId(kind, result) {
61
+ return { success: false, error: result.error ?? { type: "invalid_id", kind, id: "" } };
62
+ }
63
+ function useFormBuilder({
64
+ schema,
65
+ onChange,
66
+ policy,
67
+ idFactory = defaultIdFactory,
68
+ factories = {}
69
+ }) {
70
+ const createId = useCallback(
71
+ (kind, existingIds) => {
72
+ const rawId = idFactory(kind, existingIds);
73
+ const id = rawId.trim();
74
+ return id.length > 0 && !existingIds.has(id) ? { id } : { error: { type: "invalid_id", kind, id: rawId } };
75
+ },
76
+ [idFactory]
77
+ );
78
+ const textPolicyError = useCallback(
79
+ (text) => policy?.maxTextLength !== void 0 && text.length > policy.maxTextLength ? { success: false, error: { type: "max_text_length_exceeded", max: policy.maxTextLength } } : void 0,
80
+ [policy?.maxTextLength]
81
+ );
82
+ const updateField = useCallback(
83
+ (fieldId, updater) => {
84
+ const current = schema.fields.find((field) => field.id === fieldId);
85
+ if (current === void 0)
86
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
87
+ const updated = updater(current);
88
+ if (updated.id !== fieldId)
89
+ return { success: false, error: { type: "invalid_id", kind: "field", id: updated.id } };
90
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(updated.type))
91
+ return { success: false, error: { type: "disallowed_field_type", fieldType: updated.type } };
92
+ for (const text of [updated.title, updated.description]) {
93
+ if (text !== void 0) {
94
+ const error = textPolicyError(text);
95
+ if (error !== void 0) return error;
96
+ }
97
+ }
98
+ onChange({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? updated : field) });
99
+ return { success: true };
100
+ },
101
+ [onChange, policy?.allowedFieldTypes, schema, textPolicyError]
102
+ );
103
+ const updateOption = useCallback(
104
+ (fieldId, optionId, updater) => {
105
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
106
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
107
+ if (!("options" in field))
108
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
109
+ const current = field.options.find((option) => option.id === optionId);
110
+ if (current === void 0)
111
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
112
+ const updated = updater(current);
113
+ if (updated.id !== optionId)
114
+ return { success: false, error: { type: "invalid_id", kind: "option", id: updated.id } };
115
+ const error = textPolicyError(updated.label);
116
+ if (error !== void 0) return error;
117
+ onChange({
118
+ ...schema,
119
+ fields: schema.fields.map(
120
+ (candidate) => candidate.id === fieldId && "options" in candidate ? {
121
+ ...candidate,
122
+ options: candidate.options.map((option) => option.id === optionId ? updated : option)
123
+ } : candidate
124
+ )
125
+ });
126
+ return { success: true };
127
+ },
128
+ [onChange, schema, textPolicyError]
129
+ );
130
+ const updatePage = useCallback(
131
+ (pageId, updater) => {
132
+ const page = schema.pages?.find((candidate) => candidate.id === pageId);
133
+ if (page === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
134
+ const pages = schema.pages;
135
+ if (pages === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
136
+ const updated = updater(page);
137
+ if (updated.id !== pageId) return { success: false, error: { type: "invalid_id", kind: "page", id: updated.id } };
138
+ onChange({ ...schema, pages: pages.map((candidate) => candidate.id === pageId ? updated : candidate) });
139
+ return { success: true };
140
+ },
141
+ [onChange, schema]
142
+ );
143
+ const addField = useCallback(
144
+ (type, pageId) => {
145
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
146
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
147
+ if (policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields)
148
+ return { success: false, error: { type: "max_fields_exceeded", max: policy.maxFields } };
149
+ if (pageId !== void 0 && !schema.pages?.some((page) => page.id === pageId))
150
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
151
+ const fieldIds = new Set(schema.fields.map((field2) => field2.id));
152
+ const fieldId = createId("field", fieldIds);
153
+ if (fieldId.id === void 0) return failedId("field", fieldId);
154
+ let field = (factories.createField ?? defaultCreateField)(type, fieldId.id);
155
+ if (field.id !== fieldId.id || field.type !== type || fieldIds.has(field.id))
156
+ return { success: false, error: { type: "invalid_id", kind: "field", id: field.id } };
157
+ if (CHOICE_TYPES.includes(type) && "options" in field && field.options.length === 0) {
158
+ const optionIds = new Set(
159
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
160
+ );
161
+ const optionId = createId("option", optionIds);
162
+ if (optionId.id === void 0) return failedId("option", optionId);
163
+ const option = (factories.createOption ?? defaultCreateOption)(field, optionId.id);
164
+ if (option.id !== optionId.id || optionIds.has(option.id))
165
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
166
+ field = { ...field, options: [option] };
167
+ }
168
+ const pages = schema.pages?.map((page, index) => ({
169
+ ...page,
170
+ questionIds: page.id === pageId || pageId === void 0 && index === (schema.pages?.length ?? 0) - 1 ? [...page.questionIds, field.id] : page.questionIds
171
+ }));
172
+ onChange({ ...schema, fields: [...schema.fields, field], ...pages === void 0 ? {} : { pages } });
173
+ return { success: true };
174
+ },
175
+ [createId, factories, onChange, policy, schema]
176
+ );
177
+ const removeField = useCallback(
178
+ (fieldId) => {
179
+ if (!schema.fields.some((field) => field.id === fieldId))
180
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
181
+ if (schema.fields.length <= 1)
182
+ return { success: false, error: { type: "invalid_operation", message: "A form must contain one field." } };
183
+ const fields = schema.fields.filter((field) => field.id !== fieldId).map((field) => field.displayCondition?.questionId === fieldId ? withoutDisplayCondition(field) : field);
184
+ const pages = schema.pages?.map((page) => ({ ...page, questionIds: page.questionIds.filter((id) => id !== fieldId) })).filter((page) => page.questionIds.length > 0);
185
+ if (schema.pages !== void 0 && pages?.length === 0) {
186
+ const { pages: _pages, ...single } = schema;
187
+ onChange({ ...single, fields });
188
+ } else onChange({ ...schema, fields, ...pages === void 0 ? {} : { pages } });
189
+ return { success: true };
190
+ },
191
+ [onChange, schema]
192
+ );
193
+ const moveField = useCallback(
194
+ (fieldId, targetIndex) => {
195
+ const sourceIndex = schema.fields.findIndex((field) => field.id === fieldId);
196
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
197
+ const fields = move(schema.fields, sourceIndex, targetIndex);
198
+ if (fields === void 0)
199
+ return { success: false, error: { type: "invalid_operation", message: "Invalid field position." } };
200
+ const indexById = new Map(fields.map((field, index) => [field.id, index]));
201
+ onChange({
202
+ ...schema,
203
+ fields: fields.map((field, index) => {
204
+ const source = field.displayCondition?.questionId;
205
+ return source === void 0 || (indexById.get(source) ?? index) < index ? field : withoutDisplayCondition(field);
206
+ })
207
+ });
208
+ return { success: true };
209
+ },
210
+ [onChange, schema]
211
+ );
212
+ const addOption = useCallback(
213
+ (fieldId) => {
214
+ const field = schema.fields.find((candidate) => candidate.id === fieldId);
215
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
216
+ if (!("options" in field))
217
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
218
+ if (policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField)
219
+ return { success: false, error: { type: "max_options_exceeded", max: policy.maxOptionsPerField } };
220
+ const ids = new Set(
221
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
222
+ );
223
+ const id = createId("option", ids);
224
+ if (id.id === void 0) return failedId("option", id);
225
+ const option = (factories.createOption ?? defaultCreateOption)(field, id.id);
226
+ if (option.id !== id.id || ids.has(option.id))
227
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
228
+ onChange({
229
+ ...schema,
230
+ fields: schema.fields.map(
231
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: [...item.options, option] } : item
232
+ )
233
+ });
234
+ return { success: true };
235
+ },
236
+ [createId, factories.createOption, onChange, policy?.maxOptionsPerField, schema]
237
+ );
238
+ const removeOption = useCallback(
239
+ (fieldId, optionId) => {
240
+ const field = schema.fields.find((item) => item.id === fieldId);
241
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
242
+ if (!("options" in field) || !field.options.some((option) => option.id === optionId))
243
+ return { success: false, error: { type: "node_not_found", kind: "option", id: optionId } };
244
+ if (field.options.length <= 1)
245
+ return { success: false, error: { type: "invalid_operation", message: "A choice field needs one option." } };
246
+ onChange({
247
+ ...schema,
248
+ fields: schema.fields.map(
249
+ (item) => item.id === fieldId && "options" in item ? { ...item, options: item.options.filter((option) => option.id !== optionId) } : item
250
+ )
251
+ });
252
+ return { success: true };
253
+ },
254
+ [onChange, schema]
255
+ );
256
+ const moveOption = useCallback(
257
+ (fieldId, optionId, targetIndex) => {
258
+ const field = schema.fields.find((item) => item.id === fieldId);
259
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
260
+ if (!("options" in field))
261
+ return { success: false, error: { type: "invalid_operation", message: `Field ${fieldId} has no options.` } };
262
+ const options = move(
263
+ field.options,
264
+ field.options.findIndex((option) => option.id === optionId),
265
+ targetIndex
266
+ );
267
+ if (options === void 0)
268
+ return { success: false, error: { type: "invalid_operation", message: "Invalid option position." } };
269
+ onChange({
270
+ ...schema,
271
+ fields: schema.fields.map(
272
+ (item) => item.id === fieldId && "options" in item ? { ...item, options } : item
273
+ )
274
+ });
275
+ return { success: true };
276
+ },
277
+ [onChange, schema]
278
+ );
279
+ const changeFieldType = useCallback(
280
+ (fieldId, type) => {
281
+ const field = schema.fields.find((item) => item.id === fieldId);
282
+ if (field === void 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
283
+ if (policy?.allowedFieldTypes !== void 0 && !policy.allowedFieldTypes.includes(type))
284
+ return { success: false, error: { type: "disallowed_field_type", fieldType: type } };
285
+ let transformed = transformFieldType(field, type);
286
+ if (CHOICE_TYPES.includes(type) && !("options" in field) && "options" in transformed) {
287
+ const ids = new Set(
288
+ schema.fields.flatMap((item) => "options" in item ? item.options.map((option2) => option2.id) : [])
289
+ );
290
+ const id = createId("option", ids);
291
+ if (id.id === void 0) return failedId("option", id);
292
+ const option = (factories.createOption ?? defaultCreateOption)(transformed, id.id);
293
+ if (option.id !== id.id || ids.has(option.id))
294
+ return { success: false, error: { type: "invalid_id", kind: "option", id: option.id } };
295
+ transformed = { ...transformed, options: [option] };
296
+ }
297
+ onChange({ ...schema, fields: schema.fields.map((item) => item.id === fieldId ? transformed : item) });
298
+ return { success: true };
299
+ },
300
+ [createId, factories.createOption, onChange, policy?.allowedFieldTypes, schema]
301
+ );
302
+ const addPage = useCallback(
303
+ (questionId) => {
304
+ const ids = new Set(schema.pages?.map((page2) => page2.id) ?? []);
305
+ const id = createId("page", ids);
306
+ if (id.id === void 0) return failedId("page", id);
307
+ 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(
308
+ (value) => value !== void 0
309
+ );
310
+ if (questionIds.length === 0)
311
+ return { success: false, error: { type: "invalid_operation", message: "No question can be moved." } };
312
+ const source = schema.pages?.find((page2) => page2.questionIds.includes(questionIds[0] ?? ""));
313
+ if (schema.pages !== void 0 && (source === void 0 || source.questionIds.length <= 1))
314
+ return { success: false, error: { type: "invalid_operation", message: "A page cannot be left empty." } };
315
+ const page = (factories.createPage ?? defaultCreatePage)(id.id, [...questionIds]);
316
+ if (page.id !== id.id || ids.has(page.id))
317
+ return { success: false, error: { type: "invalid_id", kind: "page", id: page.id } };
318
+ const pages = schema.pages === void 0 ? [page] : [
319
+ ...schema.pages.map((item) => ({
320
+ ...item,
321
+ questionIds: item.questionIds.filter((fieldId) => !questionIds.includes(fieldId))
322
+ })),
323
+ page
324
+ ];
325
+ onChange({ ...schema, pages });
326
+ return { success: true };
327
+ },
328
+ [createId, factories.createPage, onChange, schema]
329
+ );
330
+ const removePage = useCallback(
331
+ (pageId) => {
332
+ const index = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
333
+ const removed = schema.pages?.[index];
334
+ if (removed === void 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
335
+ const currentPages = schema.pages;
336
+ if (currentPages === void 0)
337
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
338
+ if ((schema.pages?.length ?? 0) === 1) {
339
+ const { pages: _pages, ...single } = schema;
340
+ onChange(single);
341
+ return { success: true };
342
+ }
343
+ const targetIndex = index === 0 ? 1 : index - 1;
344
+ onChange({
345
+ ...schema,
346
+ pages: currentPages.map(
347
+ (page, pageIndex) => pageIndex === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
348
+ ).filter((page) => page.id !== pageId)
349
+ });
350
+ return { success: true };
351
+ },
352
+ [onChange, schema]
353
+ );
354
+ const movePage = useCallback(
355
+ (pageId, targetIndex) => {
356
+ const sourceIndex = schema.pages?.findIndex((page) => page.id === pageId) ?? -1;
357
+ if (sourceIndex < 0) return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
358
+ const pages = move(schema.pages ?? [], sourceIndex, targetIndex);
359
+ if (pages === void 0)
360
+ return { success: false, error: { type: "invalid_operation", message: "Invalid page position." } };
361
+ const assignment = new Map(pages.flatMap((page, index) => page.questionIds.map((id) => [id, index])));
362
+ onChange({
363
+ ...schema,
364
+ pages: pages.map((page, index) => {
365
+ const source = page.displayCondition?.questionId;
366
+ if (source === void 0 || (assignment.get(source) ?? index) < index) return page;
367
+ const { displayCondition: _condition, ...rest } = page;
368
+ return rest;
369
+ })
370
+ });
371
+ return { success: true };
372
+ },
373
+ [onChange, schema]
374
+ );
375
+ const assignFieldToPage = useCallback(
376
+ (fieldId, pageId) => {
377
+ if (!schema.fields.some((field) => field.id === fieldId))
378
+ return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
379
+ if (pageId === null) {
380
+ if (schema.pages !== void 0) {
381
+ const { pages: _pages, ...single } = schema;
382
+ onChange(single);
383
+ }
384
+ return { success: true };
385
+ }
386
+ if (!schema.pages?.some((page) => page.id === pageId))
387
+ return { success: false, error: { type: "node_not_found", kind: "page", id: pageId } };
388
+ const pages = schema.pages.map((page) => ({
389
+ ...page,
390
+ 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)
391
+ })).filter((page) => page.questionIds.length > 0);
392
+ onChange({ ...schema, pages });
393
+ return { success: true };
394
+ },
395
+ [onChange, schema]
396
+ );
397
+ const setDisplayCondition = useCallback(
398
+ (fieldId, condition) => {
399
+ const index = schema.fields.findIndex((field) => field.id === fieldId);
400
+ if (index < 0) return { success: false, error: { type: "node_not_found", kind: "field", id: fieldId } };
401
+ if (condition !== void 0) {
402
+ const sourceIndex = schema.fields.findIndex((field) => field.id === condition.questionId);
403
+ if (sourceIndex < 0 || sourceIndex >= index)
404
+ return {
405
+ success: false,
406
+ error: { type: "invalid_operation", message: "Conditions require an earlier field." }
407
+ };
408
+ }
409
+ onChange({
410
+ ...schema,
411
+ fields: schema.fields.map(
412
+ (field) => field.id !== fieldId ? field : condition === void 0 ? withoutDisplayCondition(field) : { ...field, displayCondition: condition }
413
+ )
414
+ });
415
+ return { success: true };
416
+ },
417
+ [onChange, schema]
418
+ );
419
+ const setSourceText = useCallback(
420
+ (target, property, text) => {
421
+ const error = textPolicyError(text);
422
+ if (error !== void 0) return error;
423
+ if (target.kind === "form") {
424
+ if (!["title", "description", "completionMessage"].includes(property))
425
+ return {
426
+ success: false,
427
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
428
+ };
429
+ if (property === "title") onChange({ ...schema, title: text });
430
+ else if (property === "description") {
431
+ const { description: _description, ...withoutDescription } = schema;
432
+ onChange(text.length === 0 ? withoutDescription : { ...schema, description: text });
433
+ } else {
434
+ const { completionMessage: _completionMessage, ...withoutCompletionMessage } = schema;
435
+ onChange(text.length === 0 ? withoutCompletionMessage : { ...schema, completionMessage: text });
436
+ }
437
+ return { success: true };
438
+ }
439
+ if (target.id === void 0)
440
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
441
+ if (target.kind === "field") {
442
+ if (!["title", "description"].includes(property))
443
+ return {
444
+ success: false,
445
+ error: { type: "invalid_operation", message: `Unsupported field property: ${property}` }
446
+ };
447
+ return updateField(target.id, (field) => {
448
+ if (property === "title") return { ...field, title: text };
449
+ const { description: _description, ...withoutDescription } = field;
450
+ return text.length === 0 ? withoutDescription : { ...field, description: text };
451
+ });
452
+ }
453
+ if (target.kind === "option") {
454
+ if (property !== "label")
455
+ return {
456
+ success: false,
457
+ error: { type: "invalid_operation", message: `Unsupported option property: ${property}` }
458
+ };
459
+ for (const field of schema.fields)
460
+ if ("options" in field && field.options.some((option) => option.id === target.id))
461
+ return updateOption(field.id, target.id, (option) => ({ ...option, label: text }));
462
+ return { success: false, error: { type: "node_not_found", kind: "option", id: target.id } };
463
+ }
464
+ if (!["title", "description"].includes(property))
465
+ return {
466
+ success: false,
467
+ error: { type: "invalid_operation", message: `Unsupported page property: ${property}` }
468
+ };
469
+ return updatePage(target.id, (page) => {
470
+ if (property === "title") {
471
+ const { title: _title, ...withoutTitle } = page;
472
+ return text.length === 0 ? withoutTitle : { ...page, title: text };
473
+ }
474
+ const { description: _description, ...withoutDescription } = page;
475
+ return text.length === 0 ? withoutDescription : { ...page, description: text };
476
+ });
477
+ },
478
+ [onChange, schema, textPolicyError, updateField, updateOption, updatePage]
479
+ );
480
+ const setLocaleTranslation = useCallback(
481
+ (locale, target, property, text, options = {}) => {
482
+ const normalized = locale.trim();
483
+ if (normalized.length === 0)
484
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
485
+ const error = textPolicyError(text);
486
+ if (error !== void 0) return error;
487
+ const supportedLocales = [.../* @__PURE__ */ new Set([...schema.supportedLocales ?? [], normalized])];
488
+ const remove = text.length === 0;
489
+ if (target.kind === "form") {
490
+ if (!["title", "description", "completionMessage"].includes(property))
491
+ return {
492
+ success: false,
493
+ error: { type: "invalid_operation", message: `Unsupported form property: ${property}` }
494
+ };
495
+ const key = property;
496
+ const translations = remove ? removeLocalizedProperty(schema.translations, normalized, key) : { ...schema.translations, [normalized]: { ...schema.translations?.[normalized], [key]: text } };
497
+ onChange(
498
+ setTranslationMetadata(
499
+ { ...schema, supportedLocales, ...translations === void 0 ? {} : { translations } },
500
+ normalized,
501
+ property,
502
+ options.metadata,
503
+ remove
504
+ )
505
+ );
506
+ return { success: true };
507
+ }
508
+ if (target.id === void 0)
509
+ return { success: false, error: { type: "invalid_operation", message: "A target ID is required." } };
510
+ let found = false;
511
+ const fields = schema.fields.map((field) => {
512
+ if (target.kind === "field" && field.id === target.id && ["title", "description"].includes(property)) {
513
+ found = true;
514
+ const key = property;
515
+ const translations = remove ? removeLocalizedProperty(field.translations, normalized, key) : { ...field.translations, [normalized]: { ...field.translations?.[normalized], [key]: text } };
516
+ return setTranslationMetadata(
517
+ { ...field, ...translations === void 0 ? {} : { translations } },
518
+ normalized,
519
+ property,
520
+ options.metadata,
521
+ remove
522
+ );
523
+ }
524
+ if (target.kind !== "option" || property !== "label" || !("options" in field)) return field;
525
+ return {
526
+ ...field,
527
+ options: field.options.map((option) => {
528
+ if (option.id !== target.id) return option;
529
+ found = true;
530
+ const translations = remove ? Object.fromEntries(Object.entries(option.translations ?? {}).filter(([key]) => key !== normalized)) : { ...option.translations, [normalized]: text };
531
+ return setTranslationMetadata({ ...option, translations }, normalized, property, options.metadata, remove);
532
+ })
533
+ };
534
+ });
535
+ const pages = schema.pages?.map((page) => {
536
+ if (target.kind !== "page" || page.id !== target.id || !["title", "description"].includes(property))
537
+ return page;
538
+ found = true;
539
+ const key = property;
540
+ const translations = remove ? removeLocalizedProperty(page.translations, normalized, key) : { ...page.translations, [normalized]: { ...page.translations?.[normalized], [key]: text } };
541
+ return setTranslationMetadata(
542
+ { ...page, ...translations === void 0 ? {} : { translations } },
543
+ normalized,
544
+ property,
545
+ options.metadata,
546
+ remove
547
+ );
548
+ });
549
+ if (!found) return { success: false, error: { type: "node_not_found", kind: target.kind, id: target.id } };
550
+ onChange({ ...schema, supportedLocales, fields, ...pages === void 0 ? {} : { pages } });
551
+ return { success: true };
552
+ },
553
+ [onChange, schema, textPolicyError]
554
+ );
555
+ const addLocale = useCallback(
556
+ (locale) => {
557
+ const normalized = locale.trim();
558
+ if (normalized.length === 0)
559
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
560
+ onChange({
561
+ ...schema,
562
+ supportedLocales: [
563
+ .../* @__PURE__ */ new Set([
564
+ ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
565
+ ...schema.supportedLocales ?? [],
566
+ normalized
567
+ ])
568
+ ]
569
+ });
570
+ return { success: true };
571
+ },
572
+ [onChange, schema]
573
+ );
574
+ const setDefaultLocale = useCallback(
575
+ (locale) => {
576
+ const normalized = locale.trim();
577
+ if (normalized.length === 0)
578
+ return { success: false, error: { type: "invalid_operation", message: "Locale must not be empty." } };
579
+ onChange({
580
+ ...schema,
581
+ defaultLocale: normalized,
582
+ supportedLocales: [.../* @__PURE__ */ new Set([normalized, ...schema.supportedLocales ?? []])]
583
+ });
584
+ return { success: true };
585
+ },
586
+ [onChange, schema]
587
+ );
588
+ const validationIssues = useMemo(() => {
589
+ const result = validateFormSchema(schema, policy === void 0 ? {} : { policy });
590
+ return result.valid ? [] : result.issues;
591
+ }, [policy, schema]);
592
+ return {
593
+ schema,
594
+ addField,
595
+ removeField,
596
+ moveField,
597
+ updateField,
598
+ changeFieldType,
599
+ addOption,
600
+ updateOption,
601
+ removeOption,
602
+ moveOption,
603
+ addPage,
604
+ updatePage,
605
+ removePage,
606
+ movePage,
607
+ assignFieldToPage,
608
+ setDisplayCondition,
609
+ setSourceText,
610
+ setLocaleTranslation,
611
+ addLocale,
612
+ setDefaultLocale,
613
+ validationIssues
614
+ };
615
+ }
616
+
617
+ // src/builder.tsx
7
618
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
619
  var FIELD_TYPES = [
9
620
  "text",
@@ -24,6 +635,7 @@ var BUILDER_DEFAULTS = {
24
635
  "builder.questionTitle": "\u8CEA\u554F\u6587 / Question Title",
25
636
  "builder.questionTitlePlaceholder": "Example: Tell us what we could improve",
26
637
  "builder.newQuestionTitle": "New question",
638
+ "builder.completionMessage": "Completion message",
27
639
  "builder.type": "Type",
28
640
  "builder.required": "Required",
29
641
  "builder.minimum": "Minimum",
@@ -82,33 +694,6 @@ function fieldTypeKey(type) {
82
694
  function operatorKey(operator) {
83
695
  return `builder.operator.${operator}`;
84
696
  }
85
- function createUniqueId(prefix, existingIds) {
86
- let id;
87
- do {
88
- id = `${prefix}_${globalThis.crypto.randomUUID().slice(0, 8)}`;
89
- } while (existingIds.has(id));
90
- return id;
91
- }
92
- function baseField(field, type) {
93
- return {
94
- id: field.id,
95
- type,
96
- title: field.title,
97
- ...field.description === void 0 ? {} : { description: field.description },
98
- ...field.translationKey === void 0 ? {} : { translationKey: field.translationKey },
99
- required: field.required,
100
- ...field.displayCondition === void 0 ? {} : { displayCondition: field.displayCondition }
101
- };
102
- }
103
- function normalizeField(field, type, newOptionLabel) {
104
- const base = baseField(field, type);
105
- if (type === "text" || type === "textarea") return { ...base, type };
106
- if (type === "number") return { ...base, type };
107
- if (type === "rating") return { ...base, type, min: 1, max: 5 };
108
- if (type === "checkbox") return { ...base, type };
109
- const options = "options" in field && field.options.length > 0 ? field.options : [{ id: createUniqueId("opt", /* @__PURE__ */ new Set()), label: newOptionLabel }];
110
- return { ...base, type, options };
111
- }
112
697
  function defaultConditionValue(field) {
113
698
  if (field.type === "checkbox") return true;
114
699
  if (field.type === "number" || field.type === "rating") return field.min ?? 1;
@@ -122,30 +707,6 @@ function conditionOperators(field) {
122
707
  }
123
708
  return ["equals", "not_equals", "not_empty"];
124
709
  }
125
- function withoutDisplayCondition(field) {
126
- const { displayCondition: _displayCondition, ...rest } = field;
127
- return rest;
128
- }
129
- function sanitizeBuilderSchema(schema) {
130
- const sanitized = sanitizeSchema(schema);
131
- const indexById = new Map(sanitized.fields.map((field, index) => [field.id, index]));
132
- const fields = sanitized.fields.map((field, index) => {
133
- const sourceId = field.displayCondition?.questionId;
134
- if (sourceId === void 0) return field;
135
- const sourceIndex = indexById.get(sourceId);
136
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
137
- });
138
- return {
139
- ...sanitized,
140
- fields,
141
- ...sanitized.pages === void 0 ? {} : {
142
- pages: sanitized.pages.map((page) => ({
143
- ...page,
144
- questionIds: fields.filter((field) => page.questionIds.includes(field.id)).map((field) => field.id)
145
- }))
146
- }
147
- };
148
- }
149
710
  function conditionWithValue(questionId, operator, value) {
150
711
  return operator === "not_empty" ? { questionId, operator } : { questionId, operator, value };
151
712
  }
@@ -187,7 +748,25 @@ function ConditionValueEditor({
187
748
  }
188
749
  );
189
750
  }
190
- function FormBuilder({ schema, onChange, locale = "en", translator, translationAdapter }) {
751
+ function FormBuilder({
752
+ schema,
753
+ onChange,
754
+ locale = "en",
755
+ translator,
756
+ translationAdapter,
757
+ translationOptions,
758
+ onTranslationReport,
759
+ policy,
760
+ idFactory,
761
+ factories
762
+ }) {
763
+ const headless = useFormBuilder({
764
+ schema,
765
+ onChange,
766
+ ...policy === void 0 ? {} : { policy },
767
+ ...idFactory === void 0 ? {} : { idFactory },
768
+ ...factories === void 0 ? {} : { factories }
769
+ });
191
770
  const [newPageQuestionId, setNewPageQuestionId] = useState("");
192
771
  const [newLocale, setNewLocale] = useState("");
193
772
  const [editingLocale, setEditingLocale] = useState("");
@@ -197,67 +776,17 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
197
776
  const translated = translator?.translate(key, locale, params);
198
777
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
199
778
  };
200
- const emitSchema = (candidate) => onChange(sanitizeBuilderSchema(candidate));
201
- const updateField = (fieldId, update) => {
202
- emitSchema({ ...schema, fields: schema.fields.map((field) => field.id === fieldId ? update(field) : field) });
203
- };
204
- const changeType = (fieldId, type) => {
205
- emitSchema({
206
- ...schema,
207
- fields: schema.fields.map((field) => {
208
- if (field.id === fieldId) {
209
- return normalizeField(field, type, translate("builder.newOptionLabel", { index: 1 }));
210
- }
211
- if (field.displayCondition?.questionId === fieldId) {
212
- const { displayCondition: _condition, ...withoutCondition } = field;
213
- return withoutCondition;
214
- }
215
- return field;
216
- })
217
- });
218
- };
219
- const removeField = (fieldId) => {
220
- if (schema.fields.length === 1) return;
221
- emitSchema({ ...schema, fields: schema.fields.filter((field) => field.id !== fieldId) });
222
- };
779
+ const updateField = headless.updateField;
780
+ const changeType = headless.changeFieldType;
781
+ const removeField = headless.removeField;
223
782
  const moveField = (index, offset) => {
224
783
  const target = index + offset;
225
- if (target < 0 || target >= schema.fields.length) return;
226
- const fields = [...schema.fields];
227
- const current = fields[index];
228
- const other = fields[target];
229
- if (current === void 0 || other === void 0) return;
230
- fields[index] = other;
231
- fields[target] = current;
232
- emitSchema({ ...schema, fields });
233
- };
234
- const addField = () => {
235
- const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
236
- const nextSchema = {
237
- ...schema,
238
- fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
239
- };
240
- emitSchema(
241
- schema.pages === void 0 ? nextSchema : {
242
- ...nextSchema,
243
- pages: schema.pages.map(
244
- (page, index) => index === (schema.pages?.length ?? 0) - 1 ? { ...page, questionIds: [...page.questionIds, id] } : page
245
- )
246
- }
247
- );
784
+ const field = schema.fields[index];
785
+ if (field !== void 0) headless.moveField(field.id, target);
248
786
  };
787
+ const addField = () => headless.addField("text");
249
788
  const enablePages = () => {
250
- if (schema.pages !== void 0) return;
251
- emitSchema({
252
- ...schema,
253
- pages: [
254
- {
255
- id: createUniqueId("page", /* @__PURE__ */ new Set()),
256
- title: translate("builder.newPage"),
257
- questionIds: schema.fields.map((field) => field.id)
258
- }
259
- ]
260
- });
789
+ if (schema.pages === void 0) headless.addPage();
261
790
  };
262
791
  const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
263
792
  const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
@@ -268,73 +797,28 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
268
797
  }
269
798
  const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
270
799
  if (questionId === void 0) return;
271
- const pageId = createUniqueId("page", new Set(schema.pages.map((page) => page.id)));
272
- emitSchema({
273
- ...schema,
274
- pages: [
275
- ...schema.pages.map((page) => ({
276
- ...page,
277
- questionIds: page.questionIds.filter((id) => id !== questionId)
278
- })),
279
- { id: pageId, title: translate("builder.newPage"), questionIds: [questionId] }
280
- ]
281
- });
800
+ headless.addPage(questionId);
282
801
  setNewPageQuestionId("");
283
802
  };
284
803
  const removePage = (pageIndex) => {
285
- if (schema.pages === void 0) return;
286
- const removed = schema.pages[pageIndex];
287
- if (removed === void 0) return;
288
- if (schema.pages.length === 1) {
289
- const { pages: _pages, ...singlePage } = schema;
290
- emitSchema(singlePage);
291
- return;
292
- }
293
- const targetIndex = pageIndex === 0 ? 1 : pageIndex - 1;
294
- emitSchema({
295
- ...schema,
296
- pages: schema.pages.map(
297
- (page, index) => index === targetIndex ? { ...page, questionIds: [...page.questionIds, ...removed.questionIds] } : page
298
- ).filter((_page, index) => index !== pageIndex)
299
- });
804
+ const page = schema.pages?.[pageIndex];
805
+ if (page !== void 0) headless.removePage(page.id);
300
806
  };
301
807
  const movePage = (pageIndex, offset) => {
302
- if (schema.pages === void 0) return;
303
808
  const target = pageIndex + offset;
304
- if (target < 0 || target >= schema.pages.length) return;
305
- const pages = [...schema.pages];
306
- const current = pages[pageIndex];
307
- const other = pages[target];
308
- if (current === void 0 || other === void 0) return;
309
- pages[pageIndex] = other;
310
- pages[target] = current;
311
- emitSchema({ ...schema, pages });
809
+ const page = schema.pages?.[pageIndex];
810
+ if (page !== void 0) headless.movePage(page.id, target);
312
811
  };
313
812
  const updatePage = (pageId, update) => {
314
- if (schema.pages === void 0) return;
315
- emitSchema({ ...schema, pages: schema.pages.map((page) => page.id === pageId ? update(page) : page) });
813
+ headless.updatePage(pageId, update);
316
814
  };
317
815
  const assignFieldToPage = (fieldId, pageId) => {
318
- if (schema.pages === void 0) return;
319
- emitSchema({
320
- ...schema,
321
- pages: schema.pages.map((page) => ({
322
- ...page,
323
- 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)
324
- })).filter((page) => page.questionIds.length > 0)
325
- });
816
+ headless.assignFieldToPage(fieldId, pageId);
326
817
  };
327
818
  const addLocale = () => {
328
819
  const normalized = newLocale.trim();
329
820
  if (normalized.length === 0) return;
330
- const supportedLocales = [
331
- .../* @__PURE__ */ new Set([
332
- ...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
333
- ...schema.supportedLocales ?? [],
334
- normalized
335
- ])
336
- ];
337
- emitSchema({ ...schema, supportedLocales });
821
+ headless.addLocale(normalized);
338
822
  setEditingLocale(normalized);
339
823
  setNewLocale("");
340
824
  };
@@ -343,7 +827,14 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
343
827
  setIsTranslating(true);
344
828
  setTranslationError(null);
345
829
  try {
346
- onChange(await populateSchemaTranslations(schema, [editingLocale], translationAdapter));
830
+ const populated = await populateSchemaTranslations(
831
+ schema,
832
+ [editingLocale],
833
+ translationAdapter,
834
+ translationOptions ?? { overwrite: "all" }
835
+ );
836
+ onChange(populated.schema);
837
+ onTranslationReport?.(populated.report);
347
838
  } catch (cause) {
348
839
  setTranslationError(cause instanceof Error ? cause.message : String(cause));
349
840
  } finally {
@@ -352,18 +843,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
352
843
  };
353
844
  const updateFormTranslation = (key, value) => {
354
845
  if (editingLocale.length === 0) return;
355
- const current = schema.translations?.[editingLocale];
356
- const next = key === "title" ? value.length === 0 ? { description: current?.description } : { ...current, title: value } : value.length === 0 ? { title: current?.title } : { ...current, description: value };
357
- emitSchema({
358
- ...schema,
359
- translations: {
360
- ...schema.translations,
361
- [editingLocale]: {
362
- ...next.title === void 0 ? {} : { title: next.title },
363
- ...next.description === void 0 ? {} : { description: next.description }
364
- }
365
- }
366
- });
846
+ headless.setLocaleTranslation(editingLocale, { kind: "form" }, key, value);
367
847
  };
368
848
  return /* @__PURE__ */ jsxs("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
369
849
  /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
@@ -443,19 +923,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
443
923
  "input",
444
924
  {
445
925
  value: page.translations?.[editingLocale]?.title ?? "",
446
- onChange: (event) => {
447
- const value = event.currentTarget.value;
448
- updatePage(page.id, (current) => ({
449
- ...current,
450
- translations: {
451
- ...current.translations,
452
- [editingLocale]: {
453
- ...value.length === 0 ? {} : { title: value },
454
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
455
- }
456
- }
457
- }));
458
- }
926
+ onChange: (event) => headless.setLocaleTranslation(
927
+ editingLocale,
928
+ { kind: "page", id: page.id },
929
+ "title",
930
+ event.currentTarget.value
931
+ )
459
932
  }
460
933
  )
461
934
  ] }),
@@ -465,19 +938,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
465
938
  "input",
466
939
  {
467
940
  value: page.translations?.[editingLocale]?.description ?? "",
468
- onChange: (event) => {
469
- const value = event.currentTarget.value;
470
- updatePage(page.id, (current) => ({
471
- ...current,
472
- translations: {
473
- ...current.translations,
474
- [editingLocale]: {
475
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
476
- ...value.length === 0 ? {} : { description: value }
477
- }
478
- }
479
- }));
480
- }
941
+ onChange: (event) => headless.setLocaleTranslation(
942
+ editingLocale,
943
+ { kind: "page", id: page.id },
944
+ "description",
945
+ event.currentTarget.value
946
+ )
481
947
  }
482
948
  )
483
949
  ] })
@@ -565,6 +1031,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
565
1031
  ] }),
566
1032
  /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
567
1033
  /* @__PURE__ */ jsx("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
1034
+ /* @__PURE__ */ jsxs("label", { children: [
1035
+ translate("builder.completionMessage"),
1036
+ /* @__PURE__ */ jsx(
1037
+ "input",
1038
+ {
1039
+ value: schema.completionMessage ?? "",
1040
+ onChange: (event) => headless.setSourceText({ kind: "form" }, "completionMessage", event.currentTarget.value)
1041
+ }
1042
+ )
1043
+ ] }),
568
1044
  /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
569
1045
  /* @__PURE__ */ jsxs("label", { children: [
570
1046
  translate("builder.defaultLocale"),
@@ -572,16 +1048,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
572
1048
  "input",
573
1049
  {
574
1050
  value: schema.defaultLocale ?? "",
575
- onChange: (event) => {
576
- const value = event.currentTarget.value.trim();
577
- emitSchema(
578
- value.length === 0 ? schema : {
579
- ...schema,
580
- defaultLocale: value,
581
- supportedLocales: [.../* @__PURE__ */ new Set([value, ...schema.supportedLocales ?? []])]
582
- }
583
- );
584
- }
1051
+ onChange: (event) => headless.setDefaultLocale(event.currentTarget.value)
585
1052
  }
586
1053
  )
587
1054
  ] }),
@@ -629,6 +1096,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
629
1096
  onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
630
1097
  }
631
1098
  )
1099
+ ] }),
1100
+ /* @__PURE__ */ jsxs("label", { children: [
1101
+ translate("builder.completionMessage"),
1102
+ /* @__PURE__ */ jsx(
1103
+ "input",
1104
+ {
1105
+ value: schema.translations?.[editingLocale]?.completionMessage ?? "",
1106
+ onChange: (event) => updateFormTranslation("completionMessage", event.currentTarget.value)
1107
+ }
1108
+ )
632
1109
  ] })
633
1110
  ] })
634
1111
  ] }),
@@ -692,7 +1169,9 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
692
1169
  {
693
1170
  value: field.type,
694
1171
  onChange: (event) => changeType(field.id, event.currentTarget.value),
695
- children: FIELD_TYPES.map((type) => /* @__PURE__ */ jsx("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
1172
+ children: FIELD_TYPES.filter(
1173
+ (type) => policy?.allowedFieldTypes === void 0 || policy.allowedFieldTypes.includes(type)
1174
+ ).map((type) => /* @__PURE__ */ jsx("option", { value: type, children: translate(fieldTypeKey(type)) }, type))
696
1175
  }
697
1176
  )
698
1177
  ] }),
@@ -728,19 +1207,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
728
1207
  "input",
729
1208
  {
730
1209
  value: field.translations?.[editingLocale]?.title ?? "",
731
- onChange: (event) => {
732
- const value = event.currentTarget.value;
733
- updateField(field.id, (current) => ({
734
- ...current,
735
- translations: {
736
- ...current.translations,
737
- [editingLocale]: {
738
- ...value.length === 0 ? {} : { title: value },
739
- ...current.translations?.[editingLocale]?.description === void 0 ? {} : { description: current.translations[editingLocale]?.description }
740
- }
741
- }
742
- }));
743
- }
1210
+ onChange: (event) => headless.setLocaleTranslation(
1211
+ editingLocale,
1212
+ { kind: "field", id: field.id },
1213
+ "title",
1214
+ event.currentTarget.value
1215
+ )
744
1216
  }
745
1217
  )
746
1218
  ] }),
@@ -750,19 +1222,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
750
1222
  "input",
751
1223
  {
752
1224
  value: field.translations?.[editingLocale]?.description ?? "",
753
- onChange: (event) => {
754
- const value = event.currentTarget.value;
755
- updateField(field.id, (current) => ({
756
- ...current,
757
- translations: {
758
- ...current.translations,
759
- [editingLocale]: {
760
- ...current.translations?.[editingLocale]?.title === void 0 ? {} : { title: current.translations[editingLocale]?.title },
761
- ...value.length === 0 ? {} : { description: value }
762
- }
763
- }
764
- }));
765
- }
1225
+ onChange: (event) => headless.setLocaleTranslation(
1226
+ editingLocale,
1227
+ { kind: "field", id: field.id },
1228
+ "description",
1229
+ event.currentTarget.value
1230
+ )
766
1231
  }
767
1232
  )
768
1233
  ] })
@@ -776,26 +1241,12 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
776
1241
  "input",
777
1242
  {
778
1243
  value: option.translations?.[editingLocale] ?? "",
779
- onChange: (event) => {
780
- const value = event.currentTarget.value;
781
- updateField(field.id, (current) => {
782
- if (!("options" in current)) return current;
783
- return {
784
- ...current,
785
- options: current.options.map(
786
- (candidate) => candidate.id === option.id ? {
787
- ...candidate,
788
- translations: Object.fromEntries([
789
- ...Object.entries(candidate.translations ?? {}).filter(
790
- ([localeKey]) => localeKey !== editingLocale
791
- ),
792
- ...value.length === 0 ? [] : [[editingLocale, value]]
793
- ])
794
- } : candidate
795
- )
796
- };
797
- });
798
- }
1244
+ onChange: (event) => headless.setLocaleTranslation(
1245
+ editingLocale,
1246
+ { kind: "option", id: option.id },
1247
+ "label",
1248
+ event.currentTarget.value
1249
+ )
799
1250
  }
800
1251
  )
801
1252
  ] }, option.id)) : null
@@ -847,17 +1298,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
847
1298
  "aria-label": translate("builder.optionLabel", { index: optionIndex + 1 }),
848
1299
  value: option.label,
849
1300
  placeholder: translate("builder.optionLabelPlaceholder"),
850
- onChange: (event) => updateField(field.id, (current) => {
851
- if (!("options" in current)) return current;
852
- const label = event.currentTarget.value;
853
- if (label.trim().length === 0) return current;
854
- return {
855
- ...current,
856
- options: current.options.map(
857
- (item, itemIndex) => itemIndex === optionIndex ? { ...item, label } : item
858
- )
859
- };
860
- })
1301
+ onChange: (event) => event.currentTarget.value.trim().length === 0 ? void 0 : headless.updateOption(field.id, option.id, (item) => ({
1302
+ ...item,
1303
+ label: event.currentTarget.value
1304
+ }))
861
1305
  }
862
1306
  ),
863
1307
  /* @__PURE__ */ jsx(
@@ -865,13 +1309,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
865
1309
  {
866
1310
  type: "button",
867
1311
  disabled: field.options.length === 1,
868
- onClick: () => updateField(
869
- field.id,
870
- (current) => "options" in current ? {
871
- ...current,
872
- options: current.options.filter((_item, itemIndex) => itemIndex !== optionIndex)
873
- } : current
874
- ),
1312
+ onClick: () => headless.removeOption(field.id, option.id),
875
1313
  children: translate("builder.remove")
876
1314
  }
877
1315
  )
@@ -880,22 +1318,8 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
880
1318
  "button",
881
1319
  {
882
1320
  type: "button",
883
- onClick: () => updateField(
884
- field.id,
885
- (current) => "options" in current ? (() => {
886
- const id = createUniqueId("opt", new Set(current.options.map((option) => option.id)));
887
- return {
888
- ...current,
889
- options: [
890
- ...current.options,
891
- {
892
- id,
893
- label: translate("builder.newOptionLabel", { index: current.options.length + 1 })
894
- }
895
- ]
896
- };
897
- })() : current
898
- ),
1321
+ disabled: policy?.maxOptionsPerField !== void 0 && field.options.length >= policy.maxOptionsPerField,
1322
+ onClick: () => headless.addOption(field.id),
899
1323
  children: translate("builder.addOption")
900
1324
  }
901
1325
  )
@@ -909,17 +1333,14 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
909
1333
  value: condition?.questionId ?? "",
910
1334
  onChange: (event) => {
911
1335
  const selected = schema.fields.find((item) => item.id === event.currentTarget.value);
912
- updateField(field.id, (current) => {
913
- if (selected === void 0) {
914
- const { displayCondition: _condition, ...withoutCondition } = current;
915
- return withoutCondition;
916
- }
917
- const operator = conditionOperators(selected)[0] ?? "not_empty";
918
- return {
919
- ...current,
920
- displayCondition: conditionWithValue(selected.id, operator, defaultConditionValue(selected))
921
- };
922
- });
1336
+ headless.setDisplayCondition(
1337
+ field.id,
1338
+ selected === void 0 ? void 0 : conditionWithValue(
1339
+ selected.id,
1340
+ conditionOperators(selected)[0] ?? "not_empty",
1341
+ defaultConditionValue(selected)
1342
+ )
1343
+ );
923
1344
  },
924
1345
  children: [
925
1346
  /* @__PURE__ */ jsx("option", { value: "", children: translate("builder.alwaysVisible") }),
@@ -936,10 +1357,10 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
936
1357
  value: condition.operator,
937
1358
  onChange: (event) => {
938
1359
  const operator = event.currentTarget.value;
939
- updateField(field.id, (current) => ({
940
- ...current,
941
- displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
942
- }));
1360
+ headless.setDisplayCondition(
1361
+ field.id,
1362
+ conditionWithValue(source.id, operator, defaultConditionValue(source))
1363
+ );
943
1364
  },
944
1365
  children: conditionOperators(source).map((operator) => /* @__PURE__ */ jsx("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
945
1366
  }
@@ -949,7 +1370,7 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
949
1370
  {
950
1371
  source,
951
1372
  condition,
952
- onChange: (next) => updateField(field.id, (current) => ({ ...current, displayCondition: next })),
1373
+ onChange: (next) => headless.setDisplayCondition(field.id, next),
953
1374
  translate
954
1375
  }
955
1376
  )
@@ -957,7 +1378,16 @@ function FormBuilder({ schema, onChange, locale = "en", translator, translationA
957
1378
  ] })
958
1379
  ] }, field.id);
959
1380
  }) }),
960
- /* @__PURE__ */ jsx("button", { className: "form-engine-builder__add", type: "button", onClick: addField, children: translate("builder.addQuestion") })
1381
+ /* @__PURE__ */ jsx(
1382
+ "button",
1383
+ {
1384
+ className: "form-engine-builder__add",
1385
+ type: "button",
1386
+ disabled: policy?.maxFields !== void 0 && schema.fields.length >= policy.maxFields,
1387
+ onClick: addField,
1388
+ children: translate("builder.addQuestion")
1389
+ }
1390
+ )
961
1391
  ] });
962
1392
  }
963
1393
 
@@ -971,7 +1401,7 @@ import {
971
1401
  validateAnswers,
972
1402
  validatePageAnswers
973
1403
  } from "@form-engine-ts/core";
974
- import { createContext, useCallback, useContext, useEffect, useMemo, useState as useState2 } from "react";
1404
+ import { createContext, useCallback as useCallback2, useContext, useEffect, useMemo as useMemo2, useState as useState2 } from "react";
975
1405
  import { jsx as jsx2 } from "react/jsx-runtime";
976
1406
  var FormContext = createContext(null);
977
1407
  function issuesByField(issues) {
@@ -988,7 +1418,7 @@ function FormProvider({
988
1418
  onSubmit,
989
1419
  children
990
1420
  }) {
991
- const validSchema = useMemo(() => {
1421
+ const validSchema = useMemo2(() => {
992
1422
  assertValidFormSchema(schema);
993
1423
  const localized = resolveLocalizedSchema(schema, locale);
994
1424
  assertValidFormSchema(localized);
@@ -999,13 +1429,13 @@ function FormProvider({
999
1429
  const [submitStatus, setSubmitStatus] = useState2("idle");
1000
1430
  const [submitError, setSubmitError] = useState2(null);
1001
1431
  const [validationPageIndex, setValidationPageIndex] = useState2(null);
1002
- const visibility = useMemo(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
1003
- const pageVisibility = useMemo(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
1432
+ const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
1433
+ const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
1004
1434
  useEffect(() => {
1005
1435
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1006
1436
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
1007
1437
  }, [validSchema]);
1008
- const setValue = useCallback(
1438
+ const setValue = useCallback2(
1009
1439
  (fieldId, value) => {
1010
1440
  setValues((current) => {
1011
1441
  const next = { ...current, [fieldId]: value };
@@ -1021,7 +1451,7 @@ function FormProvider({
1021
1451
  },
1022
1452
  [validSchema, validationPageIndex]
1023
1453
  );
1024
- const restoreValues = useCallback(
1454
+ const restoreValues = useCallback2(
1025
1455
  (restoredValues) => {
1026
1456
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1027
1457
  setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
@@ -1032,7 +1462,7 @@ function FormProvider({
1032
1462
  },
1033
1463
  [validSchema]
1034
1464
  );
1035
- const validatePage = useCallback(
1465
+ const validatePage = useCallback2(
1036
1466
  (pageIndex) => {
1037
1467
  const result = validatePageAnswers(validSchema, pageIndex, values);
1038
1468
  setErrors(issuesByField(result.issues));
@@ -1043,42 +1473,51 @@ function FormProvider({
1043
1473
  },
1044
1474
  [validSchema, values]
1045
1475
  );
1046
- const reset = useCallback(() => {
1476
+ const reset = useCallback2(() => {
1047
1477
  setValues({ ...initialValues });
1048
1478
  setErrors({});
1049
1479
  setValidationPageIndex(null);
1050
1480
  setSubmitStatus("idle");
1051
1481
  setSubmitError(null);
1052
1482
  }, [initialValues]);
1053
- const submit = useCallback(async () => {
1054
- const validation = validateAnswers(validSchema, values);
1055
- if (!validation.valid) {
1056
- setErrors(issuesByField(validation.issues));
1483
+ const submit = useCallback2(
1484
+ async (beforeSubmit) => {
1485
+ const validation = validateAnswers(validSchema, values);
1486
+ if (!validation.valid) {
1487
+ setErrors(issuesByField(validation.issues));
1488
+ setValidationPageIndex(null);
1489
+ setSubmitStatus("error");
1490
+ setSubmitError(null);
1491
+ return { status: "invalid", issues: validation.issues };
1492
+ }
1493
+ setErrors({});
1057
1494
  setValidationPageIndex(null);
1058
- setSubmitStatus("error");
1059
1495
  setSubmitError(null);
1060
- return false;
1061
- }
1062
- setErrors({});
1063
- setValidationPageIndex(null);
1064
- setSubmitStatus("submitting");
1065
- setSubmitError(null);
1066
- try {
1067
- await onSubmit(selectVisibleAnswers(validSchema, values));
1068
- if (resetOnSuccess) setValues({ ...initialValues });
1069
- setSubmitStatus("success");
1070
- return true;
1071
- } catch (cause) {
1072
- setSubmitError(cause instanceof Error ? cause : new Error(String(cause)));
1073
- setSubmitStatus("error");
1074
- return false;
1075
- }
1076
- }, [initialValues, onSubmit, resetOnSuccess, validSchema, values]);
1077
- const translate = useCallback(
1496
+ const visibleValues = selectVisibleAnswers(validSchema, values);
1497
+ try {
1498
+ if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
1499
+ setSubmitStatus("idle");
1500
+ return { status: "cancelled" };
1501
+ }
1502
+ setSubmitStatus("submitting");
1503
+ await onSubmit(visibleValues);
1504
+ if (resetOnSuccess) setValues({ ...initialValues });
1505
+ setSubmitStatus("success");
1506
+ return { status: "success" };
1507
+ } catch (cause) {
1508
+ const error = cause instanceof Error ? cause : new Error(String(cause));
1509
+ setSubmitError(error);
1510
+ setSubmitStatus("error");
1511
+ return { status: "error", error };
1512
+ }
1513
+ },
1514
+ [initialValues, onSubmit, resetOnSuccess, validSchema, values]
1515
+ );
1516
+ const translate = useCallback2(
1078
1517
  (key, params) => translator.translate(key, locale, params),
1079
1518
  [locale, translator]
1080
1519
  );
1081
- const contextValue = useMemo(
1520
+ const contextValue = useMemo2(
1082
1521
  () => ({
1083
1522
  schema: validSchema,
1084
1523
  locale,
@@ -1126,7 +1565,7 @@ function useField(fieldId) {
1126
1565
  const form = useForm();
1127
1566
  const field = form.schema.fields.find((item) => item.id === fieldId);
1128
1567
  if (field === void 0) throw new Error(`Unknown form field: ${fieldId}`);
1129
- const setValue = useCallback((value) => form.setValue(fieldId, value), [fieldId, form]);
1568
+ const setValue = useCallback2((value) => form.setValue(fieldId, value), [fieldId, form]);
1130
1569
  return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
1131
1570
  }
1132
1571
 
@@ -1134,8 +1573,15 @@ function useField(fieldId) {
1134
1573
  import {
1135
1574
  validateAnswers as validateAnswers2
1136
1575
  } from "@form-engine-ts/core";
1137
- import { useEffect as useEffect2, useId, useMemo as useMemo2, useRef, useState as useState3 } from "react";
1138
- import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1576
+ import {
1577
+ Fragment as Fragment2,
1578
+ useEffect as useEffect2,
1579
+ useId,
1580
+ useMemo as useMemo3,
1581
+ useRef,
1582
+ useState as useState3
1583
+ } from "react";
1584
+ import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
1139
1585
  function describedBy(field, error, helpId, errorId) {
1140
1586
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
1141
1587
  Boolean
@@ -1149,7 +1595,7 @@ function RequiredMark({ required }) {
1149
1595
  ] }) : null;
1150
1596
  }
1151
1597
  function FieldMessage({ props }) {
1152
- return /* @__PURE__ */ jsxs2(Fragment2, { children: [
1598
+ return /* @__PURE__ */ jsxs2(Fragment3, { children: [
1153
1599
  props.field.description === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.helpId, className: "fe-help", children: props.field.description }),
1154
1600
  props.error === void 0 ? null : /* @__PURE__ */ jsx3("div", { id: props.errorId, className: "fe-error", children: props.translate(props.error.messageKey, props.error.params) })
1155
1601
  ] });
@@ -1337,12 +1783,15 @@ function parseDraft(serialized) {
1337
1783
  return null;
1338
1784
  }
1339
1785
  }
1340
- function FormRenderer({
1786
+ function ContextFormRenderer({
1341
1787
  components = {},
1342
1788
  className = "",
1343
1789
  successMessageKey,
1344
1790
  errorMessageKey,
1345
- autoSaveKey
1791
+ autoSaveKey,
1792
+ beforeSubmit,
1793
+ onDraftSave,
1794
+ slots = {}
1346
1795
  }) {
1347
1796
  const form = useForm();
1348
1797
  const prefix = useId().replace(/:/g, "");
@@ -1352,7 +1801,7 @@ function FormRenderer({
1352
1801
  const [currentPageIndex, setCurrentPageIndex] = useState3(0);
1353
1802
  const [focusFieldId, setFocusFieldId] = useState3(null);
1354
1803
  const pages = form.schema.pages;
1355
- const visiblePageIndexes = useMemo2(
1804
+ const visiblePageIndexes = useMemo3(
1356
1805
  () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1357
1806
  [form.pageVisibility, pages]
1358
1807
  );
@@ -1390,9 +1839,10 @@ function FormRenderer({
1390
1839
  setDraftRestored(true);
1391
1840
  }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1392
1841
  useEffect2(() => {
1393
- if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1394
1842
  if (form.submitStatus === "success") return;
1395
1843
  const timeout = globalThis.setTimeout(() => {
1844
+ onDraftSave?.(form.values);
1845
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1396
1846
  const draft = {
1397
1847
  formId: form.schema.id,
1398
1848
  formVersion: form.schema.version,
@@ -1402,7 +1852,7 @@ function FormRenderer({
1402
1852
  globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1403
1853
  }, 500);
1404
1854
  return () => globalThis.clearTimeout(timeout);
1405
- }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values]);
1855
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values, onDraftSave]);
1406
1856
  const focusFirstIssue = (fieldId) => {
1407
1857
  if (fieldId !== void 0) setFocusFieldId(fieldId);
1408
1858
  };
@@ -1415,27 +1865,39 @@ function FormRenderer({
1415
1865
  const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1416
1866
  if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1417
1867
  };
1418
- const handleSubmit = async (event) => {
1419
- event.preventDefault();
1868
+ const submitValues = async () => {
1420
1869
  const validation = validateAnswers2(form.schema, form.values);
1421
1870
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
1422
- const valid = await form.submit();
1423
- if (!valid) {
1871
+ const result = await form.submit(beforeSubmit);
1872
+ if (result.status === "invalid") {
1424
1873
  const invalidPageIndex = pages?.findIndex(
1425
1874
  (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1426
1875
  );
1427
1876
  if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1428
1877
  focusFirstIssue(firstInvalidFieldId);
1429
- return;
1878
+ return result;
1430
1879
  }
1880
+ if (result.status !== "success") return result;
1431
1881
  if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1432
1882
  globalThis.localStorage.removeItem(autoSaveKey);
1433
1883
  setDraftRestored(false);
1434
1884
  }
1435
1885
  setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1886
+ return result;
1436
1887
  };
1888
+ const handleSubmit = (event) => {
1889
+ event.preventDefault();
1890
+ void submitValues();
1891
+ };
1892
+ const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
1893
+ const canPrev = pages !== void 0 && activeVisibleIndex > 0;
1894
+ const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
1895
+ const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: form.isSubmitting, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
1437
1896
  return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
1438
- /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
1897
+ slots.renderHeader?.({
1898
+ title: form.schema.title,
1899
+ ...form.schema.description === void 0 ? {} : { description: form.schema.description }
1900
+ }) ?? /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
1439
1901
  /* @__PURE__ */ jsx3("h1", { children: form.schema.title }),
1440
1902
  form.schema.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { children: form.schema.description }),
1441
1903
  pages === void 0 ? null : /* @__PURE__ */ jsxs2("div", { className: "fe-progress", children: [
@@ -1460,44 +1922,130 @@ function FormRenderer({
1460
1922
  ] }),
1461
1923
  draftRestored ? /* @__PURE__ */ jsx3("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
1462
1924
  ] }),
1463
- activePage?.title === void 0 ? null : /* @__PURE__ */ jsx3("h2", { className: "fe-page-title", children: activePage.title }),
1464
- activePage?.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { className: "fe-page-description", children: activePage.description }),
1925
+ activePage === void 0 ? null : slots.renderPageHeader?.({
1926
+ page: activePage,
1927
+ pageIndex: activeVisibleIndex,
1928
+ totalPages: visiblePageIndexes.length
1929
+ }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-page-header", children: [
1930
+ activePage.title === void 0 ? null : /* @__PURE__ */ jsx3("h2", { className: "fe-page-title", children: activePage.title }),
1931
+ activePage.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { className: "fe-page-description", children: activePage.description })
1932
+ ] }),
1465
1933
  /* @__PURE__ */ jsx3("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true && (fieldIds === void 0 || fieldIds.has(field.id))).map((field) => {
1934
+ const error = form.errors[field.id];
1466
1935
  const props = {
1467
1936
  field,
1468
1937
  value: form.values[field.id],
1469
- error: form.errors[field.id],
1938
+ error,
1470
1939
  setValue: (value) => form.setValue(field.id, value),
1471
1940
  translate: form.translate,
1472
1941
  inputId: `${prefix}-${field.id}`,
1473
1942
  errorId: `${prefix}-${field.id}-error`,
1474
1943
  helpId: `${prefix}-${field.id}-help`
1475
1944
  };
1945
+ if (slots.renderField !== void 0) {
1946
+ return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
1947
+ question: field,
1948
+ value: form.values[field.id],
1949
+ onChange: (value) => {
1950
+ if (isFormValue(value)) form.setValue(field.id, value);
1951
+ },
1952
+ ...error === void 0 ? {} : { error }
1953
+ }) }, field.id);
1954
+ }
1476
1955
  const Component = components[field.type];
1477
1956
  return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
1478
1957
  }) }),
1479
- pages === void 0 ? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") }) : /* @__PURE__ */ jsxs2("div", { className: "form-step-navigation", children: [
1480
- activeVisibleIndex > 0 ? /* @__PURE__ */ jsx3(
1481
- "button",
1482
- {
1483
- className: "btn-prev",
1484
- type: "button",
1485
- onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1486
- children: form.translate("form.back")
1487
- }
1488
- ) : null,
1489
- activeVisibleIndex < visiblePageIndexes.length - 1 ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") })
1958
+ validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
1959
+ validationIssues.length,
1960
+ " validation error",
1961
+ validationIssues.length === 1 ? "" : "s",
1962
+ "."
1963
+ ] }),
1964
+ pages === void 0 ? /* @__PURE__ */ jsxs2(Fragment3, { children: [
1965
+ slots.renderNavigation?.({
1966
+ currentPage: 0,
1967
+ totalPages: 1,
1968
+ canPrev: false,
1969
+ canNext: false,
1970
+ onPrev: () => void 0,
1971
+ onNext: () => void 0
1972
+ }),
1973
+ renderSubmitButton()
1974
+ ] }) : /* @__PURE__ */ jsxs2("div", { className: "form-step-navigation", children: [
1975
+ slots.renderNavigation?.({
1976
+ currentPage: activeVisibleIndex,
1977
+ totalPages: visiblePageIndexes.length,
1978
+ canPrev,
1979
+ canNext,
1980
+ onPrev: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1981
+ onNext: handleNext
1982
+ }) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
1983
+ canPrev ? /* @__PURE__ */ jsx3(
1984
+ "button",
1985
+ {
1986
+ className: "btn-prev",
1987
+ type: "button",
1988
+ onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
1989
+ children: form.translate("form.back")
1990
+ }
1991
+ ) : null,
1992
+ canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
1993
+ ] }),
1994
+ canNext ? null : renderSubmitButton()
1490
1995
  ] }),
1491
1996
  /* @__PURE__ */ jsxs2("div", { className: "fe-status", "aria-live": "polite", children: [
1492
- form.submitStatus === "success" && successMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "status", children: form.translate(successMessageKey) }) : null,
1493
- form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) }) : null
1997
+ form.submitStatus === "success" ? slots.renderCompletion?.({
1998
+ message: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey))
1999
+ }) ?? /* @__PURE__ */ jsx3("div", { role: "status", children: form.schema.completionMessage ?? (successMessageKey === void 0 ? "Submitted." : form.translate(successMessageKey)) }) : null,
2000
+ form.submitStatus === "error" && form.submitError !== null ? slots.renderSubmitError?.({ error: form.submitError, onRetry: () => void submitValues() }) ?? (errorMessageKey === void 0 ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) })) : null
1494
2001
  ] })
1495
2002
  ] });
1496
2003
  }
2004
+ var RENDERER_MESSAGES = {
2005
+ "form.submit": "Submit",
2006
+ "form.back": "Back",
2007
+ "form.next": "Next",
2008
+ "form.step": "Step {{current}} / {{total}}",
2009
+ "form.draftRestored": "Draft restored",
2010
+ "validation.required": "This field is required."
2011
+ };
2012
+ var defaultRendererTranslator = {
2013
+ translate(key, _locale, params = {}) {
2014
+ return (RENDERER_MESSAGES[key] ?? key).replace(
2015
+ /\{\{(\w+)\}\}/g,
2016
+ (token, name) => Object.hasOwn(params, name) ? String(params[name]) : token
2017
+ );
2018
+ }
2019
+ };
2020
+ function FormRenderer(props) {
2021
+ if (!("schema" in props)) return /* @__PURE__ */ jsx3(ContextFormRenderer, { ...props });
2022
+ const {
2023
+ schema,
2024
+ locale = schema.defaultLocale ?? "en",
2025
+ translator = defaultRendererTranslator,
2026
+ initialValues,
2027
+ resetOnSuccess,
2028
+ onSubmit,
2029
+ ...rendererProps
2030
+ } = props;
2031
+ return /* @__PURE__ */ jsx3(
2032
+ FormProvider,
2033
+ {
2034
+ schema,
2035
+ locale,
2036
+ translator,
2037
+ onSubmit,
2038
+ ...initialValues === void 0 ? {} : { initialValues },
2039
+ ...resetOnSuccess === void 0 ? {} : { resetOnSuccess },
2040
+ children: /* @__PURE__ */ jsx3(ContextFormRenderer, { ...rendererProps })
2041
+ }
2042
+ );
2043
+ }
1497
2044
  export {
1498
2045
  FormBuilder,
1499
2046
  FormProvider,
1500
2047
  FormRenderer,
1501
2048
  useField,
1502
- useForm
2049
+ useForm,
2050
+ useFormBuilder
1503
2051
  };