@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 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` | Debounce delay (ms) |
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
- const cached = fieldDebouncersRef.current.get(ms);
438
- if (cached) return cached;
439
- const debouncedFn = lodashEs.debounce(() => {
440
- executeAutoSaveRef.current?.();
441
- }, ms);
442
- const fn = Object.assign(debouncedFn, {
443
- pending: () => false
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
- const debouncedFn = lodashEs.debounce(() => {
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?.flush();
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]);