@fogpipe/forma-react 0.16.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/src/useForma.ts CHANGED
@@ -117,6 +117,12 @@ export interface WizardHelpers {
117
117
  goToPage: (index: number) => void;
118
118
  nextPage: () => void;
119
119
  previousPage: () => void;
120
+ /**
121
+ * Safe "Next" handler for wizard navigation.
122
+ * Advances to the next page if one exists. Never triggers submission.
123
+ * Use this instead of conditionally calling nextPage/onSubmit in a single button.
124
+ */
125
+ handleNext: () => void;
120
126
  hasNextPage: boolean;
121
127
  hasPreviousPage: boolean;
122
128
  canProceed: boolean;
@@ -337,10 +343,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
337
343
 
338
344
  // Helper: fire an event to both declarative `on` handlers and imperative listeners
339
345
  const fireEvent = useCallback(
340
- <K extends keyof FormaEventMap>(
341
- event: K,
342
- payload: FormaEventMap[K],
343
- ) => {
346
+ <K extends keyof FormaEventMap>(event: K, payload: FormaEventMap[K]) => {
344
347
  // Declarative handler (via ref for latest callback)
345
348
  try {
346
349
  const handler = onEventsRef.current?.[event];
@@ -494,11 +497,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
494
497
 
495
498
  // Queue a fieldChanged event (captures previousValue from current state ref)
496
499
  const queueFieldChangedEvent = useCallback(
497
- (
498
- path: string,
499
- value: unknown,
500
- source: "user" | "reset" | "setValues",
501
- ) => {
500
+ (path: string, value: unknown, source: "user" | "reset" | "setValues") => {
502
501
  if (isFiringEventsRef.current) return; // recursion guard
503
502
  const previousValue = getValueAtPath(path);
504
503
  if (previousValue === value) return; // no actual change
@@ -584,8 +583,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
584
583
  postSubmitPayload = {
585
584
  data: submissionData,
586
585
  success: false,
587
- error:
588
- error instanceof Error ? error : new Error(String(error)),
586
+ error: error instanceof Error ? error : new Error(String(error)),
589
587
  };
590
588
  }
591
589
  } else {
@@ -676,6 +674,21 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
676
674
  const hasPreviousPage = clampedPageIndex > 0;
677
675
  const isLastPage = clampedPageIndex === visiblePages.length - 1;
678
676
 
677
+ const advanceToNextPage = () => {
678
+ if (hasNextPage) {
679
+ const toIndex = clampedPageIndex + 1;
680
+ dispatch({ type: "SET_PAGE", page: toIndex });
681
+ const newPage = visiblePages[toIndex];
682
+ if (newPage) {
683
+ fireEvent("pageChanged", {
684
+ fromIndex: clampedPageIndex,
685
+ toIndex,
686
+ page: newPage,
687
+ });
688
+ }
689
+ }
690
+ };
691
+
679
692
  return {
680
693
  pages,
681
694
  currentPageIndex: clampedPageIndex,
@@ -694,20 +707,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
694
707
  }
695
708
  }
696
709
  },
697
- nextPage: () => {
698
- if (hasNextPage) {
699
- const toIndex = clampedPageIndex + 1;
700
- dispatch({ type: "SET_PAGE", page: toIndex });
701
- const newPage = visiblePages[toIndex];
702
- if (newPage) {
703
- fireEvent("pageChanged", {
704
- fromIndex: clampedPageIndex,
705
- toIndex,
706
- page: newPage,
707
- });
708
- }
709
- }
710
- },
710
+ nextPage: advanceToNextPage,
711
711
  previousPage: () => {
712
712
  if (hasPreviousPage) {
713
713
  const toIndex = clampedPageIndex - 1;
@@ -722,6 +722,10 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
722
722
  }
723
723
  }
724
724
  },
725
+ // Same function as nextPage — exposed as a separate name so consumers can
726
+ // bind a single "Next" button without risk of accidentally triggering submission.
727
+ // nextPage is already a no-op on the last page.
728
+ handleNext: advanceToNextPage,
725
729
  hasNextPage,
726
730
  hasPreviousPage,
727
731
  canProceed: (() => {
@@ -756,7 +760,15 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
756
760
  return pageErrors.length === 0;
757
761
  },
758
762
  };
759
- }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
763
+ }, [
764
+ spec,
765
+ state.data,
766
+ state.currentPage,
767
+ computed,
768
+ validation,
769
+ visibility,
770
+ fireEvent,
771
+ ]);
760
772
 
761
773
  // Flush pending events after render (fieldChanged, formReset)
762
774
  useEffect(() => {
@@ -885,12 +897,12 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
885
897
 
886
898
  const fieldErrors = validation.errors.filter((e) => e.field === path);
887
899
  const isTouched = state.touched[path] ?? false;
888
- const showErrors =
900
+ const shouldShowErrors =
889
901
  validateOn === "change" ||
890
902
  (validateOn === "blur" && isTouched) ||
891
903
  state.isSubmitted;
892
- const displayedErrors = showErrors ? fieldErrors : [];
893
- const hasErrors = displayedErrors.length > 0;
904
+ const visibleFieldErrors = shouldShowErrors ? fieldErrors : [];
905
+ const hasVisibleErrors = visibleFieldErrors.length > 0;
894
906
  const isRequired = required[path] ?? false;
895
907
 
896
908
  // Boolean fields: hide asterisk unless they have validation rules (consent pattern)
@@ -922,12 +934,13 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
922
934
  required: isRequired,
923
935
  showRequiredIndicator,
924
936
  touched: isTouched,
925
- errors: displayedErrors,
937
+ errors: fieldErrors,
938
+ visibleErrors: visibleFieldErrors,
926
939
  onChange: handlers.onChange,
927
940
  onBlur: handlers.onBlur,
928
- // ARIA accessibility attributes
929
- "aria-invalid": hasErrors || undefined,
930
- "aria-describedby": hasErrors ? `${path}-error` : undefined,
941
+ // ARIA accessibility attributes (driven by visibleErrors, not all errors)
942
+ "aria-invalid": hasVisibleErrors || undefined,
943
+ "aria-describedby": hasVisibleErrors ? `${path}-error` : undefined,
931
944
  "aria-required": isRequired || undefined,
932
945
  // Adorner props (only for adornable field types)
933
946
  ...adornerProps,
@@ -1020,7 +1033,8 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
1020
1033
  required: false, // TODO: Evaluate item field required
1021
1034
  showRequiredIndicator: false, // Item fields don't show required indicator
1022
1035
  touched: isTouched,
1023
- errors: showErrors ? fieldErrors : [],
1036
+ errors: fieldErrors,
1037
+ visibleErrors: showErrors ? fieldErrors : [],
1024
1038
  onChange: handlers.onChange,
1025
1039
  onBlur: handlers.onBlur,
1026
1040
  options: visibleOptions,