@formality-ui/react 0.2.2 → 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 +74 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +74 -30
- 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
|
@@ -432,22 +432,15 @@ function Form({
|
|
|
432
432
|
await handleSubmit(values);
|
|
433
433
|
}, [methods, handleSubmit, waitForFieldValidation]);
|
|
434
434
|
executeAutoSaveRef.current = executeAutoSave;
|
|
435
|
-
const getOrCreateDebounced = react.useCallback(
|
|
436
|
-
(ms)
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// lodash debounce tracks pending internally
|
|
445
|
-
});
|
|
446
|
-
fieldDebouncersRef.current.set(ms, fn);
|
|
447
|
-
return fn;
|
|
448
|
-
},
|
|
449
|
-
[]
|
|
450
|
-
);
|
|
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
|
+
}, []);
|
|
451
444
|
getOrCreateDebouncedRef.current = getOrCreateDebounced;
|
|
452
445
|
react.useEffect(() => {
|
|
453
446
|
return () => {
|
|
@@ -472,13 +465,9 @@ function Form({
|
|
|
472
465
|
}
|
|
473
466
|
);
|
|
474
467
|
}
|
|
475
|
-
|
|
468
|
+
return wrapDebounced(() => {
|
|
476
469
|
executeAutoSaveRef.current?.();
|
|
477
470
|
}, debounceMs);
|
|
478
|
-
return Object.assign(debouncedFn, {
|
|
479
|
-
pending: () => false
|
|
480
|
-
// lodash debounce handles this internally
|
|
481
|
-
});
|
|
482
471
|
}, [debounceMs]);
|
|
483
472
|
debouncedSubmitRef.current = debouncedSubmit;
|
|
484
473
|
react.useEffect(() => {
|
|
@@ -487,7 +476,11 @@ function Form({
|
|
|
487
476
|
};
|
|
488
477
|
}, [debouncedSubmit]);
|
|
489
478
|
const submitImmediate = react.useCallback(() => {
|
|
490
|
-
debouncedSubmitRef.current?.
|
|
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?.();
|
|
491
484
|
}, []);
|
|
492
485
|
const unusedFields = react.useMemo(() => {
|
|
493
486
|
const allFields = Object.keys(config);
|
|
@@ -561,6 +554,30 @@ function Form({
|
|
|
561
554
|
resolvedTitle
|
|
562
555
|
}) : children }) }) });
|
|
563
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
|
+
}
|
|
564
581
|
function transformValuesForSubmit(values, config, inputs) {
|
|
565
582
|
const result = {};
|
|
566
583
|
for (const [name, value] of Object.entries(values)) {
|
|
@@ -1008,18 +1025,52 @@ function Field({
|
|
|
1008
1025
|
}
|
|
1009
1026
|
}
|
|
1010
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
|
+
});
|
|
1011
1035
|
const isDisabled = react.useMemo(() => {
|
|
1012
1036
|
if (disabledProp !== void 0) return disabledProp;
|
|
1013
1037
|
if (fieldConfig.disabled !== void 0) return fieldConfig.disabled;
|
|
1014
1038
|
if (conditionResult.hasDisabledCondition)
|
|
1015
1039
|
return conditionResult.disabled ?? false;
|
|
1016
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
|
+
}
|
|
1017
1061
|
return false;
|
|
1018
1062
|
}, [
|
|
1019
1063
|
disabledProp,
|
|
1020
1064
|
fieldConfig.disabled,
|
|
1065
|
+
fieldConfig.props,
|
|
1021
1066
|
conditionResult,
|
|
1022
|
-
groupContext.state.isDisabled
|
|
1067
|
+
groupContext.state.isDisabled,
|
|
1068
|
+
fieldSelectProps,
|
|
1069
|
+
formSelectProps,
|
|
1070
|
+
providerSelectProps,
|
|
1071
|
+
inputConfig.props,
|
|
1072
|
+
formConfig.defaultFieldProps,
|
|
1073
|
+
providerConfig.defaultFieldProps
|
|
1023
1074
|
]);
|
|
1024
1075
|
const isVisible = react.useMemo(() => {
|
|
1025
1076
|
if (hiddenProp !== void 0) return !hiddenProp;
|
|
@@ -1034,13 +1085,6 @@ function Field({
|
|
|
1034
1085
|
conditionResult,
|
|
1035
1086
|
groupContext.state.isVisible
|
|
1036
1087
|
]);
|
|
1037
|
-
const { providerSelectProps, formSelectProps, fieldSelectProps } = usePropsEvaluation({
|
|
1038
|
-
selectProps: fieldConfig.selectProps,
|
|
1039
|
-
formDefaultFieldProps: formConfig.selectDefaultFieldProps,
|
|
1040
|
-
providerDefaultFieldProps: providerConfig.selectDefaultFieldProps,
|
|
1041
|
-
subscribesTo: fieldConfig.subscribesTo,
|
|
1042
|
-
fieldName: name
|
|
1043
|
-
});
|
|
1044
1088
|
const label = react.useMemo(() => {
|
|
1045
1089
|
return core.resolveLabel(name, fieldConfig, fieldSelectProps, restProps);
|
|
1046
1090
|
}, [name, fieldConfig, fieldSelectProps, restProps]);
|