@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 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
@@ -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
- if (inputConfig?.debounce === false) {
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
- const immediateFn = Object.assign(
453
+ return Object.assign(
437
454
  () => {
438
- executeAutoSave();
455
+ executeAutoSaveRef.current?.();
439
456
  },
440
457
  {
441
458
  cancel: () => {
442
459
  },
443
460
  // No-op for immediate function
444
- flush: () => executeAutoSave(),
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
- const debouncedFn = lodashEs.debounce(() => {
455
- executeAutoSave();
468
+ return wrapDebounced(() => {
469
+ executeAutoSaveRef.current?.();
456
470
  }, debounceMs);
457
- const fn = Object.assign(debouncedFn, {
458
- pending: () => false
459
- // lodash debounce handles this internally
460
- });
461
- debouncedSubmitRef.current = fn;
471
+ }, [debounceMs]);
472
+ debouncedSubmitRef.current = debouncedSubmit;
473
+ react.useEffect(() => {
462
474
  return () => {
463
- debouncedFn.cancel();
475
+ debouncedSubmit.cancel();
464
476
  };
465
- }, [executeAutoSave, debounceMs]);
477
+ }, [debouncedSubmit]);
466
478
  const submitImmediate = react.useCallback(() => {
467
- if (debouncedSubmitRef.current?.flush) {
468
- debouncedSubmitRef.current.flush();
469
- } else {
470
- executeAutoSave();
471
- }
472
- }, [executeAutoSave]);
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: debouncedSubmitRef.current,
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]);