@fogpipe/forma-react 0.12.0 → 0.14.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/src/useForma.ts CHANGED
@@ -25,6 +25,8 @@ import type {
25
25
  GetSelectFieldPropsResult,
26
26
  GetArrayHelpersResult,
27
27
  } from "./types.js";
28
+ import { FormaEventEmitter } from "./events.js";
29
+ import type { FormaEventMap, FormaEvents } from "./events.js";
28
30
  import {
29
31
  getVisibility,
30
32
  getRequired,
@@ -62,6 +64,12 @@ export interface UseFormaOptions {
62
64
  * Set to 0 (default) for immediate validation.
63
65
  */
64
66
  validationDebounceMs?: number;
67
+ /**
68
+ * Declarative event listeners for form lifecycle events.
69
+ * Listeners are stable for the lifetime of the hook — the latest
70
+ * callback is always invoked via refs, without causing dependency changes.
71
+ */
72
+ on?: FormaEvents;
65
73
  }
66
74
 
67
75
  /**
@@ -167,6 +175,15 @@ export interface UseFormaReturn {
167
175
  /** Reset the form */
168
176
  resetForm: () => void;
169
177
 
178
+ /**
179
+ * Register an imperative event listener. Returns an unsubscribe function.
180
+ * Multiple listeners per event are supported; they fire in registration order.
181
+ */
182
+ on: <K extends keyof FormaEventMap>(
183
+ event: K,
184
+ listener: (payload: FormaEventMap[K]) => void | Promise<void>,
185
+ ) => () => void;
186
+
170
187
  // Helper methods for getting field props
171
188
  /** Get props for any field */
172
189
  getFieldProps: (path: string) => GetFieldPropsResult;
@@ -237,6 +254,23 @@ function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
237
254
  return defaults;
238
255
  }
239
256
 
257
+ /**
258
+ * Get default values from field definitions.
259
+ * Collects `defaultValue` from each field that specifies one.
260
+ * These are applied after boolean defaults but before initialData,
261
+ * so explicit defaults override type-implicit defaults,
262
+ * and runtime initialData overrides everything.
263
+ */
264
+ function getFieldDefaults(spec: Forma): Record<string, unknown> {
265
+ const defaults: Record<string, unknown> = {};
266
+ for (const [fieldPath, fieldDef] of Object.entries(spec.fields)) {
267
+ if (fieldDef.defaultValue !== undefined) {
268
+ defaults[fieldPath] = fieldDef.defaultValue;
269
+ }
270
+ }
271
+ return defaults;
272
+ }
273
+
240
274
  /**
241
275
  * Main Forma hook
242
276
  */
@@ -249,6 +283,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
249
283
  validateOn = "blur",
250
284
  referenceData,
251
285
  validationDebounceMs = 0,
286
+ on: onEvents,
252
287
  } = options;
253
288
 
254
289
  // Merge referenceData from options with spec.referenceData
@@ -264,7 +299,11 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
264
299
  }, [inputSpec, referenceData]);
265
300
 
