@fogpipe/forma-react 0.17.0 → 0.17.1

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/README.md CHANGED
@@ -199,13 +199,13 @@ The event system lets you observe form lifecycle events for side effects like an
199
199
 
200
200
  ### Available Events
201
201
 
202
- | Event | Description |
203
- | -------------- | ------------------------------------------------------- |
204
- | `fieldChanged` | Fires after a field value changes |
205
- | `preSubmit` | Fires before validation; `data` is mutable |
206
- | `postSubmit` | Fires after submission (success, error, or invalid) |
207
- | `pageChanged` | Fires when wizard page changes |
208
- | `formReset` | Fires after `resetForm()` completes |
202
+ | Event | Description |
203
+ | -------------- | --------------------------------------------------- |
204
+ | `fieldChanged` | Fires after a field value changes |
205
+ | `preSubmit` | Fires before validation; `data` is mutable |
206
+ | `postSubmit` | Fires after submission (success, error, or invalid) |
207
+ | `pageChanged` | Fires when wizard page changes |
208
+ | `formReset` | Fires after `resetForm()` completes |
209
209
 
210
210
  ### Declarative Registration
211
211
 
@@ -217,7 +217,10 @@ const forma = useForma({
217
217
  onSubmit: handleSubmit,
218
218
  on: {
219
219
  fieldChanged: (event) => {
220
- analytics.track("field_changed", { path: event.path, source: event.source });
220
+ analytics.track("field_changed", {
221
+ path: event.path,
222
+ source: event.source,
223
+ });
221
224
  },
222
225
  preSubmit: async (event) => {
223
226
  event.data.token = await getCSRFToken();
@@ -302,28 +305,28 @@ formRef.current?.setValues({ name: "John" });
302
305
 
303
306
  ### useForma Methods
304
307
 
305
- | Method | Description |
306
- | --------------------------------- | ------------------------------------ |
307
- | `setFieldValue(path, value)` | Set field value |
308
- | `setFieldTouched(path, touched?)` | Mark field as touched |
309
- | `setValues(values)` | Set multiple values |
310
- | `validateField(path)` | Validate single field |
311
- | `validateForm()` | Validate entire form |
312
- | `submitForm()` | Submit the form |
313
- | `resetForm()` | Reset to initial values |
308
+ | Method | Description |
309
+ | --------------------------------- | -------------------------------------------- |
310
+ | `setFieldValue(path, value)` | Set field value |
311
+ | `setFieldTouched(path, touched?)` | Mark field as touched |
312
+ | `setValues(values)` | Set multiple values |
313
+ | `validateField(path)` | Validate single field |
314
+ | `validateForm()` | Validate entire form |
315
+ | `submitForm()` | Submit the form |
316
+ | `resetForm()` | Reset to initial values |
314
317
  | `on(event, listener)` | Register event listener; returns unsubscribe |
315
318
 
316
319
  ### useForma Options
317
320
 
318
- | Option | Type | Default | Description |
319
- | ---------------------- | -------------------------------- | -------- | ------------------------- |
320
- | `spec` | `Forma` | required | The Forma specification |
321
- | `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
322
- | `onSubmit` | `(data) => void` | - | Submit handler |
323
- | `onChange` | `(data, computed) => void` | - | Change handler |
324
- | `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
325
- | `referenceData` | `Record<string, unknown>` | - | Additional reference data |
326
- | `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
321
+ | Option | Type | Default | Description |
322
+ | ---------------------- | -------------------------------- | -------- | --------------------------- |
323
+ | `spec` | `Forma` | required | The Forma specification |
324
+ | `initialData` | `Record<string, unknown>` | `{}` | Initial form values |
325
+ | `onSubmit` | `(data) => void` | - | Submit handler |
326
+ | `onChange` | `(data, computed) => void` | - | Change handler |
327
+ | `validateOn` | `"change" \| "blur" \| "submit"` | `"blur"` | When to validate |
328
+ | `referenceData` | `Record<string, unknown>` | - | Additional reference data |
329
+ | `validationDebounceMs` | `number` | `0` | Debounce validation (ms) |
327
330
  | `on` | `FormaEvents` | - | Declarative event listeners |
328
331
 
329
332
  ## Error Boundary
package/dist/index.d.ts CHANGED
@@ -102,8 +102,13 @@ interface BaseFieldProps {
102
102
  required: boolean;
103
103
  /** Whether the field is disabled */
104
104
  disabled: boolean;
105
- /** Validation errors for this field */
105
+ /** Validation errors for this field (always populated — use visibleErrors for display) */
106
106
  errors: FieldError[];
107
+ /**
108
+ * Errors filtered by interaction state (touched or submitted).
109
+ * Use this for displaying errors in the UI to avoid showing errors on untouched fields.
110
+ */
111
+ visibleErrors: FieldError[];
107
112
  /** Handler for value changes */
108
113
  onChange: (value: unknown) => void;
109
114
  /** Handler for blur events */
@@ -384,7 +389,7 @@ interface ComponentMap {
384
389
  */
385
390
  interface LayoutProps {
386
391
  children: React.ReactNode;
387
- onSubmit: () => void;
392
+ onSubmit: (e?: React.FormEvent) => void;
388
393
  isSubmitting: boolean;
389
394
  isValid: boolean;
390
395
  }
@@ -513,8 +518,13 @@ interface GetFieldPropsResult {
513
518
  showRequiredIndicator: boolean;
514
519
  /** Whether field has been touched */
515
520
  touched: boolean;
516
- /** Validation errors for this field */
521
+ /** Validation errors for this field (always populated — use visibleErrors for display) */
517
522
  errors: FieldError[];
523
+ /**
524
+ * Errors filtered by interaction state (touched or submitted).
525
+ * Use this for displaying errors in the UI to avoid showing errors on untouched fields.
526
+ */
527
+ visibleErrors: FieldError[];
518
528
  /** Handler for value changes */
519
529
  onChange: (value: unknown) => void;
520
530
  /** Handler for blur events */
@@ -631,6 +641,12 @@ interface WizardHelpers {
631
641
  goToPage: (index: number) => void;
632
642
  nextPage: () => void;
633
643
  previousPage: () => void;
644
+ /**
645
+ * Safe "Next" handler for wizard navigation.
646
+ * Advances to the next page if one exists. Never triggers submission.
647
+ * Use this instead of conditionally calling nextPage/onSubmit in a single button.
648
+ */
649
+ handleNext: () => void;
634
650
  hasNextPage: boolean;
635
651
  hasPreviousPage: boolean;
636
652
  canProceed: boolean;
package/dist/index.js CHANGED
@@ -443,6 +443,20 @@ function useForma(options) {
443
443
  const hasNextPage = clampedPageIndex < visiblePages.length - 1;
444
444
  const hasPreviousPage = clampedPageIndex > 0;
445
445
  const isLastPage = clampedPageIndex === visiblePages.length - 1;
446
+ const advanceToNextPage = () => {
447
+ if (hasNextPage) {
448
+ const toIndex = clampedPageIndex + 1;
449
+ dispatch({ type: "SET_PAGE", page: toIndex });
450
+ const newPage = visiblePages[toIndex];
451
+ if (newPage) {
452
+ fireEvent("pageChanged", {
453
+ fromIndex: clampedPageIndex,
454
+ toIndex,
455
+ page: newPage
456
+ });
457
+ }
458
+ }
459
+ };
446
460
  return {
447
461
  pages,
448
462
  currentPageIndex: clampedPageIndex,
@@ -461,20 +475,7 @@ function useForma(options) {
461
475
  }
462
476
  }
463
477
  },
464
- nextPage: () => {
465
- if (hasNextPage) {
466
- const toIndex = clampedPageIndex + 1;
467
- dispatch({ type: "SET_PAGE", page: toIndex });
468
- const newPage = visiblePages[toIndex];
469
- if (newPage) {
470
- fireEvent("pageChanged", {
471
- fromIndex: clampedPageIndex,
472
- toIndex,
473
- page: newPage
474
- });
475
- }
476
- }
477
- },
478
+ nextPage: advanceToNextPage,
478
479
  previousPage: () => {
479
480
  if (hasPreviousPage) {
480
481
  const toIndex = clampedPageIndex - 1;
@@ -489,6 +490,10 @@ function useForma(options) {
489
490
  }
490
491
  }
491
492
  },
493
+ // Same function as nextPage — exposed as a separate name so consumers can
494
+ // bind a single "Next" button without risk of accidentally triggering submission.
495
+ // nextPage is already a no-op on the last page.
496
+ handleNext: advanceToNextPage,
492
497
  hasNextPage,
493
498
  hasPreviousPage,
494
499
  canProceed: (() => {
@@ -517,7 +522,15 @@ function useForma(options) {
517
522
  return pageErrors.length === 0;
518
523
  }
519
524
  };
520
- }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
525
+ }, [
526
+ spec,
527
+ state.data,
528
+ state.currentPage,
529
+ computed,
530
+ validation,
531
+ visibility,
532
+ fireEvent
533
+ ]);
521
534
  useEffect(() => {
522
535
  const events = pendingEventsRef.current;
523
536
  if (events.length === 0) return;
@@ -621,9 +634,9 @@ function useForma(options) {
621
634
  }
622
635
  const fieldErrors = validation.errors.filter((e) => e.field === path);
623
636
  const isTouched = state.touched[path] ?? false;
624
- const showErrors = validateOn === "change" || validateOn === "blur" && isTouched || state.isSubmitted;
625
- const displayedErrors = showErrors ? fieldErrors : [];
626
- const hasErrors = displayedErrors.length > 0;
637
+ const shouldShowErrors = validateOn === "change" || validateOn === "blur" && isTouched || state.isSubmitted;
638
+ const visibleFieldErrors = shouldShowErrors ? fieldErrors : [];
639
+ const hasVisibleErrors = visibleFieldErrors.length > 0;
627
640
  const isRequired = required[path] ?? false;
628
641
  const schemaProperty = spec.schema.properties[path];
629
642
  const isBooleanField = (schemaProperty == null ? void 0 : schemaProperty.type) === "boolean" || (fieldDef == null ? void 0 : fieldDef.type) === "boolean";
@@ -643,12 +656,13 @@ function useForma(options) {
643
656
  required: isRequired,
644
657
  showRequiredIndicator,
645
658
  touched: isTouched,
646
- errors: displayedErrors,
659
+ errors: fieldErrors,
660
+ visibleErrors: visibleFieldErrors,
647
661
  onChange: handlers.onChange,
648
662
  onBlur: handlers.onBlur,
649
- // ARIA accessibility attributes
650
- "aria-invalid": hasErrors || void 0,
651
- "aria-describedby": hasErrors ? `${path}-error` : void 0,
663
+ // ARIA accessibility attributes (driven by visibleErrors, not all errors)
664
+ "aria-invalid": hasVisibleErrors || void 0,
665
+ "aria-describedby": hasVisibleErrors ? `${path}-error` : void 0,
652
666
  "aria-required": isRequired || void 0,
653
667
  // Adorner props (only for adornable field types)
654
668
  ...adornerProps,
@@ -719,7 +733,8 @@ function useForma(options) {
719
733
  showRequiredIndicator: false,
720
734
  // Item fields don't show required indicator
721
735
  touched: isTouched,
722
- errors: showErrors ? fieldErrors : [],
736
+ errors: fieldErrors,
737
+ visibleErrors: showErrors ? fieldErrors : [],
723
738
  onChange: handlers.onChange,
724
739
  onBlur: handlers.onBlur,
725
740
  options: visibleOptions
@@ -871,19 +886,10 @@ function useFormaContext() {
871
886
  // src/FormRenderer.tsx
872
887
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
873
888
  function DefaultLayout({ children, onSubmit, isSubmitting }) {
874
- return /* @__PURE__ */ jsxs(
875
- "form",
876
- {
877
- onSubmit: (e) => {
878
- e.preventDefault();
879
- onSubmit();
880
- },
881
- children: [
882
- children,
883
- /* @__PURE__ */ jsx("button", { type: "submit", disabled: isSubmitting, children: isSubmitting ? "Submitting..." : "Submit" })
884
- ]
885
- }
886
- );
889
+ return /* @__PURE__ */ jsxs("form", { onSubmit, children: [
890
+ children,
891
+ /* @__PURE__ */ jsx("button", { type: "submit", disabled: isSubmitting, children: isSubmitting ? "Submitting..." : "Submit" })
892
+ ] });
887
893
  }
888
894
  function DefaultFieldWrapper({
889
895
  fieldPath,
@@ -967,7 +973,7 @@ var FormRenderer = forwardRef(
967
973
  layout: Layout = DefaultLayout,
968
974
  fieldWrapper: FieldWrapper = DefaultFieldWrapper,
969
975
  pageWrapper: PageWrapper = DefaultPageWrapper,
970
- validateOn
976
+ validateOn = "blur"
971
977
  } = props;
972
978
  const forma = useForma({
973
979
  spec,
@@ -1012,6 +1018,7 @@ var FormRenderer = forwardRef(
1012
1018
  optionsVisibility: formaOptionsVisibility,
1013
1019
  touched: formaTouched,
1014
1020
  errors: formaErrors,
1021
+ isSubmitted: formaIsSubmitted,
1015
1022
  setFieldValue,
1016
1023
  setFieldTouched,
1017
1024
  getArrayHelpers
@@ -1045,6 +1052,8 @@ var FormRenderer = forwardRef(
1045
1052
  }
1046
1053
  const errors = formaErrors.filter((e) => e.field === fieldPath);
1047
1054
  const touched = formaTouched[fieldPath] ?? false;
1055
+ const showErrors = validateOn === "change" || validateOn === "blur" && touched || formaIsSubmitted;
1056
+ const visibleErrors = showErrors ? errors : [];
1048
1057
  const required = formaRequired[fieldPath] ?? false;
1049
1058
  const disabled = formaEnabled[fieldPath] === false;
1050
1059
  const schemaProperty = spec.schema.properties[fieldPath];
@@ -1060,6 +1069,7 @@ var FormRenderer = forwardRef(
1060
1069
  required,
1061
1070
  disabled,
1062
1071
  errors,
1072
+ visibleErrors,
1063
1073
  onChange: (value) => setFieldValue(fieldPath, value),
1064
1074
  onBlur: () => setFieldTouched(fieldPath),
1065
1075
  // Convenience properties
@@ -1212,6 +1222,8 @@ var FormRenderer = forwardRef(
1212
1222
  formaOptionsVisibility,
1213
1223
  formaTouched,
1214
1224
  formaErrors,
1225
+ formaIsSubmitted,
1226
+ validateOn,
1215
1227
  setFieldValue,
1216
1228
  setFieldTouched,
1217
1229
  getArrayHelpers
@@ -1238,10 +1250,17 @@ var FormRenderer = forwardRef(
1238
1250
  }
1239
1251
  return /* @__PURE__ */ jsx(Fragment, { children: renderedFields });
1240
1252
  }, [spec.pages, forma.wizard, PageWrapper, renderedFields]);
1253
+ const handleSubmit = useCallback2(
1254
+ (e) => {
1255
+ e == null ? void 0 : e.preventDefault();
1256
+ forma.submitForm();
1257
+ },
1258
+ [forma.submitForm]
1259
+ );
1241
1260
  return /* @__PURE__ */ jsx(FormaContext.Provider, { value: forma, children: /* @__PURE__ */ jsx(
1242
1261
  Layout,
1243
1262
  {
1244
- onSubmit: forma.submitForm,
1263
+ onSubmit: handleSubmit,
1245
1264
  isSubmitting: forma.isSubmitting,
1246
1265
  isValid: forma.isValid,
1247
1266
  children: content
@@ -1307,6 +1326,7 @@ function FieldRenderer({
1307
1326
  }
1308
1327
  const errors = forma.errors.filter((e) => e.field === fieldPath);
1309
1328
  const touched = forma.touched[fieldPath] ?? false;
1329
+ const visibleErrors = touched || forma.isSubmitted ? errors : [];
1310
1330
  const required = forma.required[fieldPath] ?? false;
1311
1331
  const disabled = forma.enabled[fieldPath] === false;
1312
1332
  const schemaProperty = spec.schema.properties[fieldPath];
@@ -1319,6 +1339,7 @@ function FieldRenderer({
1319
1339
  required,
1320
1340
  disabled,
1321
1341
  errors,
1342
+ visibleErrors,
1322
1343
  onChange: (value) => forma.setFieldValue(fieldPath, value),
1323
1344
  onBlur: () => forma.setFieldTouched(fieldPath),
1324
1345
  // Convenience properties
@@ -1478,9 +1499,13 @@ function FieldRenderer({
1478
1499
  const sourceValue = fieldDef.source ? forma.data[fieldDef.source] ?? forma.computed[fieldDef.source] : void 0;
1479
1500
  const {
1480
1501
  onChange: _onChange,
1502
+ // omit from display props
1481
1503
  value: _value,
1504
+ // omit from display props
1482
1505
  ...displayBaseProps
1483
1506
  } = baseProps;
1507
+ void _onChange;
1508
+ void _value;
1484
1509
  fieldProps = {
1485
1510
  ...displayBaseProps,
1486
1511
  fieldType: "display",