@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/README.md +29 -26
- package/dist/index.d.ts +46 -6
- package/dist/index.js +95 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FieldRenderer.tsx +34 -4
- package/src/FormRenderer.tsx +47 -9
- package/src/__tests__/FieldRenderer.test.tsx +186 -0
- package/src/__tests__/FormRenderer.test.tsx +146 -0
- package/src/__tests__/canProceed.test.ts +243 -0
- package/src/__tests__/events.test.ts +15 -5
- package/src/__tests__/useForma.test.ts +108 -5
- package/src/events.ts +4 -1
- package/src/index.ts +2 -0
- package/src/types.ts +43 -4
- package/src/useForma.ts +48 -34
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
|
-
}, [
|
|
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
|
|
900
|
+
const shouldShowErrors =
|
|
889
901
|
validateOn === "change" ||
|
|
890
902
|
(validateOn === "blur" && isTouched) ||
|
|
891
903
|
state.isSubmitted;
|
|
892
|
-
const
|
|
893
|
-
const
|
|
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:
|
|
937
|
+
errors: fieldErrors,
|
|
938
|
+
visibleErrors: visibleFieldErrors,
|
|
926
939
|
onChange: handlers.onChange,
|
|
927
940
|
onBlur: handlers.onBlur,
|
|
928
|
-
// ARIA accessibility attributes
|
|
929
|
-
"aria-invalid":
|
|
930
|
-
"aria-describedby":
|
|
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:
|
|
1036
|
+
errors: fieldErrors,
|
|
1037
|
+
visibleErrors: showErrors ? fieldErrors : [],
|
|
1024
1038
|
onChange: handlers.onChange,
|
|
1025
1039
|
onBlur: handlers.onBlur,
|
|
1026
1040
|
options: visibleOptions,
|