@form-engine-ts/react 1.0.0 → 1.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,5 +1,9 @@
1
1
  // src/builder.tsx
2
- import { sanitizeSchema } from "@form-engine-ts/core";
2
+ import {
3
+ populateSchemaTranslations,
4
+ sanitizeSchema
5
+ } from "@form-engine-ts/core";
6
+ import { useState } from "react";
3
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
8
  var FIELD_TYPES = [
5
9
  "text",
@@ -37,6 +41,22 @@ var BUILDER_DEFAULTS = {
37
41
  "builder.conditionTrue": "true",
38
42
  "builder.conditionFalse": "false",
39
43
  "builder.addQuestion": "Add question",
44
+ "builder.pages": "Page manager",
45
+ "builder.enablePages": "Enable multi-step pages",
46
+ "builder.addPage": "Add page",
47
+ "builder.newPage": "New page",
48
+ "builder.pageTitle": "Page title",
49
+ "builder.pageDescription": "Page description",
50
+ "builder.pageQuestion": "Question to move to the new page",
51
+ "builder.questionPage": "Page",
52
+ "builder.pageCondition": "Page display condition",
53
+ "builder.localization": "Localization",
54
+ "builder.defaultLocale": "Default locale",
55
+ "builder.supportedLocales": "Supported locales",
56
+ "builder.addLocale": "Add locale",
57
+ "builder.editLocale": "Edit locale",
58
+ "builder.autoTranslate": "Translate all text",
59
+ "builder.translationUnavailable": "Provide an async translation adapter to enable automatic translation.",
40
60
  "builder.fieldType.text": "Text",
41
61
  "builder.fieldType.textarea": "Textarea",
42
62
  "builder.fieldType.number": "Number",
@@ -109,14 +129,21 @@ function withoutDisplayCondition(field) {
109
129
  function sanitizeBuilderSchema(schema) {
110
130
  const sanitized = sanitizeSchema(schema);
111
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
+ });
112
138
  return {
113
139
  ...sanitized,
114
- fields: sanitized.fields.map((field, index) => {
115
- const sourceId = field.displayCondition?.questionId;
116
- if (sourceId === void 0) return field;
117
- const sourceIndex = indexById.get(sourceId);
118
- return sourceIndex !== void 0 && sourceIndex < index ? field : withoutDisplayCondition(field);
119
- })
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
+ }
120
147
  };
121
148
  }
122
149
  function conditionWithValue(questionId, operator, value) {
@@ -160,7 +187,12 @@ function ConditionValueEditor({
160
187
  }
161
188
  );
162
189
  }
163
- function FormBuilder({ schema, onChange, locale = "en", translator }) {
190
+ function FormBuilder({ schema, onChange, locale = "en", translator, translationAdapter }) {
191
+ const [newPageQuestionId, setNewPageQuestionId] = useState("");
192
+ const [newLocale, setNewLocale] = useState("");
193
+ const [editingLocale, setEditingLocale] = useState("");
194
+ const [isTranslating, setIsTranslating] = useState(false);
195
+ const [translationError, setTranslationError] = useState(null);
164
196
  const translate = (key, params = {}) => {
165
197
  const translated = translator?.translate(key, locale, params);
166
198
  return translated === void 0 ? interpolate(BUILDER_DEFAULTS[key] ?? key, params) : translated;
@@ -201,12 +233,405 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
201
233
  };
202
234
  const addField = () => {
203
235
  const id = createUniqueId("q", new Set(schema.fields.map((field) => field.id)));
204
- emitSchema({
236
+ const nextSchema = {
205
237
  ...schema,
206
238
  fields: [...schema.fields, { id, type: "text", title: translate("builder.newQuestionTitle"), required: false }]
239
+ };
240
+ emitSchema(
241
+ schema.pages === void 0 ? nextSchema : {
242
+ ...nextSchema,
243
+ pages: schema.pages.map(
244
+ (page, index) => index === (schema.pages?.length ?? 0) - 1 ? { ...page, questionIds: [...page.questionIds, id] } : page
245
+ )
246
+ }
247
+ );
248
+ };
249
+ 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
+ });
261
+ };
262
+ const pageForField = (fieldId) => schema.pages?.find((page) => page.questionIds.includes(fieldId));
263
+ const movablePageQuestions = schema.pages?.flatMap((page) => page.questionIds.length > 1 ? page.questionIds : []) ?? [];
264
+ const addPage = () => {
265
+ if (schema.pages === void 0) {
266
+ enablePages();
267
+ return;
268
+ }
269
+ const questionId = movablePageQuestions.includes(newPageQuestionId) ? newPageQuestionId : movablePageQuestions[0];
270
+ 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
+ });
282
+ setNewPageQuestionId("");
283
+ };
284
+ 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
+ });
300
+ };
301
+ const movePage = (pageIndex, offset) => {
302
+ if (schema.pages === void 0) return;
303
+ 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 });
312
+ };
313
+ 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) });
316
+ };
317
+ 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
+ });
326
+ };
327
+ const addLocale = () => {
328
+ const normalized = newLocale.trim();
329
+ 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 });
338
+ setEditingLocale(normalized);
339
+ setNewLocale("");
340
+ };
341
+ const translateAll = async () => {
342
+ if (translationAdapter === void 0 || editingLocale.length === 0) return;
343
+ setIsTranslating(true);
344
+ setTranslationError(null);
345
+ try {
346
+ onChange(await populateSchemaTranslations(schema, [editingLocale], translationAdapter));
347
+ } catch (cause) {
348
+ setTranslationError(cause instanceof Error ? cause.message : String(cause));
349
+ } finally {
350
+ setIsTranslating(false);
351
+ }
352
+ };
353
+ const updateFormTranslation = (key, value) => {
354
+ 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
+ }
207
366
  });
