@fogpipe/forma-react 0.17.0 → 0.18.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.
Files changed (46) hide show
  1. package/README.md +111 -26
  2. package/dist/FormRenderer-D_ZVK44t.d.ts +558 -0
  3. package/dist/chunk-5K4QITFH.js +1276 -0
  4. package/dist/chunk-5K4QITFH.js.map +1 -0
  5. package/dist/defaults/index.d.ts +56 -0
  6. package/dist/defaults/index.js +895 -0
  7. package/dist/defaults/index.js.map +1 -0
  8. package/dist/defaults/styles/forma-defaults.css +696 -0
  9. package/dist/index.d.ts +13 -549
  10. package/dist/index.js +34 -1273
  11. package/dist/index.js.map +1 -1
  12. package/package.json +17 -3
  13. package/src/FieldRenderer.tsx +12 -4
  14. package/src/FormRenderer.tsx +26 -9
  15. package/src/__tests__/FieldRenderer.test.tsx +5 -1
  16. package/src/__tests__/FormRenderer.test.tsx +146 -0
  17. package/src/__tests__/canProceed.test.ts +243 -0
  18. package/src/__tests__/defaults/components.test.tsx +818 -0
  19. package/src/__tests__/defaults/integration.test.tsx +494 -0
  20. package/src/__tests__/defaults/layout.test.tsx +298 -0
  21. package/src/__tests__/events.test.ts +15 -5
  22. package/src/__tests__/useForma.test.ts +108 -5
  23. package/src/defaults/DefaultFormRenderer.tsx +43 -0
  24. package/src/defaults/componentMap.ts +45 -0
  25. package/src/defaults/components/ArrayField.tsx +183 -0
  26. package/src/defaults/components/BooleanInput.tsx +32 -0
  27. package/src/defaults/components/ComputedDisplay.tsx +26 -0
  28. package/src/defaults/components/DateInput.tsx +59 -0
  29. package/src/defaults/components/DisplayField.tsx +15 -0
  30. package/src/defaults/components/FallbackField.tsx +35 -0
  31. package/src/defaults/components/MatrixField.tsx +98 -0
  32. package/src/defaults/components/MultiSelectInput.tsx +51 -0
  33. package/src/defaults/components/NumberInput.tsx +73 -0
  34. package/src/defaults/components/ObjectField.tsx +22 -0
  35. package/src/defaults/components/SelectInput.tsx +44 -0
  36. package/src/defaults/components/TextInput.tsx +48 -0
  37. package/src/defaults/components/TextareaInput.tsx +46 -0
  38. package/src/defaults/index.ts +33 -0
  39. package/src/defaults/layout/FieldWrapper.tsx +83 -0
  40. package/src/defaults/layout/FormLayout.tsx +34 -0
  41. package/src/defaults/layout/PageWrapper.tsx +18 -0
  42. package/src/defaults/layout/WizardLayout.tsx +130 -0
  43. package/src/defaults/styles/forma-defaults.css +696 -0
  44. package/src/events.ts +4 -1
  45. package/src/types.ts +16 -4
  46. package/src/useForma.ts +48 -34
package/src/types.ts CHANGED
@@ -26,8 +26,13 @@ export interface BaseFieldProps {
26
26
  required: boolean;
27
27
  /** Whether the field is disabled */
28
28
  disabled: boolean;
29
- /** Validation errors for this field */
29
+ /** Validation errors for this field (always populated — use visibleErrors for display) */
30
30
  errors: FieldError[];
31
+ /**
32
+ * Errors filtered by interaction state (touched or submitted).
33
+ * Use this for displaying errors in the UI to avoid showing errors on untouched fields.
34
+ */
35
+ visibleErrors: FieldError[];
31
36
  /** Handler for value changes */
32
37
  onChange: (value: unknown) => void;
33
38
  /** Handler for blur events */
@@ -322,7 +327,9 @@ export interface MatrixFieldProps extends Omit<
322
327
  fieldType: "matrix";
323
328
  /** Current matrix value: row ID → selected column value(s) */
324
329
  value: Record<string, string | number | string[] | number[]> | null;
325
- onChange: (value: Record<string, string | number | string[] | number[]>) => void;
330
+ onChange: (
331
+ value: Record<string, string | number | string[] | number[]>,
332
+ ) => void;
326
333
  /** Row definitions with visibility state */
327
334
  rows: Array<{ id: string; label: string; visible: boolean }>;
328
335
  /** Column definitions (shared options for all rows) */
@@ -380,7 +387,7 @@ export interface ComponentMap {
380
387
  */
381
388
  export interface LayoutProps {
382
389
  children: React.ReactNode;
383
- onSubmit: () => void;
390
+ onSubmit: (e?: React.FormEvent) => void;
384
391
  isSubmitting: boolean;
385
392
  isValid: boolean;
386
393
  }
@@ -552,8 +559,13 @@ export interface GetFieldPropsResult {
552
559
  showRequiredIndicator: boolean;
553
560
  /** Whether field has been touched */
554
561
  touched: boolean;
555
- /** Validation errors for this field */
562
+ /** Validation errors for this field (always populated — use visibleErrors for display) */
556
563
  errors: FieldError[];
564
+ /**
565
+ * Errors filtered by interaction state (touched or submitted).
566
+ * Use this for displaying errors in the UI to avoid showing errors on untouched fields.
567
+ */
568
+ visibleErrors: FieldError[];
557
569
  /** Handler for value changes */
558
570
  onChange: (value: unknown) => void;
559
571
  /** Handler for blur events */
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,