266
301
  const [state, dispatch] = useReducer(formReducer, {
267
- data: { ...getDefaultBooleanValues(spec), ...initialData }, // Boolean defaults merged UNDER initialData
302
+ data: {
303
+ ...getDefaultBooleanValues(spec),
304
+ ...getFieldDefaults(spec),
305
+ ...initialData,
306
+ },
268
307
  touched: {},
269
308
  isSubmitting: false,
270
309
  isSubmitted: false,
@@ -279,6 +318,42 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
279
318
  // Track if we've initialized (to avoid calling onChange on first render)
280
319
  const hasInitialized = useRef(false);
281
320
 
321
+ // ── Event system ──────────────────────────────────────────────────────
322
+ const emitterRef = useRef(new FormaEventEmitter());
323
+ const onEventsRef = useRef(onEvents);
324
+ onEventsRef.current = onEvents;
325
+ const pendingEventsRef = useRef<
326
+ Array<{ event: keyof FormaEventMap; payload: unknown }>
327
+ >([]);
328
+ const isFiringEventsRef = useRef(false);
329
+
330
+ // Cleanup emitter on unmount
331
+ useEffect(() => {
332
+ const emitter = emitterRef.current;
333
+ return () => {
334
+ emitter.clear();
335
+ };
336
+ }, []);
337
+
338
+ // Helper: fire an event to both declarative `on` handlers and imperative listeners
339
+ const fireEvent = useCallback(
340
+ <K extends keyof FormaEventMap>(
341
+ event: K,
342
+ payload: FormaEventMap[K],
343
+ ) => {
344
+ // Declarative handler (via ref for latest callback)
345
+ try {
346
+ const handler = onEventsRef.current?.[event];
347
+ if (handler) (handler as (p: FormaEventMap[K]) => void)(payload);
348
+ } catch (error) {
349
+ console.error(`[forma] Error in "${event}" event handler:`, error);
350
+ }
351
+ // Imperative listeners
352
+ emitterRef.current.fire(event, payload);
353
+ },
354
+ [],
355
+ );
356
+
282
357
  // Calculate computed values
283
358
  const computed = useMemo(
284
359
  () => calculate(state.data, spec),
@@ -404,24 +479,62 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
404
479
  [state.data],
405
480
  );
406
481
 
482
+ // Helper to get value at nested path
483
+ // Uses stateDataRef to always access current state, avoiding stale closure issues
484
+ const getValueAtPath = useCallback((path: string): unknown => {
485
+ // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
486
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
487
+ let value: unknown = stateDataRef.current;
488
+ for (const part of parts) {
489
+ if (value === null || value === undefined) return undefined;
490
+ value = (value as Record<string, unknown>)[part];
491
+ }
492
+ return value;
493
+ }, []); // No dependencies - uses ref for current state
494
+
495
+ // Queue a fieldChanged event (captures previousValue from current state ref)
496
+ const queueFieldChangedEvent = useCallback(
497
+ (
498
+ path: string,
499
+ value: unknown,
500
+ source: "user" | "reset" | "setValues",
501
+ ) => {
502
+ if (isFiringEventsRef.current) return; // recursion guard
503
+ const previousValue = getValueAtPath(path);
504
+ if (previousValue === value) return; // no actual change
505
+ pendingEventsRef.current.push({
506
+ event: "fieldChanged",
507
+ payload: { path, value, previousValue, source },
508
+ });
509
+ },
510
+ [getValueAtPath],
511
+ );
512
+
407
513
  // Actions
408
514
  const setFieldValue = useCallback(
409
515
  (path: string, value: unknown) => {
516
+ queueFieldChangedEvent(path, value, "user");
410
517
  setNestedValue(path, value);
411
518
  if (validateOn === "change") {
412
519
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
413
520
  }
414
521
  },
415
- [validateOn, setNestedValue],
522
+ [validateOn, setNestedValue, queueFieldChangedEvent],
416
523
  );
417
524
 
418
525
  const setFieldTouched = useCallback((path: string, touched = true) => {
419
526
  dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
420
527
  }, []);
421
528
 
422
- const setValues = useCallback((values: Record<string, unknown>) => {
423
- dispatch({ type: "SET_VALUES", values });
424
- }, []);
529
+ const setValues = useCallback(
530
+ (values: Record<string, unknown>) => {
531
+ for (const [key, value] of Object.entries(values)) {
532
+ queueFieldChangedEvent(key, value, "setValues");
533
+ }
534
+ dispatch({ type: "SET_VALUES", values });
535
+ },
536
+ [queueFieldChangedEvent],
537
+ );
425
538
 
426
539
  const validateField = useCallback(
427
540
  (path: string): FieldError[] => {
@@ -436,20 +549,97 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
436
549
 
437
550
  const submitForm = useCallback(async () => {
438
551
  dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
552
+
553
+ const submissionData = { ...state.data };
554
+ let postSubmitPayload: FormaEventMap["postSubmit"] | undefined;
555
+
439
556
  try {
557
+ // Fire preSubmit (async, inline — listeners can mutate submissionData)
558
+ const preSubmitPayload = {
559
+ data: submissionData,
560
+ computed: { ...computed },
561
+ };
562
+ // Declarative handler
563
+ const declarativePreSubmit = onEventsRef.current?.preSubmit;
564
+ if (declarativePreSubmit) {
565
+ await declarativePreSubmit(preSubmitPayload);
566
+ }
567
+ // Imperative listeners
568
+ if (emitterRef.current.hasListeners("preSubmit")) {
569
+ await emitterRef.current.fireAsync("preSubmit", preSubmitPayload);
570
+ }
571
+
440
572
  // Always use immediate validation on submit to ensure accurate result
441
- if (immediateValidation.valid && onSubmit) {
442
- await onSubmit(state.data);
573
+ if (!immediateValidation.valid) {
574
+ postSubmitPayload = {
575
+ data: submissionData,
576
+ success: false,
577
+ validationErrors: immediateValidation.errors,
578
+ };
579
+ } else if (onSubmit) {
580
+ try {
581
+ await onSubmit(submissionData);
582
+ postSubmitPayload = { data: submissionData, success: true };
583
+ } catch (error) {
584
+ postSubmitPayload = {
585
+ data: submissionData,
586
+ success: false,
587
+ error:
588
+ error instanceof Error ? error : new Error(String(error)),
589
+ };
590
+ }
591
+ } else {
592
+ postSubmitPayload = { data: submissionData, success: true };
443
593
  }
594
+
444
595
  dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
445
596
  } finally {
446
597
  dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
598
+ // Fire postSubmit after state updates
599
+ if (postSubmitPayload) {
600
+ fireEvent("postSubmit", postSubmitPayload);
601
+ }
447
602
  }
448
- }, [immediateValidation, onSubmit, state.data]);
603
+ }, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
449
604
 
450
605
  const resetForm = useCallback(() => {
451
- dispatch({ type: "RESET", initialData });
452
- }, [initialData]);
606
+ const resetData = {
607
+ ...getDefaultBooleanValues(spec),
608
+ ...getFieldDefaults(spec),
609
+ ...initialData,
610
+ };
611
+
612
+ // Queue fieldChanged for each field that actually changes
613
+ if (!isFiringEventsRef.current) {
614
+ const currentData = stateDataRef.current;
615
+ const allKeys = new Set([
616
+ ...Object.keys(currentData),
617
+ ...Object.keys(resetData),
618
+ ]);
619
+ for (const key of allKeys) {
620
+ const currentVal = currentData[key];
621
+ const resetVal = resetData[key];
622
+ if (currentVal !== resetVal) {
623
+ pendingEventsRef.current.push({
624
+ event: "fieldChanged",
625
+ payload: {
626
+ path: key,
627
+ value: resetVal,
628
+ previousValue: currentVal,
629
+ source: "reset" as const,
630
+ },
631
+ });
632
+ }
633
+ }
634
+ // Queue formReset (fires after fieldChanged events)
635
+ pendingEventsRef.current.push({
636
+ event: "formReset",
637
+ payload: {} as FormaEventMap["formReset"],
638
+ });
639
+ }
640
+
641
+ dispatch({ type: "RESET", initialData: resetData });
642
+ }, [spec, initialData]);
453
643
 
454
644
  // Wizard helpers
455
645
  const wizard = useMemo((): WizardHelpers | null => {
@@ -491,18 +681,45 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
491
681
  currentPageIndex: clampedPageIndex,
492
682
  currentPage,
493
683
  goToPage: (index: number) => {
494
- // Clamp to valid range
495
684
  const validIndex = Math.min(Math.max(0, index), maxPageIndex);
496
- dispatch({ type: "SET_PAGE", page: validIndex });
685
+ if (validIndex !== clampedPageIndex) {
686
+ dispatch({ type: "SET_PAGE", page: validIndex });
687
+ const newPage = visiblePages[validIndex];
688
+ if (newPage) {
689
+ fireEvent("pageChanged", {
690
+ fromIndex: clampedPageIndex,
691
+ toIndex: validIndex,
692
+ page: newPage,
693
+ });
694
+ }
695
+ }
497
696
  },
498
697
  nextPage: () => {
499
698
  if (hasNextPage) {
500
- dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
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
+ }
501
709
  }
502
710
  },
503
711
  previousPage: () => {
504
712
  if (hasPreviousPage) {
505
- dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
713
+ const toIndex = clampedPageIndex - 1;
714
+ dispatch({ type: "SET_PAGE", page: toIndex });
715
+ const newPage = visiblePages[toIndex];
716
+ if (newPage) {
717
+ fireEvent("pageChanged", {
718
+ fromIndex: clampedPageIndex,
719
+ toIndex,
720
+ page: newPage,
721
+ });
722
+ }
506
723
  }
507
724
  },
508
725
  hasNextPage,
@@ -539,53 +756,63 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
539
756
  return pageErrors.length === 0;
540
757
  },
541
758
  };
542
- }, [spec, state.data, state.currentPage, computed, validation, visibility]);
759
+ }, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
543
760
 