208
367
  };
209
368
  return /* @__PURE__ */ jsxs("section", { className: "form-engine-builder", "aria-label": translate("builder.formBuilder"), children: [
369
+ /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__pages", "aria-labelledby": "builder-pages-heading", children: [
370
+ /* @__PURE__ */ jsx("h2", { id: "builder-pages-heading", children: translate("builder.pages") }),
371
+ schema.pages === void 0 ? /* @__PURE__ */ jsx("button", { type: "button", onClick: enablePages, children: translate("builder.enablePages") }) : /* @__PURE__ */ jsxs(Fragment, { children: [
372
+ schema.pages.map((page, pageIndex) => {
373
+ const priorQuestionIds = new Set(schema.pages?.slice(0, pageIndex).flatMap((item) => item.questionIds));
374
+ const availableSources = schema.fields.filter((field) => priorQuestionIds.has(field.id));
375
+ const source = schema.fields.find((field) => field.id === page.displayCondition?.questionId);
376
+ return /* @__PURE__ */ jsxs("fieldset", { className: "form-engine-builder__page", children: [
377
+ /* @__PURE__ */ jsx("legend", { children: page.title ?? `${translate("builder.newPage")} ${pageIndex + 1}` }),
378
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__toolbar", children: [
379
+ /* @__PURE__ */ jsx(
380
+ "button",
381
+ {
382
+ type: "button",
383
+ disabled: pageIndex === 0,
384
+ "aria-label": translate("builder.moveUp", { title: page.title ?? page.id }),
385
+ onClick: () => movePage(pageIndex, -1),
386
+ children: "\u2191"
387
+ }
388
+ ),
389
+ /* @__PURE__ */ jsx(
390
+ "button",
391
+ {
392
+ type: "button",
393
+ disabled: pageIndex === (schema.pages?.length ?? 0) - 1,
394
+ "aria-label": translate("builder.moveDown", { title: page.title ?? page.id }),
395
+ onClick: () => movePage(pageIndex, 1),
396
+ children: "\u2193"
397
+ }
398
+ ),
399
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: () => removePage(pageIndex), children: translate("builder.deleteAction") })
400
+ ] }),
401
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
402
+ /* @__PURE__ */ jsxs("label", { children: [
403
+ translate("builder.pageTitle"),
404
+ /* @__PURE__ */ jsx(
405
+ "input",
406
+ {
407
+ value: page.title ?? "",
408
+ onChange: (event) => {
409
+ const value = event.currentTarget.value;
410
+ updatePage(page.id, (current) => {
411
+ if (value.length > 0) return { ...current, title: value };
412
+ const { title: _title, ...withoutTitle } = current;
413
+ return withoutTitle;
414
+ });
415
+ }
416
+ }
417
+ )
418
+ ] }),
419
+ /* @__PURE__ */ jsxs("label", { children: [
420
+ translate("builder.pageDescription"),
421
+ /* @__PURE__ */ jsx(
422
+ "input",
423
+ {
424
+ value: page.description ?? "",
425
+ onChange: (event) => {
426
+ const value = event.currentTarget.value;
427
+ updatePage(page.id, (current) => {
428
+ if (value.length > 0) return { ...current, description: value };
429
+ const { description: _description, ...withoutDescription } = current;
430
+ return withoutDescription;
431
+ });
432
+ }
433
+ }
434
+ )
435
+ ] })
436
+ ] }),
437
+ editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
438
+ /* @__PURE__ */ jsx("strong", { children: editingLocale }),
439
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
440
+ /* @__PURE__ */ jsxs("label", { children: [
441
+ translate("builder.pageTitle"),
442
+ /* @__PURE__ */ jsx(
443
+ "input",
444
+ {
445
+ 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
+ }
459
+ }
460
+ )
461
+ ] }),
462
+ /* @__PURE__ */ jsxs("label", { children: [
463
+ translate("builder.pageDescription"),
464
+ /* @__PURE__ */ jsx(
465
+ "input",
466
+ {
467
+ 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
+ }
481
+ }
482
+ )
483
+ ] })
484
+ ] })
485
+ ] }),
486
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__condition", children: [
487
+ /* @__PURE__ */ jsxs("label", { children: [
488
+ translate("builder.pageCondition"),
489
+ /* @__PURE__ */ jsxs(
490
+ "select",
491
+ {
492
+ value: page.displayCondition?.questionId ?? "",
493
+ onChange: (event) => {
494
+ const selected = schema.fields.find((field) => field.id === event.currentTarget.value);
495
+ updatePage(page.id, (current) => {
496
+ if (selected === void 0) {
497
+ const { displayCondition: _condition, ...withoutCondition } = current;
498
+ return withoutCondition;
499
+ }
500
+ return {
501
+ ...current,
502
+ displayCondition: conditionWithValue(
503
+ selected.id,
504
+ conditionOperators(selected)[0] ?? "not_empty",
505
+ defaultConditionValue(selected)
506
+ )
507
+ };
508
+ });
509
+ },
510
+ children: [
511
+ /* @__PURE__ */ jsx("option", { value: "", children: translate("builder.alwaysVisible") }),
512
+ availableSources.map((field) => /* @__PURE__ */ jsx("option", { value: field.id, children: field.title }, field.id))
513
+ ]
514
+ }
515
+ )
516
+ ] }),
517
+ page.displayCondition !== void 0 && source !== void 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
518
+ /* @__PURE__ */ jsx(
519
+ "select",
520
+ {
521
+ "aria-label": translate("builder.conditionOperator"),
522
+ value: page.displayCondition.operator,
523
+ onChange: (event) => {
524
+ const operator = event.currentTarget.value;
525
+ updatePage(page.id, (current) => ({
526
+ ...current,
527
+ displayCondition: conditionWithValue(source.id, operator, defaultConditionValue(source))
528
+ }));
529
+ },
530
+ children: conditionOperators(source).map((operator) => /* @__PURE__ */ jsx("option", { value: operator, children: translate(operatorKey(operator)) }, operator))
531
+ }
532
+ ),
533
+ /* @__PURE__ */ jsx(
534
+ ConditionValueEditor,
535
+ {
536
+ source,
537
+ condition: page.displayCondition,
538
+ onChange: (condition) => updatePage(page.id, (current) => ({ ...current, displayCondition: condition })),
539
+ translate
540
+ }
541
+ )
542
+ ] }) : null
543
+ ] })
544
+ ] }, page.id);
545
+ }),
546
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__page-add", children: [
547
+ /* @__PURE__ */ jsxs("label", { children: [
548
+ translate("builder.pageQuestion"),
549
+ /* @__PURE__ */ jsxs(
550
+ "select",
551
+ {
552
+ value: newPageQuestionId,
553
+ disabled: movablePageQuestions.length === 0,
554
+ onChange: (event) => setNewPageQuestionId(event.currentTarget.value),
555
+ children: [
556
+ /* @__PURE__ */ jsx("option", { value: "", children: "\u2014" }),
557
+ schema.fields.filter((field) => movablePageQuestions.includes(field.id)).map((field) => /* @__PURE__ */ jsx("option", { value: field.id, children: field.title }, field.id))
558
+ ]
559
+ }
560
+ )
561
+ ] }),
562
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: movablePageQuestions.length === 0, onClick: addPage, children: translate("builder.addPage") })
563
+ ] })
564
+ ] })
565
+ ] }),
566
+ /* @__PURE__ */ jsxs("section", { className: "form-engine-builder__localization", "aria-labelledby": "builder-localization-heading", children: [
567
+ /* @__PURE__ */ jsx("h2", { id: "builder-localization-heading", children: translate("builder.localization") }),
568
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
569
+ /* @__PURE__ */ jsxs("label", { children: [
570
+ translate("builder.defaultLocale"),
571
+ /* @__PURE__ */ jsx(
572
+ "input",
573
+ {
574
+ 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
+ }
585
+ }
586
+ )
587
+ ] }),
588
+ /* @__PURE__ */ jsxs("label", { children: [
589
+ translate("builder.addLocale"),
590
+ /* @__PURE__ */ jsx("input", { value: newLocale, onChange: (event) => setNewLocale(event.currentTarget.value) })
591
+ ] }),
592
+ /* @__PURE__ */ jsx("button", { type: "button", onClick: addLocale, children: translate("builder.addLocale") }),
593
+ /* @__PURE__ */ jsxs("label", { children: [
594
+ translate("builder.editLocale"),
595
+ /* @__PURE__ */ jsxs("select", { value: editingLocale, onChange: (event) => setEditingLocale(event.currentTarget.value), children: [
596
+ /* @__PURE__ */ jsx("option", { value: "", children: "\u2014" }),
597
+ (schema.supportedLocales ?? []).filter((item) => item !== schema.defaultLocale).map((item) => /* @__PURE__ */ jsx("option", { value: item, children: item }, item))
598
+ ] })
599
+ ] }),
600
+ /* @__PURE__ */ jsx(
601
+ "button",
602
+ {
603
+ type: "button",
604
+ disabled: translationAdapter === void 0 || editingLocale.length === 0 || isTranslating,
605
+ onClick: () => void translateAll(),
606
+ children: translate("builder.autoTranslate")
607
+ }
608
+ )
609
+ ] }),
610
+ translationAdapter === void 0 ? /* @__PURE__ */ jsx("p", { children: translate("builder.translationUnavailable") }) : null,
611
+ translationError === null ? null : /* @__PURE__ */ jsx("p", { className: "form-engine-builder__error", children: translationError }),
612
+ editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
613
+ /* @__PURE__ */ jsxs("label", { children: [
614
+ translate("builder.questionTitle"),
615
+ /* @__PURE__ */ jsx(
616
+ "input",
617
+ {
618
+ value: schema.translations?.[editingLocale]?.title ?? "",
619
+ onChange: (event) => updateFormTranslation("title", event.currentTarget.value)
620
+ }
621
+ )
622
+ ] }),
623
+ /* @__PURE__ */ jsxs("label", { children: [
624
+ translate("builder.pageDescription"),
625
+ /* @__PURE__ */ jsx(
626
+ "input",
627
+ {
628
+ value: schema.translations?.[editingLocale]?.description ?? "",
629
+ onChange: (event) => updateFormTranslation("description", event.currentTarget.value)
630
+ }
631
+ )
632
+ ] })
633
+ ] })
634
+ ] }),
210
635
  /* @__PURE__ */ jsx("div", { className: "form-engine-builder__list", children: schema.fields.map((field, index) => {
211
636
  const condition = field.displayCondition;
212
637
  const source = condition === void 0 ? void 0 : schema.fields.find((item) => item.id === condition.questionId);
@@ -283,6 +708,98 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
283
708
  translate("builder.required")
284
709
  ] })
285
710
  ] }),
