@formality-ui/react 0.2.1 → 0.2.3
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 +90 -1
- package/dist/index.cjs +99 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +99 -35
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ Form container with React Hook Form integration.
|
|
|
135
135
|
| `record` | `Record<string, any>` | Initial values |
|
|
136
136
|
| `onSubmit` | `(values) => void` | Submit handler |
|
|
137
137
|
| `autoSave` | `boolean` | Enable auto-save |
|
|
138
|
-
| `debounce` | `number` |
|
|
138
|
+
| `debounce` | `number \| false` | Auto-save debounce delay (ms); `false` submits immediately (no timer). Defaults to `1000` |
|
|
139
139
|
|
|
140
140
|
**Render API:**
|
|
141
141
|
| Property | Type | Description |
|
|
@@ -293,6 +293,78 @@ Enable automatic form submission on changes:
|
|
|
293
293
|
</Form>
|
|
294
294
|
```
|
|
295
295
|
|
|
296
|
+
The Form-level `debounce` prop (`number | false`, default `1000`) sets the
|
|
297
|
+
default cadence for every field. `false` submits immediately on every change;
|
|
298
|
+
a number delays the save until that many milliseconds elapse without a change.
|
|
299
|
+
|
|
300
|
+
### Per-field debounce overrides
|
|
301
|
+
|
|
302
|
+
Each input type can override the auto-save cadence via
|
|
303
|
+
`InputConfig.debounce: number | false | undefined`. When unset, the field falls
|
|
304
|
+
back to the Form-level `debounce` prop.
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
const inputs: Record<string, InputConfig> = {
|
|
308
|
+
// switch saves IMMEDIATELY on toggle (no timer):
|
|
309
|
+
switch: { component: Switch, defaultValue: false, debounce: false },
|
|
310
|
+
// textField waits 2s after typing stops:
|
|
311
|
+
textField: { component: TextField, defaultValue: "", debounce: 2000 },
|
|
312
|
+
// select waits 0.5s:
|
|
313
|
+
select: { component: Select, defaultValue: "", debounce: 500 },
|
|
314
|
+
// numberField is unset → falls back to the Form-level `debounce` prop:
|
|
315
|
+
numberField: { component: NumberField, defaultValue: 0 },
|
|
316
|
+
};
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
The routing, transcribed from `Form.tsx` (`changeField`):
|
|
320
|
+
|
|
321
|
+
| `InputConfig.debounce` | Behavior |
|
|
322
|
+
| ---------------------- | -------------------------------------------- |
|
|
323
|
+
| `false` | Submit immediately (no debounce timer). |
|
|
324
|
+
| `<number>` | A per-field timer at that ms interval. |
|
|
325
|
+
| `undefined` | Fall back to the Form-level `debounce` prop. |
|
|
326
|
+
|
|
327
|
+
**Coalescing by interval.** Per-field numeric timers are keyed by their **ms
|
|
328
|
+
interval**, not by field name. Fields that share the same numeric debounce
|
|
329
|
+
coalesce into a **single** timer; all of their pending changes accumulate in a
|
|
330
|
+
shared set and are captured together when that timer fires. Fields with
|
|
331
|
+
different numeric debounces each get their own timer and fire on their own
|
|
332
|
+
cadence.
|
|
333
|
+
|
|
334
|
+
### Flushing pending saves: `submitImmediate()`
|
|
335
|
+
|
|
336
|
+
`submitImmediate()` flushes any pending auto-save **immediately** — both the
|
|
337
|
+
per-field numeric timers and the Form-level timer. It is a **no-op** when
|
|
338
|
+
nothing is pending (no spurious empty save), cancels any trailing timers so
|
|
339
|
+
they cannot race this flush, and runs the save pipeline **exactly once**.
|
|
340
|
+
|
|
341
|
+
`submitImmediate` lives on the form's context value — access it via
|
|
342
|
+
[`useFormContext()`](#useformcontext), **not** the `<Form>` render-prop API:
|
|
343
|
+
|
|
344
|
+
```tsx
|
|
345
|
+
function SaveNowButton() {
|
|
346
|
+
const { submitImmediate } = useFormContext();
|
|
347
|
+
return <button onClick={() => submitImmediate()}>Save Now</button>;
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### The debounced submit handle (`cancel` / `flush` / `pending`)
|
|
352
|
+
|
|
353
|
+
`useFormContext()` also exposes `debouncedSubmit`, a `DebouncedFunction` with
|
|
354
|
+
the standard debounced-handle contract:
|
|
355
|
+
|
|
356
|
+
| Member | Behavior |
|
|
357
|
+
| ------------------- | ----------------------------------------------- |
|
|
358
|
+
| `debouncedSubmit()` | Schedule the debounced invocation. |
|
|
359
|
+
| `.cancel()` | Cancel any pending invocation. |
|
|
360
|
+
| `.flush()` | Immediately execute any pending invocation. |
|
|
361
|
+
| `.pending()` | `true` if an invocation is currently scheduled. |
|
|
362
|
+
|
|
363
|
+
`.pending()` is **reliable** — it tracks the real scheduled state on both the
|
|
364
|
+
Form-level and per-field debouncers (the earlier "always returns `false`" bug
|
|
365
|
+
has been fixed). For most UI needs prefer `submitImmediate()`: it covers both
|
|
366
|
+
timer sources and the cancel-race, so you do not have to manage them yourself.
|
|
367
|
+
|
|
296
368
|
## Hooks
|
|
297
369
|
|
|
298
370
|
### useFormContext
|
|
@@ -309,6 +381,14 @@ function CustomComponent() {
|
|
|
309
381
|
}
|
|
310
382
|
```
|
|
311
383
|
|
|
384
|
+
Two auto-save handles are available on the context value (see
|
|
385
|
+
[Auto-Save](#auto-save) for the full semantics):
|
|
386
|
+
|
|
387
|
+
| Member | Description |
|
|
388
|
+
| ----------------- | ---------------------------------------------------------------- |
|
|
389
|
+
| `submitImmediate` | Flush pending auto-save immediately (both timer sources). |
|
|
390
|
+
| `debouncedSubmit` | The `DebouncedFunction` handle (`cancel` / `flush` / `pending`). |
|
|
391
|
+
|
|
312
392
|
### useConditions
|
|
313
393
|
|
|
314
394
|
Evaluate conditions manually:
|
|
@@ -655,6 +735,15 @@ All other code — `packages/core/**`, `packages/react/**`, and any future
|
|
|
655
735
|
adapter with a real implementation — is in scope and must clear 90%. See
|
|
656
736
|
`vitest.config.ts` for the exact configuration.
|
|
657
737
|
|
|
738
|
+
## Known Issues
|
|
739
|
+
|
|
740
|
+
- **`isDisabled` condition matcher (React adapter)** — conditions using the
|
|
741
|
+
`isDisabled` field-state matcher do not currently evaluate correctly in the
|
|
742
|
+
React adapter because the `fieldStates` map intentionally omits the
|
|
743
|
+
`disabled` property (to avoid circular re-render dependencies). See
|
|
744
|
+
[`KNOWN_ISSUES.md`](./KNOWN_ISSUES.md) for the symptom, root cause, and a
|
|
745
|
+
value-based workaround.
|
|
746
|
+
|
|
658
747
|
## License
|
|
659
748
|
|
|
660
749
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -181,6 +181,8 @@ function Form({
|
|
|
181
181
|
const pendingChangedFields = react.useRef(/* @__PURE__ */ new Set());
|
|
182
182
|
const pendingAffectedFields = react.useRef(/* @__PURE__ */ new Set());
|
|
183
183
|
const executionVersionRef = react.useRef(0);
|
|
184
|
+
const fieldDebouncersRef = react.useRef(/* @__PURE__ */ new Map());
|
|
185
|
+
const getOrCreateDebouncedRef = react.useRef();
|
|
184
186
|
const registerField = react.useCallback((name) => {
|
|
185
187
|
fieldRegistry.current.add(name);
|
|
186
188
|
setRegisteredFields(new Set(fieldRegistry.current));
|
|
@@ -281,8 +283,11 @@ function Form({
|
|
|
281
283
|
for (const field of affected) {
|
|
282
284
|
pendingAffectedFields.current.add(field);
|
|
283
285
|
}
|
|
284
|
-
|
|
286
|
+
const fieldDebounce = inputConfig?.debounce;
|
|
287
|
+
if (fieldDebounce === false) {
|
|
285
288
|
executeAutoSaveRef.current?.();
|
|
289
|
+
} else if (typeof fieldDebounce === "number") {
|
|
290
|
+
getOrCreateDebouncedRef.current?.(fieldDebounce)();
|
|
286
291
|
} else {
|
|
287
292
|
debouncedSubmitRef.current?.();
|
|
288
293
|
}
|
|
@@ -423,53 +428,60 @@ function Form({
|
|
|
423
428
|
return;
|
|
424
429
|
}
|
|
425
430
|
}
|
|
426
|
-
const formState = methods.formState;
|
|
427
|
-
if (Object.keys(formState.errors).length > 0) {
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
431
|
const values = methods.getValues();
|
|
431
432
|
await handleSubmit(values);
|
|
432
433
|
}, [methods, handleSubmit, waitForFieldValidation]);
|
|
433
434
|
executeAutoSaveRef.current = executeAutoSave;
|
|
435
|
+
const getOrCreateDebounced = react.useCallback((ms) => {
|
|
436
|
+
const cached = fieldDebouncersRef.current.get(ms);
|
|
437
|
+
if (cached) return cached;
|
|
438
|
+
const fn = wrapDebounced(() => {
|
|
439
|
+
executeAutoSaveRef.current?.();
|
|
440
|
+
}, ms);
|
|
441
|
+
fieldDebouncersRef.current.set(ms, fn);
|
|
442
|
+
return fn;
|
|
443
|
+
}, []);
|
|
444
|
+
getOrCreateDebouncedRef.current = getOrCreateDebounced;
|
|
434
445
|
react.useEffect(() => {
|
|
446
|
+
return () => {
|
|
447
|
+
fieldDebouncersRef.current.forEach((fn) => fn.cancel());
|
|
448
|
+
fieldDebouncersRef.current.clear();
|
|
449
|
+
};
|
|
450
|
+
}, [getOrCreateDebounced]);
|
|
451
|
+
const debouncedSubmit = react.useMemo(() => {
|
|
435
452
|
if (debounceMs === false) {
|
|
436
|
-
|
|
453
|
+
return Object.assign(
|
|
437
454
|
() => {
|
|
438
|
-
|
|
455
|
+
executeAutoSaveRef.current?.();
|
|
439
456
|
},
|
|
440
457
|
{
|
|
441
458
|
cancel: () => {
|
|
442
459
|
},
|
|
443
460
|
// No-op for immediate function
|
|
444
|
-
flush: () =>
|
|
461
|
+
flush: () => executeAutoSaveRef.current?.(),
|
|
445
462
|
// Execute immediately on flush
|
|
446
463
|
pending: () => false
|
|
447
464
|
// Never pending when immediate
|
|
448
465
|
}
|
|
449
466
|
);
|
|
450
|
-
debouncedSubmitRef.current = immediateFn;
|
|
451
|
-
return () => {
|
|
452
|
-
};
|
|
453
467
|
}
|
|
454
|
-
|
|
455
|
-
|
|
468
|
+
return wrapDebounced(() => {
|
|
469
|
+
executeAutoSaveRef.current?.();
|
|
456
470
|
}, debounceMs);
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
});
|
|
461
|
-
debouncedSubmitRef.current = fn;
|
|
471
|
+
}, [debounceMs]);
|
|
472
|
+
debouncedSubmitRef.current = debouncedSubmit;
|
|
473
|
+
react.useEffect(() => {
|
|
462
474
|
return () => {
|
|
463
|
-
|
|
475
|
+
debouncedSubmit.cancel();
|
|
464
476
|
};
|
|
465
|
-
}, [
|
|
477
|
+
}, [debouncedSubmit]);
|
|
466
478
|
const submitImmediate = react.useCallback(() => {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
}, [
|
|
479
|
+
const anyPending = debouncedSubmitRef.current?.pending() === true || [...fieldDebouncersRef.current.values()].some((fn) => fn.pending());
|
|
480
|
+
if (!anyPending) return;
|
|
481
|
+
debouncedSubmitRef.current?.cancel();
|
|
482
|
+
fieldDebouncersRef.current.forEach((fn) => fn.cancel());
|
|
483
|
+
executeAutoSaveRef.current?.();
|
|
484
|
+
}, []);
|
|
473
485
|
const unusedFields = react.useMemo(() => {
|
|
474
486
|
const allFields = Object.keys(config);
|
|
475
487
|
return allFields.filter((name) => !registeredFields.has(name));
|
|
@@ -508,7 +520,7 @@ function Form({
|
|
|
508
520
|
setFieldValidating,
|
|
509
521
|
getFormState,
|
|
510
522
|
onSubmit,
|
|
511
|
-
debouncedSubmit
|
|
523
|
+
debouncedSubmit,
|
|
512
524
|
submitImmediate,
|
|
513
525
|
unusedFields,
|
|
514
526
|
methods
|
|
@@ -527,6 +539,7 @@ function Form({
|
|
|
527
539
|
setFieldValidating,
|
|
528
540
|
getFormState,
|
|
529
541
|
onSubmit,
|
|
542
|
+
debouncedSubmit,
|
|
530
543
|
submitImmediate,
|
|
531
544
|
unusedFields,
|
|
532
545
|
methods
|
|
@@ -541,6 +554,30 @@ function Form({
|
|
|
541
554
|
resolvedTitle
|
|
542
555
|
}) : children }) }) });
|
|
543
556
|
}
|
|
557
|
+
function wrapDebounced(callback, ms) {
|
|
558
|
+
let isPending = false;
|
|
559
|
+
const debounced = lodashEs.debounce(() => {
|
|
560
|
+
isPending = false;
|
|
561
|
+
callback();
|
|
562
|
+
}, ms);
|
|
563
|
+
return Object.assign(
|
|
564
|
+
() => {
|
|
565
|
+
isPending = true;
|
|
566
|
+
debounced();
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
cancel: () => {
|
|
570
|
+
isPending = false;
|
|
571
|
+
debounced.cancel();
|
|
572
|
+
},
|
|
573
|
+
flush: () => {
|
|
574
|
+
isPending = false;
|
|
575
|
+
debounced.flush();
|
|
576
|
+
},
|
|
577
|
+
pending: () => isPending
|
|
578
|
+
}
|
|
579
|
+
);
|
|
580
|
+
}
|
|
544
581
|
function transformValuesForSubmit(values, config, inputs) {
|
|
545
582
|
const result = {};
|
|
546
583
|
for (const [name, value] of Object.entries(values)) {
|
|
@@ -988,18 +1025,52 @@ function Field({
|
|
|
988
1025
|
}
|
|
989
1026
|
}
|
|
990
1027
|
}, [effectiveSetValue.hasCondition, effectiveSetValue.value, name]);
|
|
1028
|
+
const { providerSelectProps, formSelectProps, fieldSelectProps } = usePropsEvaluation({
|
|
1029
|
+
selectProps: fieldConfig.selectProps,
|
|
1030
|
+
formDefaultFieldProps: formConfig.selectDefaultFieldProps,
|
|
1031
|
+
providerDefaultFieldProps: providerConfig.selectDefaultFieldProps,
|
|
1032
|
+
subscribesTo: fieldConfig.subscribesTo,
|
|
1033
|
+
fieldName: name
|
|
1034
|
+
});
|
|
991
1035
|
const isDisabled = react.useMemo(() => {
|
|
992
1036
|
if (disabledProp !== void 0) return disabledProp;
|
|
993
1037
|
if (fieldConfig.disabled !== void 0) return fieldConfig.disabled;
|
|
994
1038
|
if (conditionResult.hasDisabledCondition)
|
|
995
1039
|
return conditionResult.disabled ?? false;
|
|
996
1040
|
if (groupContext.state.isDisabled) return true;
|
|
1041
|
+
const propsLayers = [
|
|
1042
|
+
fieldSelectProps,
|
|
1043
|
+
// layer 2: field-level selectProps
|
|
1044
|
+
formSelectProps,
|
|
1045
|
+
// layer 5: form-level selectDefaultFieldProps
|
|
1046
|
+
providerSelectProps,
|
|
1047
|
+
// layer 7: provider-level selectDefaultFieldProps
|
|
1048
|
+
inputConfig.props,
|
|
1049
|
+
// layer 4: input config props
|
|
1050
|
+
fieldConfig.props,
|
|
1051
|
+
// layer 3: field config props
|
|
1052
|
+
formConfig.defaultFieldProps,
|
|
1053
|
+
// layer 6: form-level defaultFieldProps
|
|
1054
|
+
providerConfig.defaultFieldProps
|
|
1055
|
+
// layer 8: provider-level defaultFieldProps
|
|
1056
|
+
];
|
|
1057
|
+
for (const layer of propsLayers) {
|
|
1058
|
+
const layerDisabled = layer?.disabled;
|
|
1059
|
+
if (layerDisabled !== void 0) return Boolean(layerDisabled);
|
|
1060
|
+
}
|
|
997
1061
|
return false;
|
|
998
1062
|
}, [
|
|
999
1063
|
disabledProp,
|
|
1000
1064
|
fieldConfig.disabled,
|
|
1065
|
+
fieldConfig.props,
|
|
1001
1066
|
conditionResult,
|
|
1002
|
-
groupContext.state.isDisabled
|
|
1067
|
+
groupContext.state.isDisabled,
|
|
1068
|
+
fieldSelectProps,
|
|
1069
|
+
formSelectProps,
|
|
1070
|
+
providerSelectProps,
|
|
1071
|
+
inputConfig.props,
|
|
1072
|
+
formConfig.defaultFieldProps,
|
|
1073
|
+
providerConfig.defaultFieldProps
|
|
1003
1074
|
]);
|
|
1004
1075
|
const isVisible = react.useMemo(() => {
|
|
1005
1076
|
if (hiddenProp !== void 0) return !hiddenProp;
|
|
@@ -1014,13 +1085,6 @@ function Field({
|
|
|
1014
1085
|
conditionResult,
|
|
1015
1086
|
groupContext.state.isVisible
|
|
1016
1087
|
]);
|
|
1017
|
-
const { providerSelectProps, formSelectProps, fieldSelectProps } = usePropsEvaluation({
|
|
1018
|
-
selectProps: fieldConfig.selectProps,
|
|
1019
|
-
formDefaultFieldProps: formConfig.selectDefaultFieldProps,
|
|
1020
|
-
providerDefaultFieldProps: providerConfig.selectDefaultFieldProps,
|
|
1021
|
-
subscribesTo: fieldConfig.subscribesTo,
|
|
1022
|
-
fieldName: name
|
|
1023
|
-
});
|
|
1024
1088
|
const label = react.useMemo(() => {
|
|
1025
1089
|
return core.resolveLabel(name, fieldConfig, fieldSelectProps, restProps);
|
|
1026
1090
|
}, [name, fieldConfig, fieldSelectProps, restProps]);
|