544
- // Helper to get value at nested path
545
- // Uses stateDataRef to always access current state, avoiding stale closure issues
546
- const getValueAtPath = useCallback((path: string): unknown => {
547
- // Handle array index notation: "items[0].name" -> ["items", "0", "name"]
548
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
549
- let value: unknown = stateDataRef.current;
550
- for (const part of parts) {
551
- if (value === null || value === undefined) return undefined;
552
- value = (value as Record<string, unknown>)[part];
761
+ // Flush pending events after render (fieldChanged, formReset)
762
+ useEffect(() => {
763
+ const events = pendingEventsRef.current;
764
+ if (events.length === 0) return;
765
+ pendingEventsRef.current = [];
766
+
767
+ isFiringEventsRef.current = true;
768
+ try {
769
+ for (const pending of events) {
770
+ fireEvent(
771
+ pending.event as keyof FormaEventMap,
772
+ pending.payload as FormaEventMap[keyof FormaEventMap],
773
+ );
774
+ }
775
+ } finally {
776
+ isFiringEventsRef.current = false;
553
777
  }
554
- return value;
555
- }, []); // No dependencies - uses ref for current state
778
+ });
556
779
 
557
780
  // Helper to set value at nested path
558
781
  // Uses stateDataRef to always access current state, avoiding stale closure issues
559
- const setValueAtPath = useCallback((path: string, value: unknown): void => {
560
- // For nested paths, we need to build the nested structure
561
- const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
562
- if (parts.length === 1) {
563
- dispatch({ type: "SET_FIELD_VALUE", field: path, value });
564
- return;
565
- }
566
-
567
- // Build nested object from CURRENT state via ref (not stale closure)
568
- const newData = { ...stateDataRef.current };
569
- let current: Record<string, unknown> = newData;
570
-
571
- for (let i = 0; i < parts.length - 1; i++) {
572
- const part = parts[i];
573
- const nextPart = parts[i + 1];
574
- const isNextArrayIndex = /^\d+$/.test(nextPart);
782
+ const setValueAtPath = useCallback(
783
+ (path: string, value: unknown): void => {
784
+ queueFieldChangedEvent(path, value, "user");
785
+ // For nested paths, we need to build the nested structure
786
+ const parts = path.replace(/\[(\d+)\]/g, ".$1").split(".");
787
+ if (parts.length === 1) {
788
+ dispatch({ type: "SET_FIELD_VALUE", field: path, value });
789
+ return;
790
+ }
575
791
 
576
- if (current[part] === undefined) {
577
- current[part] = isNextArrayIndex ? [] : {};
578
- } else if (Array.isArray(current[part])) {
579
- current[part] = [...(current[part] as unknown[])];
580
- } else {
581
- current[part] = { ...(current[part] as Record<string, unknown>) };
792
+ // Build nested object from CURRENT state via ref (not stale closure)
793
+ const newData = { ...stateDataRef.current };
794
+ let current: Record<string, unknown> = newData;
795
+
796
+ for (let i = 0; i < parts.length - 1; i++) {
797
+ const part = parts[i];
798
+ const nextPart = parts[i + 1];
799
+ const isNextArrayIndex = /^\d+$/.test(nextPart);
800
+
801
+ if (current[part] === undefined) {
802
+ current[part] = isNextArrayIndex ? [] : {};
803
+ } else if (Array.isArray(current[part])) {
804
+ current[part] = [...(current[part] as unknown[])];
805
+ } else {
806
+ current[part] = { ...(current[part] as Record<string, unknown>) };
807
+ }
808
+ current = current[part] as Record<string, unknown>;
582
809
  }
583
- current = current[part] as Record<string, unknown>;
584
- }
585
810
 
586
- current[parts[parts.length - 1]] = value;
587
- dispatch({ type: "SET_VALUES", values: newData });
588
- }, []); // No dependencies - uses ref for current state
811
+ current[parts[parts.length - 1]] = value;
812
+ dispatch({ type: "SET_VALUES", values: newData });
813
+ },
814
+ [queueFieldChangedEvent],
815
+ );
589
816
 
590
817
  // Memoized onChange/onBlur handlers for fields
591
818
  const fieldHandlers = useRef<
@@ -857,6 +1084,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
857
1084
  ],
858
1085
  );
859
1086
 
1087
+ // Stable reference for imperative event subscription — only depends on the
1088
+ // emitter ref, so consumers can safely use it as a useEffect dependency.
1089
+ const on = useCallback(
1090
+ <K extends keyof FormaEventMap>(
1091
+ event: K,
1092
+ listener: (payload: FormaEventMap[K]) => void | Promise<void>,
1093
+ ) => emitterRef.current.on(event, listener),
1094
+ [],
1095
+ );
1096
+
860
1097
  return useMemo(
861
1098
  (): UseFormaReturn => ({
862
1099
  data: state.data,
@@ -881,6 +1118,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
881
1118
  validateForm,
882
1119
  submitForm,
883
1120
  resetForm,
1121
+ on,
884
1122
  getFieldProps,
885
1123
  getSelectFieldProps,
886
1124
  getArrayHelpers,
@@ -908,6 +1146,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
908
1146
  validateForm,
909
1147
  submitForm,
910
1148
  resetForm,
1149
+ on,
911
1150
  getFieldProps,
912
1151
  getSelectFieldProps,
913
1152
  getArrayHelpers,