@fogpipe/forma-react 0.13.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/README.md +74 -9
- package/dist/index.d.ts +90 -1
- package/dist/index.js +274 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/events.test.ts +752 -0
- package/src/events.ts +186 -0
- package/src/index.ts +5 -0
- package/src/types.ts +7 -0
- package/src/useForma.ts +269 -58
package/src/events.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event system for forma-react
|
|
3
|
+
*
|
|
4
|
+
* Lightweight event emitter for form lifecycle events.
|
|
5
|
+
* Events are for side effects (analytics, data injection, external state sync)
|
|
6
|
+
* — they do not trigger React re-renders.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FieldError } from "@fogpipe/forma-core";
|
|
10
|
+
import type { PageState } from "./useForma.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Event Type Definitions
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Map of all forma event names to their payload types.
|
|
18
|
+
*/
|
|
19
|
+
export interface FormaEventMap {
|
|
20
|
+
/**
|
|
21
|
+
* Fires after a field value changes via user input, setFieldValue, setValues, or resetForm.
|
|
22
|
+
* Does NOT fire for computed/calculated field changes or on initial mount.
|
|
23
|
+
*/
|
|
24
|
+
fieldChanged: {
|
|
25
|
+
/** Field path (e.g., "age" or "medications[0].dosage") */
|
|
26
|
+
path: string;
|
|
27
|
+
/** New value */
|
|
28
|
+
value: unknown;
|
|
29
|
+
/** Value before the change */
|
|
30
|
+
previousValue: unknown;
|
|
31
|
+
/** What triggered the change */
|
|
32
|
+
source: "user" | "reset" | "setValues";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fires at the start of submitForm(), before validation.
|
|
37
|
+
* The `data` object is mutable — consumers can inject extra fields.
|
|
38
|
+
* Async handlers are awaited before proceeding to validation.
|
|
39
|
+
*/
|
|
40
|
+
preSubmit: {
|
|
41
|
+
/** Mutable form data — add/modify fields here */
|
|
42
|
+
data: Record<string, unknown>;
|
|
43
|
+
/** Read-only snapshot of computed values */
|
|
44
|
+
computed: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fires after onSubmit resolves/rejects or after validation failure.
|
|
49
|
+
*/
|
|
50
|
+
postSubmit: {
|
|
51
|
+
/** The submitted data (reflects any preSubmit mutations) */
|
|
52
|
+
data: Record<string, unknown>;
|
|
53
|
+
/** Whether submission succeeded */
|
|
54
|
+
success: boolean;
|
|
55
|
+
/** Present when onSubmit threw an error */
|
|
56
|
+
error?: Error;
|
|
57
|
+
/** Present when validation failed (onSubmit was never called) */
|
|
58
|
+
validationErrors?: FieldError[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fires when the wizard page changes via nextPage, previousPage, or goToPage.
|
|
63
|
+
* Does NOT fire on initial render or automatic page clamping.
|
|
64
|
+
*/
|
|
65
|
+
pageChanged: {
|
|
66
|
+
/** Previous page index */
|
|
67
|
+
fromIndex: number;
|
|
68
|
+
/** New page index */
|
|
69
|
+
toIndex: number;
|
|
70
|
+
/** The new current page */
|
|
71
|
+
page: PageState;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fires after resetForm() completes and state is back to initial values.
|
|
76
|
+
*/
|
|
77
|
+
formReset: Record<string, never>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Helper Types
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Listener function type for a specific event.
|
|
86
|
+
*/
|
|
87
|
+
export type FormaEventListener<K extends keyof FormaEventMap> = (
|
|
88
|
+
event: FormaEventMap[K],
|
|
89
|
+
) => void | Promise<void>;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Declarative event listener map for useForma `on` option.
|
|
93
|
+
*/
|
|
94
|
+
export type FormaEvents = Partial<{
|
|
95
|
+
[K in keyof FormaEventMap]: FormaEventListener<K>;
|
|
96
|
+
}>;
|
|
97
|
+
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// Event Emitter
|
|
100
|
+
// ============================================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Lightweight event emitter for forma lifecycle events.
|
|
104
|
+
* Uses Map<string, Set<listener>> internally — no external dependencies.
|
|
105
|
+
*/
|
|
106
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
107
|
+
type AnyListener = (...args: any[]) => void | Promise<void>;
|
|
108
|
+
|
|
109
|
+
export class FormaEventEmitter {
|
|
110
|
+
private listeners = new Map<string, Set<AnyListener>>();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a listener for an event. Returns an unsubscribe function.
|
|
114
|
+
*/
|
|
115
|
+
on<K extends keyof FormaEventMap>(
|
|
116
|
+
event: K,
|
|
117
|
+
listener: FormaEventListener<K>,
|
|
118
|
+
): () => void {
|
|
119
|
+
if (!this.listeners.has(event)) {
|
|
120
|
+
this.listeners.set(event, new Set());
|
|
121
|
+
}
|
|
122
|
+
this.listeners.get(event)!.add(listener);
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
const set = this.listeners.get(event);
|
|
126
|
+
if (set) {
|
|
127
|
+
set.delete(listener);
|
|
128
|
+
if (set.size === 0) {
|
|
129
|
+
this.listeners.delete(event);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Fire an event synchronously. Listener errors are caught and logged
|
|
137
|
+
* to prevent one listener from breaking others.
|
|
138
|
+
*/
|
|
139
|
+
fire<K extends keyof FormaEventMap>(event: K, payload: FormaEventMap[K]): void {
|
|
140
|
+
const set = this.listeners.get(event);
|
|
141
|
+
if (!set || set.size === 0) return;
|
|
142
|
+
|
|
143
|
+
for (const listener of set) {
|
|
144
|
+
try {
|
|
145
|
+
listener(payload);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`[forma] Error in "${event}" event listener:`, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fire an event and await all async listeners sequentially.
|
|
154
|
+
* Used for preSubmit where handlers can be async.
|
|
155
|
+
*/
|
|
156
|
+
async fireAsync<K extends keyof FormaEventMap>(
|
|
157
|
+
event: K,
|
|
158
|
+
payload: FormaEventMap[K],
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const set = this.listeners.get(event);
|
|
161
|
+
if (!set || set.size === 0) return;
|
|
162
|
+
|
|
163
|
+
for (const listener of set) {
|
|
164
|
+
try {
|
|
165
|
+
await listener(payload);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error(`[forma] Error in "${event}" event listener:`, error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if any listeners are registered for an event.
|
|
174
|
+
*/
|
|
175
|
+
hasListeners(event: keyof FormaEventMap): boolean {
|
|
176
|
+
const set = this.listeners.get(event);
|
|
177
|
+
return set !== undefined && set.size > 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove all listeners. Called on cleanup.
|
|
182
|
+
*/
|
|
183
|
+
clear(): void {
|
|
184
|
+
this.listeners.clear();
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -476,6 +476,13 @@ export type { UseFormaReturn as FormState } from "./useForma.js";
|
|
|
476
476
|
export type { UseFormaOptions } from "./useForma.js";
|
|
477
477
|
export type { PageState, WizardHelpers } from "./useForma.js";
|
|
478
478
|
|
|
479
|
+
// Re-export event types
|
|
480
|
+
export type {
|
|
481
|
+
FormaEventMap,
|
|
482
|
+
FormaEvents,
|
|
483
|
+
FormaEventListener,
|
|
484
|
+
} from "./events.js";
|
|
485
|
+
|
|
479
486
|
// Re-export ValidationResult for convenience
|
|
480
487
|
export type { ValidationResult } from "@fogpipe/forma-core";
|
|
481
488
|
|
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;
|
|
@@ -266,6 +283,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
266
283
|
validateOn = "blur",
|
|
267
284
|
referenceData,
|
|
268
285
|
validationDebounceMs = 0,
|
|
286
|
+
on: onEvents,
|
|
269
287
|
} = options;
|
|
270
288
|
|
|
271
289
|
// Merge referenceData from options with spec.referenceData
|
|
@@ -300,6 +318,42 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
300
318
|
// Track if we've initialized (to avoid calling onChange on first render)
|
|
301
319
|
const hasInitialized = useRef(false);
|
|
302
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
|
+
|
|
303
357
|
// Calculate computed values
|
|
304
358
|
const computed = useMemo(
|
|
305
359
|
() => calculate(state.data, spec),
|
|
@@ -425,24 +479,62 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
425
479
|
[state.data],
|
|
426
480
|
);
|
|
427
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
|
+
|
|
428
513
|
// Actions
|
|
429
514
|
const setFieldValue = useCallback(
|
|
430
515
|
(path: string, value: unknown) => {
|
|
516
|
+
queueFieldChangedEvent(path, value, "user");
|
|
431
517
|
setNestedValue(path, value);
|
|
432
518
|
if (validateOn === "change") {
|
|
433
519
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched: true });
|
|
434
520
|
}
|
|
435
521
|
},
|
|
436
|
-
[validateOn, setNestedValue],
|
|
522
|
+
[validateOn, setNestedValue, queueFieldChangedEvent],
|
|
437
523
|
);
|
|
438
524
|
|
|
439
525
|
const setFieldTouched = useCallback((path: string, touched = true) => {
|
|
440
526
|
dispatch({ type: "SET_FIELD_TOUCHED", field: path, touched });
|
|
441
527
|
}, []);
|
|
442
528
|
|
|
443
|
-
const setValues = useCallback(
|
|
444
|
-
|
|
445
|
-
|
|
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
|
+
);
|
|
446
538
|
|
|
447
539
|
const validateField = useCallback(
|
|
448
540
|
(path: string): FieldError[] => {
|
|
@@ -457,26 +549,96 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
457
549
|
|
|
458
550
|
const submitForm = useCallback(async () => {
|
|
459
551
|
dispatch({ type: "SET_SUBMITTING", isSubmitting: true });
|
|
552
|
+
|
|
553
|
+
const submissionData = { ...state.data };
|
|
554
|
+
let postSubmitPayload: FormaEventMap["postSubmit"] | undefined;
|
|
555
|
+
|
|
460
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
|
+
|
|
461
572
|
// Always use immediate validation on submit to ensure accurate result
|
|
462
|
-
if (immediateValidation.valid
|
|
463
|
-
|
|
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 };
|
|
464
593
|
}
|
|
594
|
+
|
|
465
595
|
dispatch({ type: "SET_SUBMITTED", isSubmitted: true });
|
|
466
596
|
} finally {
|
|
467
597
|
dispatch({ type: "SET_SUBMITTING", isSubmitting: false });
|
|
598
|
+
// Fire postSubmit after state updates
|
|
599
|
+
if (postSubmitPayload) {
|
|
600
|
+
fireEvent("postSubmit", postSubmitPayload);
|
|
601
|
+
}
|
|
468
602
|
}
|
|
469
|
-
}, [immediateValidation, onSubmit, state.data]);
|
|
603
|
+
}, [immediateValidation, onSubmit, state.data, computed, fireEvent]);
|
|
470
604
|
|
|
471
605
|
const resetForm = useCallback(() => {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 });
|
|
480
642
|
}, [spec, initialData]);
|
|
481
643
|
|
|
482
644
|
// Wizard helpers
|
|
@@ -519,18 +681,45 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
519
681
|
currentPageIndex: clampedPageIndex,
|
|
520
682
|
currentPage,
|
|
521
683
|
goToPage: (index: number) => {
|
|
522
|
-
// Clamp to valid range
|
|
523
684
|
const validIndex = Math.min(Math.max(0, index), maxPageIndex);
|
|
524
|
-
|
|
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
|
+
}
|
|
525
696
|
},
|
|
526
697
|
nextPage: () => {
|
|
527
698
|
if (hasNextPage) {
|
|
528
|
-
|
|
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
|
+
}
|
|
529
709
|
}
|
|
530
710
|
},
|
|
531
711
|
previousPage: () => {
|
|
532
712
|
if (hasPreviousPage) {
|
|
533
|
-
|
|
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
|
+
}
|
|
534
723
|
}
|
|
535
724
|
},
|
|
536
725
|
hasNextPage,
|
|
@@ -567,53 +756,63 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
567
756
|
return pageErrors.length === 0;
|
|
568
757
|
},
|
|
569
758
|
};
|
|
570
|
-
}, [spec, state.data, state.currentPage, computed, validation, visibility]);
|
|
759
|
+
}, [spec, state.data, state.currentPage, computed, validation, visibility, fireEvent]);
|
|
571
760
|
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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;
|
|
581
777
|
}
|
|
582
|
-
|
|
583
|
-
}, []); // No dependencies - uses ref for current state
|
|
778
|
+
});
|
|
584
779
|
|
|
585
780
|
// Helper to set value at nested path
|
|
586
781
|
// Uses stateDataRef to always access current state, avoiding stale closure issues
|
|
587
|
-
const setValueAtPath = useCallback(
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const newData = { ...stateDataRef.current };
|
|
597
|
-
let current: Record<string, unknown> = newData;
|
|
598
|
-
|
|
599
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
600
|
-
const part = parts[i];
|
|
601
|
-
const nextPart = parts[i + 1];
|
|
602
|
-
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
|
+
}
|
|
603
791
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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>;
|
|
610
809
|
}
|
|
611
|
-
current = current[part] as Record<string, unknown>;
|
|
612
|
-
}
|
|
613
810
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
811
|
+
current[parts[parts.length - 1]] = value;
|
|
812
|
+
dispatch({ type: "SET_VALUES", values: newData });
|
|
813
|
+
},
|
|
814
|
+
[queueFieldChangedEvent],
|
|
815
|
+
);
|
|
617
816
|
|
|
618
817
|
// Memoized onChange/onBlur handlers for fields
|
|
619
818
|
const fieldHandlers = useRef<
|
|
@@ -885,6 +1084,16 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
885
1084
|
],
|
|
886
1085
|
);
|
|
887
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
|
+
|
|
888
1097
|
return useMemo(
|
|
889
1098
|
(): UseFormaReturn => ({
|
|
890
1099
|
data: state.data,
|
|
@@ -909,6 +1118,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
909
1118
|
validateForm,
|
|
910
1119
|
submitForm,
|
|
911
1120
|
resetForm,
|
|
1121
|
+
on,
|
|
912
1122
|
getFieldProps,
|
|
913
1123
|
getSelectFieldProps,
|
|
914
1124
|
getArrayHelpers,
|
|
@@ -936,6 +1146,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
936
1146
|
validateForm,
|
|
937
1147
|
submitForm,
|
|
938
1148
|
resetForm,
|
|
1149
|
+
on,
|
|
939
1150
|
getFieldProps,
|
|
940
1151
|
getSelectFieldProps,
|
|
941
1152
|
getArrayHelpers,
|