711
+ schema.pages === void 0 ? null : /* @__PURE__ */ jsxs("label", { children: [
712
+ translate("builder.questionPage"),
713
+ /* @__PURE__ */ jsx(
714
+ "select",
715
+ {
716
+ value: pageForField(field.id)?.id ?? "",
717
+ onChange: (event) => assignFieldToPage(field.id, event.currentTarget.value),
718
+ children: schema.pages.map((page, pageIndex) => /* @__PURE__ */ jsx("option", { value: page.id, children: page.title ?? `${translate("builder.newPage")} ${pageIndex + 1}` }, page.id))
719
+ }
720
+ )
721
+ ] }),
722
+ editingLocale.length === 0 ? null : /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__translation-editor", children: [
723
+ /* @__PURE__ */ jsx("strong", { children: editingLocale }),
724
+ /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
725
+ /* @__PURE__ */ jsxs("label", { children: [
726
+ translate("builder.questionTitle"),
727
+ /* @__PURE__ */ jsx(
728
+ "input",
729
+ {
730
+ 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
+ }
744
+ }
745
+ )
746
+ ] }),
747
+ /* @__PURE__ */ jsxs("label", { children: [
748
+ translate("builder.pageDescription"),
749
+ /* @__PURE__ */ jsx(
750
+ "input",
751
+ {
752
+ 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
+ }
766
+ }
767
+ )
768
+ ] })
769
+ ] }),
770
+ "options" in field ? field.options.map((option, optionIndex) => /* @__PURE__ */ jsxs("label", { children: [
771
+ translate("builder.optionLabel", { index: optionIndex + 1 }),
772
+ " (",
773
+ editingLocale,
774
+ ")",
775
+ /* @__PURE__ */ jsx(
776
+ "input",
777
+ {
778
+ 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
+ }
799
+ }
800
+ )
801
+ ] }, option.id)) : null
802
+ ] }),
286
803
  field.type === "rating" ? /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
287
804
  /* @__PURE__ */ jsxs("label", { children: [
288
805
  translate("builder.minimum"),
@@ -448,10 +965,13 @@ function FormBuilder({ schema, onChange, locale = "en", translator }) {
448
965
  import {
449
966
  assertValidFormSchema,
450
967
  calculateFieldVisibility,
968
+ calculatePageVisibility,
969
+ resolveLocalizedSchema,
451
970
  selectVisibleAnswers,
452
- validateAnswers
971
+ validateAnswers,
972
+ validatePageAnswers
453
973
  } from "@form-engine-ts/core";
454
- import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
974
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState as useState2 } from "react";
455
975
  import { jsx as jsx2 } from "react/jsx-runtime";
456
976
  var FormContext = createContext(null);
457
977
  function issuesByField(issues) {
@@ -470,13 +990,17 @@ function FormProvider({
470
990
  }) {
471
991
  const validSchema = useMemo(() => {
472
992
  assertValidFormSchema(schema);
473
- return schema;
474
- }, [schema]);
475
- const [values, setValues] = useState(() => ({ ...initialValues }));
476
- const [errors, setErrors] = useState({});
477
- const [submitStatus, setSubmitStatus] = useState("idle");
478
- const [submitError, setSubmitError] = useState(null);
993
+ const localized = resolveLocalizedSchema(schema, locale);
994
+ assertValidFormSchema(localized);
995
+ return localized;
996
+ }, [locale, schema]);
997
+ const [values, setValues] = useState2(() => ({ ...initialValues }));
998
+ const [errors, setErrors] = useState2({});
999
+ const [submitStatus, setSubmitStatus] = useState2("idle");
1000
+ const [submitError, setSubmitError] = useState2(null);
1001
+ const [validationPageIndex, setValidationPageIndex] = useState2(null);
479
1002
  const visibility = useMemo(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
1003
+ const pageVisibility = useMemo(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
480
1004
  useEffect(() => {
481
1005
  const fieldIds = new Set(validSchema.fields.map((field) => field.id));
482
1006
  setValues((current) => Object.fromEntries(Object.entries(current).filter(([fieldId]) => fieldIds.has(fieldId))));
@@ -487,7 +1011,7 @@ function FormProvider({
487
1011
  const next = { ...current, [fieldId]: value };
488
1012
  setErrors((currentErrors) => {
489
1013
  if (Object.keys(currentErrors).length === 0) return currentErrors;
490
- const result = validateAnswers(validSchema, next);
1014
+ const result = validationPageIndex === null ? validateAnswers(validSchema, next) : validatePageAnswers(validSchema, validationPageIndex, next);
491
1015
  return issuesByField(result.issues);
492
1016
  });
493
1017
  return next;
@@ -495,11 +1019,34 @@ function FormProvider({
495
1019
  setSubmitStatus((current) => current === "success" || current === "error" ? "idle" : current);
496
1020
  setSubmitError(null);
497
1021
  },
1022
+ [validSchema, validationPageIndex]
1023
+ );
1024
+ const restoreValues = useCallback(
1025
+ (restoredValues) => {
1026
+ const fieldIds = new Set(validSchema.fields.map((field) => field.id));
1027
+ setValues(Object.fromEntries(Object.entries(restoredValues).filter(([fieldId]) => fieldIds.has(fieldId))));
1028
+ setErrors({});
1029
+ setValidationPageIndex(null);
1030
+ setSubmitStatus("idle");
1031
+ setSubmitError(null);
1032
+ },
498
1033
  [validSchema]
499
1034
  );
1035
+ const validatePage = useCallback(
1036
+ (pageIndex) => {
1037
+ const result = validatePageAnswers(validSchema, pageIndex, values);
1038
+ setErrors(issuesByField(result.issues));
1039
+ setValidationPageIndex(result.valid ? null : pageIndex);
1040
+ setSubmitStatus("idle");
1041
+ setSubmitError(null);
1042
+ return result;
1043
+ },
1044
+ [validSchema, values]
1045
+ );
500
1046
  const reset = useCallback(() => {
501
1047
  setValues({ ...initialValues });
502
1048
  setErrors({});
1049
+ setValidationPageIndex(null);
503
1050
  setSubmitStatus("idle");
504
1051
  setSubmitError(null);
505
1052
  }, [initialValues]);
@@ -507,11 +1054,13 @@ function FormProvider({
507
1054
  const validation = validateAnswers(validSchema, values);
508
1055
  if (!validation.valid) {
509
1056
  setErrors(issuesByField(validation.issues));
1057
+ setValidationPageIndex(null);
510
1058
  setSubmitStatus("error");
511
1059
  setSubmitError(null);
512
1060
  return false;
513
1061
  }
514
1062
  setErrors({});
1063
+ setValidationPageIndex(null);
515
1064
  setSubmitStatus("submitting");
516
1065
  setSubmitError(null);
517
1066
  try {
@@ -536,11 +1085,14 @@ function FormProvider({
536
1085
  translator,
537
1086
  values,
538
1087
  visibility,
1088
+ pageVisibility,
539
1089
  errors,
540
1090
  submitStatus,
541
1091
  submitError,
542
1092
  isSubmitting: submitStatus === "submitting",
543
1093
  setValue,
1094
+ restoreValues,
1095
+ validatePage,
544
1096
  reset,
545
1097
  submit,
546
1098
  translate
@@ -548,13 +1100,16 @@ function FormProvider({
548
1100
  [
549
1101
  errors,
550
1102
  locale,
1103
+ pageVisibility,
551
1104
  reset,
1105
+ restoreValues,
552
1106
  setValue,
553
1107
  submit,
554
1108
  submitError,
555
1109
  submitStatus,
556
1110
  translate,
557
1111
  translator,
1112
+ validatePage,
558
1113
  validSchema,
559
1114
  values,
560
1115
  visibility
@@ -579,7 +1134,7 @@ function useField(fieldId) {
579
1134
  import {
580
1135
  validateAnswers as validateAnswers2
581
1136
  } from "@form-engine-ts/core";
582
- import { useId } from "react";
1137
+ import { useEffect as useEffect2, useId, useMemo as useMemo2, useRef, useState as useState3 } from "react";
583
1138
  import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
584
1139
  function describedBy(field, error, helpId, errorId) {
585
1140
  const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
@@ -758,35 +1313,156 @@ function DefaultField(props) {
758
1313
  /* @__PURE__ */ jsx3(FieldMessage, { props })
759
1314
  ] });
760
1315
  }
1316
+ function isRecord(value) {
1317
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1318
+ }
1319
+ function isFormValue(value) {
1320
+ return value === void 0 || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) || Array.isArray(value) && value.every((item) => typeof item === "string");
1321
+ }
1322
+ function parseDraft(serialized) {
1323
+ try {
1324
+ const value = JSON.parse(serialized);
1325
+ if (!isRecord(value) || typeof value.formId !== "string" || typeof value.formVersion !== "number" || !Number.isInteger(value.formVersion) || typeof value.savedAt !== "string" || !isRecord(value.values) || !Object.values(value.values).every(isFormValue)) {
1326
+ return null;
1327
+ }
1328
+ return {
1329
+ formId: value.formId,
1330
+ formVersion: value.formVersion,
1331
+ savedAt: value.savedAt,
1332
+ values: Object.fromEntries(
1333
+ Object.entries(value.values).filter((entry) => isFormValue(entry[1]))
1334
+ )
1335
+ };
1336
+ } catch {
1337
+ return null;
1338
+ }
1339
+ }
761
1340
  function FormRenderer({
762
1341
  components = {},
763
1342
  className = "",
764
1343
  successMessageKey,
765
- errorMessageKey
1344
+ errorMessageKey,
1345
+ autoSaveKey
766
1346
  }) {
767
1347
  const form = useForm();
768
1348
  const prefix = useId().replace(/:/g, "");
1349
+ const formRef = useRef(null);
1350
+ const loadedDraftKey = useRef(null);
1351
+ const [draftRestored, setDraftRestored] = useState3(false);
1352
+ const [currentPageIndex, setCurrentPageIndex] = useState3(0);
1353
+ const [focusFieldId, setFocusFieldId] = useState3(null);
1354
+ const pages = form.schema.pages;
1355
+ const visiblePageIndexes = useMemo2(
1356
+ () => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
1357
+ [form.pageVisibility, pages]
1358
+ );
1359
+ const activePage = pages?.[currentPageIndex];
1360
+ const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
1361
+ const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
1362
+ useEffect2(() => {
1363
+ if (pages === void 0 || visiblePageIndexes.length === 0) {
1364
+ setCurrentPageIndex(0);
1365
+ return;
1366
+ }
1367
+ if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
1368
+ }, [currentPageIndex, pages, visiblePageIndexes]);
1369
+ useEffect2(() => {
1370
+ if (focusFieldId === null) return;
1371
+ const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
1372
+ (element) => element.dataset.fieldId === focusFieldId
1373
+ );
1374
+ const control = fieldContainer?.querySelector("input, select, textarea");
1375
+ if (control !== void 0 && control !== null) {
1376
+ control.focus();
1377
+ setFocusFieldId(null);
1378
+ }
1379
+ }, [focusFieldId]);
1380
+ useEffect2(() => {
1381
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1382
+ const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
1383
+ if (loadedDraftKey.current === loadIdentity) return;
1384
+ loadedDraftKey.current = loadIdentity;
1385
+ const serialized = globalThis.localStorage.getItem(autoSaveKey);
1386
+ if (serialized === null) return;
1387
+ const draft = parseDraft(serialized);
1388
+ if (draft === null || draft.formId !== form.schema.id || draft.formVersion !== form.schema.version) return;
1389
+ form.restoreValues(draft.values);
1390
+ setDraftRestored(true);
1391
+ }, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
1392
+ useEffect2(() => {
1393
+ if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
1394
+ if (form.submitStatus === "success") return;
1395
+ const timeout = globalThis.setTimeout(() => {
1396
+ const draft = {
1397
+ formId: form.schema.id,
1398
+ formVersion: form.schema.version,
1399
+ values: form.values,
1400
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
1401
+ };
1402
+ globalThis.localStorage.setItem(autoSaveKey, JSON.stringify(draft));
1403
+ }, 500);
1404
+ return () => globalThis.clearTimeout(timeout);
1405
+ }, [autoSaveKey, form.schema.id, form.schema.version, form.submitStatus, form.values]);
1406
+ const focusFirstIssue = (fieldId) => {
1407
+ if (fieldId !== void 0) setFocusFieldId(fieldId);
1408
+ };
1409
+ const handleNext = () => {
1410
+ const result = form.validatePage(currentPageIndex);
1411
+ if (!result.valid) {
1412
+ focusFirstIssue(result.issues[0]?.fieldId);
1413
+ return;
1414
+ }
1415
+ const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
1416
+ if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
1417
+ };
769
1418
  const handleSubmit = async (event) => {
770
1419
  event.preventDefault();
771
- const formElement = event.currentTarget;
772
1420
  const validation = validateAnswers2(form.schema, form.values);
773
1421
  const firstInvalidFieldId = validation.issues[0]?.fieldId;
774
1422
  const valid = await form.submit();
775
- if (!valid && firstInvalidFieldId !== void 0) {
776
- queueMicrotask(() => {
777
- const fieldContainer = [...formElement.querySelectorAll("[data-field-id]")].find(
778
- (element) => element.dataset.fieldId === firstInvalidFieldId
779
- );
780
- fieldContainer?.querySelector("input, select, textarea")?.focus();
781
- });
1423
+ if (!valid) {
1424
+ const invalidPageIndex = pages?.findIndex(
1425
+ (page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
1426
+ );
1427
+ if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
1428
+ focusFirstIssue(firstInvalidFieldId);
1429
+ return;
782
1430
  }
1431
+ if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
1432
+ globalThis.localStorage.removeItem(autoSaveKey);
1433
+ setDraftRestored(false);
1434
+ }
1435
+ setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
783
1436
  };
784
- return /* @__PURE__ */ jsxs2("form", { className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
1437
+ return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
785
1438
  /* @__PURE__ */ jsxs2("header", { className: "fe-header", children: [
786
1439
  /* @__PURE__ */ jsx3("h1", { children: form.schema.title }),
787
- form.schema.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { children: form.schema.description })
1440
+ form.schema.description === void 0 ? null : /* @__PURE__ */ jsx3("p", { children: form.schema.description }),
1441
+ pages === void 0 ? null : /* @__PURE__ */ jsxs2("div", { className: "fe-progress", children: [
1442
+ /* @__PURE__ */ jsx3(
1443
+ "div",
1444
+ {
1445
+ className: "form-progress-bar",
1446
+ role: "progressbar",
1447
+ "aria-valuemin": 1,
1448
+ "aria-valuemax": visiblePageIndexes.length,
1449
+ "aria-valuenow": activeVisibleIndex + 1,
1450
+ children: /* @__PURE__ */ jsx3(
1451
+ "div",
1452
+ {
1453
+ className: "form-progress-fill",
1454
+ style: { width: `${(activeVisibleIndex + 1) / visiblePageIndexes.length * 100}%` }
1455
+ }
1456
+ )
1457
+ }
1458
+ ),
1459
+ /* @__PURE__ */ jsx3("span", { children: form.translate("form.step", { current: activeVisibleIndex + 1, total: visiblePageIndexes.length }) })
1460
+ ] }),
1461
+ draftRestored ? /* @__PURE__ */ jsx3("span", { className: "form-draft-badge", children: form.translate("form.draftRestored") }) : null
788
1462
  ] }),
789
- /* @__PURE__ */ jsx3("div", { className: "fe-fields", children: form.schema.fields.filter((field) => form.visibility[field.id] === true).map((field) => {
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 }),
1465
+ /* @__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) => {
790
1466
  const props = {
791
1467
  field,
792
1468
  value: form.values[field.id],
@@ -800,7 +1476,18 @@ function FormRenderer({
800
1476
  const Component = components[field.type];
801
1477
  return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
802
1478
  }) }),
803
- /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: form.isSubmitting, children: form.translate(form.schema.submitLabelKey ?? "form.submit") }),
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") })
1490
+ ] }),
804
1491
  /* @__PURE__ */ jsxs2("div", { className: "fe-status", "aria-live": "polite", children: [
805
1492
  form.submitStatus === "success" && successMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "status", children: form.translate(successMessageKey) }) : null,
806
1493
  form.submitStatus === "error" && form.submitError !== null && errorMessageKey !== void 0 ? /* @__PURE__ */ jsx3("div", { role: "alert", children: form.translate(errorMessageKey) }) : null