react-formesh 0.1.4
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 +191 -0
- package/dist/array.cjs +4 -0
- package/dist/array.cjs.map +1 -0
- package/dist/array.d.cts +2 -0
- package/dist/array.d.ts +2 -0
- package/dist/array.js +3 -0
- package/dist/array.js.map +1 -0
- package/dist/field-CLlwd7BC.d.ts +134 -0
- package/dist/field-DvWKUaTD.d.cts +134 -0
- package/dist/file.cjs +4 -0
- package/dist/file.cjs.map +1 -0
- package/dist/file.d.cts +2 -0
- package/dist/file.d.ts +2 -0
- package/dist/file.js +3 -0
- package/dist/file.js.map +1 -0
- package/dist/index.cjs +431 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +209 -0
- package/dist/index.d.ts +209 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +487 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +482 -0
- package/dist/react.js.map +1 -0
- package/dist/validation-gIT3Xn9h.d.cts +125 -0
- package/dist/validation-gIT3Xn9h.d.ts +125 -0
- package/dist/validators.cjs +99 -0
- package/dist/validators.cjs.map +1 -0
- package/dist/validators.d.cts +20 -0
- package/dist/validators.d.ts +20 -0
- package/dist/validators.js +89 -0
- package/dist/validators.js.map +1 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# ForMesh
|
|
2
|
+
|
|
3
|
+
**Multi-section forms for React, without the re-render tax.**
|
|
4
|
+
|
|
5
|
+
`@mhasansagor/formesh` is a small, framework-agnostic form state library built for forms that outgrow `useState` — long ERP/CRM-style forms made of independent sections, each merging into one plain JavaScript object, with validation, debounced sync, and derived fields as first-class primitives instead of hand-rolled hooks per component.
|
|
6
|
+
|
|
7
|
+
[](/LICENSE)
|
|
8
|
+
[]()
|
|
9
|
+
[]()
|
|
10
|
+
[]()
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why ForMesh
|
|
15
|
+
|
|
16
|
+
Most form libraries assume one flat form. Real enterprise forms rarely look like that — an employee record is Personal Info + Job Info + Bank Info + Documents, each built as its own component, all merging into one object to submit. Wiring that by hand means the same plumbing rewritten in every section: sync-to-parent, type-sniffing `onChange` handlers, visual-only validation, and re-renders that cascade across the whole form on every keystroke.
|
|
17
|
+
|
|
18
|
+
ForMesh starts from that problem instead of retrofitting it:
|
|
19
|
+
|
|
20
|
+
- **Sections are a first-class primitive**, not a convention you maintain yourself. Each section owns its slice; the parent always has the complete, merged object.
|
|
21
|
+
- **Fine-grained by construction.** The store only clones the branch that changed (structural sharing), so a component subscribed to one field never re-renders when an unrelated field changes — no memoization, no selectors to get right.
|
|
22
|
+
- **Debounced sync and derived fields are one primitive, not two.** The same `createDebouncedSync` wrapper fronts a whole store, a single section, or a future array row — and it composes straight into the same hooks as the un-wrapped target.
|
|
23
|
+
- **Validation is a plain function, not a schema DSL.** `(value, context) => message | undefined`. Ship the built-ins, or write a domain rule in one line.
|
|
24
|
+
- **Nearly zero dependencies.** No lodash, no schema-validation runtime baked into core. `~3.4 KB` gzip for the core, `~3.9 KB` for the React bindings — pay only for what you import.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install @mhasansagor/formesh
|
|
30
|
+
# or
|
|
31
|
+
pnpm add @mhasansagor/formesh
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> **Status:** ForMesh is under active development and not yet published to the public npm registry. The API below reflects what's implemented and tested today; `/file` and `/array` entry points are reserved for upcoming phases (see [Roadmap](#roadmap)).
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { createFormStore } from "@mhasansagor/formesh";
|
|
40
|
+
import { useForm } from "@mhasansagor/formesh/react";
|
|
41
|
+
|
|
42
|
+
const store = createFormStore({ firstName: "", email: "" });
|
|
43
|
+
|
|
44
|
+
function ProfileForm() {
|
|
45
|
+
const form = useForm(store);
|
|
46
|
+
const firstName = form.registerField("firstName");
|
|
47
|
+
const email = form.registerField("email");
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<form onSubmit={(e) => { e.preventDefault(); console.log(form.values); }}>
|
|
51
|
+
<input {...firstName} />
|
|
52
|
+
<input {...email} />
|
|
53
|
+
<button type="submit" disabled={!form.isValid}>Save</button>
|
|
54
|
+
</form>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Multi-section forms
|
|
60
|
+
|
|
61
|
+
Each section is built independently and reads/writes its own slice — the parent store ends up with the full merged object, with no manual wiring in between.
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
const store = createFormStore({
|
|
65
|
+
employeeInfo: { firstName: "", lastName: "" },
|
|
66
|
+
jobInfo: { department: "", designation: "" },
|
|
67
|
+
bankInfo: { accountNumber: "", bankName: "" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function EmployeeInfoSection() {
|
|
71
|
+
const form = useForm(store, { section: "employeeInfo" });
|
|
72
|
+
return <input {...form.registerField("firstName")} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function JobInfoSection() {
|
|
76
|
+
const form = useForm(store, { section: "jobInfo" });
|
|
77
|
+
return <input {...form.registerField("department")} />;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// store.getValues() → { employeeInfo: {...}, jobInfo: {...}, bankInfo: {...} }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Validation
|
|
84
|
+
|
|
85
|
+
Validators are plain functions — pure, synchronous, composable. Use the built-ins or write your own; both compose identically.
|
|
86
|
+
|
|
87
|
+
```tsx
|
|
88
|
+
import { required, email, minLength } from "@mhasansagor/formesh/validators";
|
|
89
|
+
|
|
90
|
+
const form = useForm(store, {
|
|
91
|
+
validation: {
|
|
92
|
+
fields: {
|
|
93
|
+
firstName: required(),
|
|
94
|
+
email: [required(), email()],
|
|
95
|
+
sku: (value, { values }) =>
|
|
96
|
+
values.existingSkus.includes(value) ? "SKU already exists." : undefined,
|
|
97
|
+
},
|
|
98
|
+
form: (values) =>
|
|
99
|
+
values.endDate < values.startDate
|
|
100
|
+
? { endDate: "End date must be after start date." }
|
|
101
|
+
: undefined,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
form.errors; // { email: "Enter a valid email address." }
|
|
106
|
+
form.isValid; // false
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Debounced sync & derived fields
|
|
110
|
+
|
|
111
|
+
One wrapper, any target — a whole store, a section, or (soon) an array row. Writes batch and commit after a quiet period; reads stay live the whole time.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
import { useDebouncedSync } from "@mhasansagor/formesh/react";
|
|
115
|
+
|
|
116
|
+
function EmployeeInfoSection() {
|
|
117
|
+
const section = useForm(store, { section: "employeeInfo" });
|
|
118
|
+
const sync = useDebouncedSync(section, { delay: 300 });
|
|
119
|
+
const form = useForm(sync); // composes straight in — same hooks, buffered target
|
|
120
|
+
return <input {...form.registerField("firstName")} />;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Derived and cascading fields subscribe to one path and react to real changes only:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { useWatch } from "@mhasansagor/formesh/react";
|
|
128
|
+
|
|
129
|
+
useWatch(store, "jobInfo.department", (department) => {
|
|
130
|
+
store.setValue("jobInfo.designation", "");
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Why not just React Context?
|
|
135
|
+
|
|
136
|
+
A Context-based form re-renders every consumer on every keystroke, regardless of which field they read. ForMesh instead exposes a subscribable store (`useSyncExternalStore` under the hood) where each write only touches the objects along its own path — so a field's re-render is gated by whether *its own* value actually changed, not by anything else in the form. No `Controller` ceremony required for third-party inputs that don't expose a ref.
|
|
137
|
+
|
|
138
|
+
## Roadmap
|
|
139
|
+
|
|
140
|
+
| Phase | Status |
|
|
141
|
+
|---|---|
|
|
142
|
+
| Core store, sections, `useForm` | ✅ Shipped |
|
|
143
|
+
| Field registration & normalization | ✅ Shipped |
|
|
144
|
+
| Debounced sync & `watch` | ✅ Shipped |
|
|
145
|
+
| Validation (`/validators`) | ✅ Shipped |
|
|
146
|
+
| File uploads (`/file`) | ✅ Shipped |
|
|
147
|
+
| Field arrays (`/array`) | ✅ Shipped |
|
|
148
|
+
| TypeScript path autocomplete, `FormDebugger` | 📋 Planned |
|
|
149
|
+
|
|
150
|
+
## Repository structure
|
|
151
|
+
|
|
152
|
+
This is a pnpm workspace. This file is the single source of truth for the library's documentation — it physically lives here, at `packages/formesh/README.md`, and the repo root's `README.md` is a **symlink** to this exact file, so both locations always show identical, up-to-date content with nothing to keep in sync manually.
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
formesh/
|
|
156
|
+
├── README.md → symlink → packages/formesh/README.md
|
|
157
|
+
├── packages/
|
|
158
|
+
│ └── formesh/
|
|
159
|
+
│ ├── README.md → the real file (this one)
|
|
160
|
+
│ ├── src/
|
|
161
|
+
│ └── tests/
|
|
162
|
+
├── examples/
|
|
163
|
+
│ └── basic-react/ → example app (planned)
|
|
164
|
+
├── LICENSE
|
|
165
|
+
├── .github/workflows/ → CI pipeline
|
|
166
|
+
└── .changeset/ → versioning config
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Contributing
|
|
170
|
+
|
|
171
|
+
From the repo root:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
pnpm install # install all workspace dependencies
|
|
175
|
+
pnpm lint # ESLint, across all packages
|
|
176
|
+
pnpm typecheck # tsc --noEmit, strict mode, across all packages
|
|
177
|
+
pnpm test # Vitest, across all packages
|
|
178
|
+
pnpm build # tsup, all entry points
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Please run the full pipeline above before opening a pull request — CI runs the same commands and will fail on any regression.
|
|
182
|
+
|
|
183
|
+
Edit this file directly (`packages/formesh/README.md`) — the root `README.md` is a symlink to it, so there is nothing separate to keep updated.
|
|
184
|
+
|
|
185
|
+
## Author
|
|
186
|
+
|
|
187
|
+
Built by **Mehedi Hasan** ([@mhasansagor](https://github.com/mhasansagor)), out of real multi-module ERP form work, and generalized to be useful well beyond it.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
Apache License, Version 2.0 — see the [`LICENSE`](/LICENSE) file, or the [official license text](https://www.apache.org/licenses/LICENSE-2.0).
|
package/dist/array.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"array.cjs"}
|
package/dist/array.d.cts
ADDED
package/dist/array.d.ts
ADDED
package/dist/array.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"array.js"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { F as FormValues, a as FieldPath, S as StoreListener, U as Unsubscribe, W as WatchListener } from './validation-gIT3Xn9h.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The minimal read/write/subscribe surface `createDebouncedSync` (and the
|
|
5
|
+
* React hooks) need from whatever sits behind them.
|
|
6
|
+
*
|
|
7
|
+
* `FormStore`, `FormSection`, and `DebouncedSync` all satisfy this
|
|
8
|
+
* structurally — which is the whole point: there is exactly ONE
|
|
9
|
+
* debounced-sync wrapper, and whether it fronts a whole store, a section
|
|
10
|
+
* slice, or (via `createFormSection`) an array row's object slice is
|
|
11
|
+
* decided by the target handed to it, not by a second implementation.
|
|
12
|
+
* The same surface is what lets a wrapper be handed straight to
|
|
13
|
+
* `useForm`/`useFormField`/`useWatch` or wrapped in another
|
|
14
|
+
* `createDebouncedSync`.
|
|
15
|
+
*
|
|
16
|
+
* Members are declared with method syntax (see the note on `FormStore`
|
|
17
|
+
* for why — property-syntax function members break structural
|
|
18
|
+
* assignability of typed targets under `strictFunctionTypes`).
|
|
19
|
+
*/
|
|
20
|
+
interface SyncTarget<TValues extends FormValues = FormValues> {
|
|
21
|
+
getValues(): TValues;
|
|
22
|
+
getValue(path: FieldPath): unknown;
|
|
23
|
+
setValue(path: FieldPath, value: unknown): void;
|
|
24
|
+
setValues(partial: Partial<TValues>): void;
|
|
25
|
+
reset(nextInitialValues?: TValues): void;
|
|
26
|
+
/** The committed baseline (what `reset()` restores to), never buffered writes. */
|
|
27
|
+
getInitialValues(): TValues;
|
|
28
|
+
subscribe(listener: StoreListener): Unsubscribe;
|
|
29
|
+
/**
|
|
30
|
+
* Observes one path (including its subtree) across changes, firing the
|
|
31
|
+
* listener only when the value actually changed (deep equality). Present
|
|
32
|
+
* on all implementations (store, section, debounced sync), so `useWatch`
|
|
33
|
+
* and derived-field logic compose against any target the same way.
|
|
34
|
+
*/
|
|
35
|
+
watch(path: FieldPath, listener: WatchListener): Unsubscribe;
|
|
36
|
+
}
|
|
37
|
+
interface DebouncedSyncOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Milliseconds to wait after the last buffered write before committing to
|
|
40
|
+
* the target (trailing-edge debounce). Defaults to 300ms.
|
|
41
|
+
*/
|
|
42
|
+
delay?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A buffering layer in front of any SyncTarget. Writes land in a pending
|
|
46
|
+
* batch and commit to the target in ONE transactional write after `delay`
|
|
47
|
+
* ms of quiet; reads see the buffered writes immediately (read-through),
|
|
48
|
+
* so a UI rendering from the wrapper never lags behind typing even though
|
|
49
|
+
* the parent store updates on the debounce.
|
|
50
|
+
*
|
|
51
|
+
* It deliberately exposes the same surface shape as its target (members in
|
|
52
|
+
* method syntax, see `SyncTarget`), so it can itself be wrapped, watched,
|
|
53
|
+
* composed, and handed to the React hooks the same way.
|
|
54
|
+
*/
|
|
55
|
+
interface DebouncedSync<TValues extends FormValues = FormValues> {
|
|
56
|
+
/** The configured debounce delay in milliseconds. */
|
|
57
|
+
readonly delay: number;
|
|
58
|
+
/** How many writes are currently buffered and not yet committed. */
|
|
59
|
+
readonly pendingCount: number;
|
|
60
|
+
/**
|
|
61
|
+
* Current values including any buffered-but-uncommitted writes. The
|
|
62
|
+
* returned snapshot is cached between changes, making it safe as a
|
|
63
|
+
* `useSyncExternalStore` getSnapshot.
|
|
64
|
+
*/
|
|
65
|
+
getValues(): TValues;
|
|
66
|
+
/** Reads a single value by path, including buffered writes. */
|
|
67
|
+
getValue(path: FieldPath): unknown;
|
|
68
|
+
/** Buffers a single write and (re)schedules the debounced commit. */
|
|
69
|
+
setValue(path: FieldPath, value: unknown): void;
|
|
70
|
+
/** Buffers a partial merge (same shallow-merge semantics as the target). */
|
|
71
|
+
setValues(partial: Partial<TValues>): void;
|
|
72
|
+
/** Drops all pending writes and forwards the reset to the target. */
|
|
73
|
+
reset(nextInitialValues?: TValues): void;
|
|
74
|
+
/**
|
|
75
|
+
* The target's committed baseline (what `reset()` restores to),
|
|
76
|
+
* delegated straight through to the wrapped target. Buffered writes are
|
|
77
|
+
* deliberately NOT part of the baseline — they are uncommitted edits.
|
|
78
|
+
*/
|
|
79
|
+
getInitialValues(): TValues;
|
|
80
|
+
/**
|
|
81
|
+
* Subscribes to changes. Listeners fire immediately when writes are
|
|
82
|
+
* buffered (with the read-through changed paths) and when the target
|
|
83
|
+
* changes for reasons other than this wrapper's own flush.
|
|
84
|
+
*/
|
|
85
|
+
subscribe(listener: StoreListener): Unsubscribe;
|
|
86
|
+
/** Observes one path (read-through) across buffered and committed changes. */
|
|
87
|
+
watch(path: FieldPath, listener: WatchListener): Unsubscribe;
|
|
88
|
+
/** Commits everything buffered right now and clears the timer. */
|
|
89
|
+
flush(): void;
|
|
90
|
+
/** Drops everything buffered and clears the timer. Target is untouched. */
|
|
91
|
+
cancel(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Context passed to a normalizer alongside the raw input value.
|
|
96
|
+
* Kept intentionally small in Phase 1 — extend only when a real normalizer
|
|
97
|
+
* needs more (e.g. sibling values), not speculatively.
|
|
98
|
+
*/
|
|
99
|
+
interface NormalizeContext {
|
|
100
|
+
/** The dot-path of the field being normalized. */
|
|
101
|
+
path: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A normalizer takes whatever a UI component hands back (a DOM event, a
|
|
105
|
+
* Date, a react-select-style option object, a plain value...) and returns
|
|
106
|
+
* the value that should actually be stored.
|
|
107
|
+
*
|
|
108
|
+
* This is the single place that replaces the hand-written
|
|
109
|
+
* `handleFieldChange` type-sniffing function duplicated across form
|
|
110
|
+
* components in the reference application — implemented once here, with an
|
|
111
|
+
* escape hatch (a custom Normalizer) for shapes it doesn't know about.
|
|
112
|
+
*/
|
|
113
|
+
type Normalizer<TValue = unknown> = (input: unknown, context: NormalizeContext) => TValue;
|
|
114
|
+
interface FieldOptions<TValue = unknown> {
|
|
115
|
+
/** Custom normalizer. Defaults to `defaultNormalize` when omitted. */
|
|
116
|
+
normalize?: Normalizer<TValue>;
|
|
117
|
+
/** Value to use when the field has never been set. */
|
|
118
|
+
defaultValue?: TValue;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The prop bag `registerField` hands back — deliberately shaped like the
|
|
122
|
+
* value/onChange/onBlur triplet every existing input component
|
|
123
|
+
* (native inputs, and the reference app's AppInput/AppDropdown/etc.)
|
|
124
|
+
* already expects, so adopting the library is a plumbing swap, not a
|
|
125
|
+
* component rewrite.
|
|
126
|
+
*/
|
|
127
|
+
interface RegisteredField<TValue = unknown> {
|
|
128
|
+
name: string;
|
|
129
|
+
value: TValue;
|
|
130
|
+
onChange: (input: unknown) => void;
|
|
131
|
+
onBlur: () => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { DebouncedSync as D, FieldOptions as F, Normalizer as N, RegisteredField as R, SyncTarget as S, DebouncedSyncOptions as a, NormalizeContext as b };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { F as FormValues, a as FieldPath, S as StoreListener, U as Unsubscribe, W as WatchListener } from './validation-gIT3Xn9h.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The minimal read/write/subscribe surface `createDebouncedSync` (and the
|
|
5
|
+
* React hooks) need from whatever sits behind them.
|
|
6
|
+
*
|
|
7
|
+
* `FormStore`, `FormSection`, and `DebouncedSync` all satisfy this
|
|
8
|
+
* structurally — which is the whole point: there is exactly ONE
|
|
9
|
+
* debounced-sync wrapper, and whether it fronts a whole store, a section
|
|
10
|
+
* slice, or (via `createFormSection`) an array row's object slice is
|
|
11
|
+
* decided by the target handed to it, not by a second implementation.
|
|
12
|
+
* The same surface is what lets a wrapper be handed straight to
|
|
13
|
+
* `useForm`/`useFormField`/`useWatch` or wrapped in another
|
|
14
|
+
* `createDebouncedSync`.
|
|
15
|
+
*
|
|
16
|
+
* Members are declared with method syntax (see the note on `FormStore`
|
|
17
|
+
* for why — property-syntax function members break structural
|
|
18
|
+
* assignability of typed targets under `strictFunctionTypes`).
|
|
19
|
+
*/
|
|
20
|
+
interface SyncTarget<TValues extends FormValues = FormValues> {
|
|
21
|
+
getValues(): TValues;
|
|
22
|
+
getValue(path: FieldPath): unknown;
|
|
23
|
+
setValue(path: FieldPath, value: unknown): void;
|
|
24
|
+
setValues(partial: Partial<TValues>): void;
|
|
25
|
+
reset(nextInitialValues?: TValues): void;
|
|
26
|
+
/** The committed baseline (what `reset()` restores to), never buffered writes. */
|
|
27
|
+
getInitialValues(): TValues;
|
|
28
|
+
subscribe(listener: StoreListener): Unsubscribe;
|
|
29
|
+
/**
|
|
30
|
+
* Observes one path (including its subtree) across changes, firing the
|
|
31
|
+
* listener only when the value actually changed (deep equality). Present
|
|
32
|
+
* on all implementations (store, section, debounced sync), so `useWatch`
|
|
33
|
+
* and derived-field logic compose against any target the same way.
|
|
34
|
+
*/
|
|
35
|
+
watch(path: FieldPath, listener: WatchListener): Unsubscribe;
|
|
36
|
+
}
|
|
37
|
+
interface DebouncedSyncOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Milliseconds to wait after the last buffered write before committing to
|
|
40
|
+
* the target (trailing-edge debounce). Defaults to 300ms.
|
|
41
|
+
*/
|
|
42
|
+
delay?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A buffering layer in front of any SyncTarget. Writes land in a pending
|
|
46
|
+
* batch and commit to the target in ONE transactional write after `delay`
|
|
47
|
+
* ms of quiet; reads see the buffered writes immediately (read-through),
|
|
48
|
+
* so a UI rendering from the wrapper never lags behind typing even though
|
|
49
|
+
* the parent store updates on the debounce.
|
|
50
|
+
*
|
|
51
|
+
* It deliberately exposes the same surface shape as its target (members in
|
|
52
|
+
* method syntax, see `SyncTarget`), so it can itself be wrapped, watched,
|
|
53
|
+
* composed, and handed to the React hooks the same way.
|
|
54
|
+
*/
|
|
55
|
+
interface DebouncedSync<TValues extends FormValues = FormValues> {
|
|
56
|
+
/** The configured debounce delay in milliseconds. */
|
|
57
|
+
readonly delay: number;
|
|
58
|
+
/** How many writes are currently buffered and not yet committed. */
|
|
59
|
+
readonly pendingCount: number;
|
|
60
|
+
/**
|
|
61
|
+
* Current values including any buffered-but-uncommitted writes. The
|
|
62
|
+
* returned snapshot is cached between changes, making it safe as a
|
|
63
|
+
* `useSyncExternalStore` getSnapshot.
|
|
64
|
+
*/
|
|
65
|
+
getValues(): TValues;
|
|
66
|
+
/** Reads a single value by path, including buffered writes. */
|
|
67
|
+
getValue(path: FieldPath): unknown;
|
|
68
|
+
/** Buffers a single write and (re)schedules the debounced commit. */
|
|
69
|
+
setValue(path: FieldPath, value: unknown): void;
|
|
70
|
+
/** Buffers a partial merge (same shallow-merge semantics as the target). */
|
|
71
|
+
setValues(partial: Partial<TValues>): void;
|
|
72
|
+
/** Drops all pending writes and forwards the reset to the target. */
|
|
73
|
+
reset(nextInitialValues?: TValues): void;
|
|
74
|
+
/**
|
|
75
|
+
* The target's committed baseline (what `reset()` restores to),
|
|
76
|
+
* delegated straight through to the wrapped target. Buffered writes are
|
|
77
|
+
* deliberately NOT part of the baseline — they are uncommitted edits.
|
|
78
|
+
*/
|
|
79
|
+
getInitialValues(): TValues;
|
|
80
|
+
/**
|
|
81
|
+
* Subscribes to changes. Listeners fire immediately when writes are
|
|
82
|
+
* buffered (with the read-through changed paths) and when the target
|
|
83
|
+
* changes for reasons other than this wrapper's own flush.
|
|
84
|
+
*/
|
|
85
|
+
subscribe(listener: StoreListener): Unsubscribe;
|
|
86
|
+
/** Observes one path (read-through) across buffered and committed changes. */
|
|
87
|
+
watch(path: FieldPath, listener: WatchListener): Unsubscribe;
|
|
88
|
+
/** Commits everything buffered right now and clears the timer. */
|
|
89
|
+
flush(): void;
|
|
90
|
+
/** Drops everything buffered and clears the timer. Target is untouched. */
|
|
91
|
+
cancel(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Context passed to a normalizer alongside the raw input value.
|
|
96
|
+
* Kept intentionally small in Phase 1 — extend only when a real normalizer
|
|
97
|
+
* needs more (e.g. sibling values), not speculatively.
|
|
98
|
+
*/
|
|
99
|
+
interface NormalizeContext {
|
|
100
|
+
/** The dot-path of the field being normalized. */
|
|
101
|
+
path: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A normalizer takes whatever a UI component hands back (a DOM event, a
|
|
105
|
+
* Date, a react-select-style option object, a plain value...) and returns
|
|
106
|
+
* the value that should actually be stored.
|
|
107
|
+
*
|
|
108
|
+
* This is the single place that replaces the hand-written
|
|
109
|
+
* `handleFieldChange` type-sniffing function duplicated across form
|
|
110
|
+
* components in the reference application — implemented once here, with an
|
|
111
|
+
* escape hatch (a custom Normalizer) for shapes it doesn't know about.
|
|
112
|
+
*/
|
|
113
|
+
type Normalizer<TValue = unknown> = (input: unknown, context: NormalizeContext) => TValue;
|
|
114
|
+
interface FieldOptions<TValue = unknown> {
|
|
115
|
+
/** Custom normalizer. Defaults to `defaultNormalize` when omitted. */
|
|
116
|
+
normalize?: Normalizer<TValue>;
|
|
117
|
+
/** Value to use when the field has never been set. */
|
|
118
|
+
defaultValue?: TValue;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The prop bag `registerField` hands back — deliberately shaped like the
|
|
122
|
+
* value/onChange/onBlur triplet every existing input component
|
|
123
|
+
* (native inputs, and the reference app's AppInput/AppDropdown/etc.)
|
|
124
|
+
* already expects, so adopting the library is a plumbing swap, not a
|
|
125
|
+
* component rewrite.
|
|
126
|
+
*/
|
|
127
|
+
interface RegisteredField<TValue = unknown> {
|
|
128
|
+
name: string;
|
|
129
|
+
value: TValue;
|
|
130
|
+
onChange: (input: unknown) => void;
|
|
131
|
+
onBlur: () => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type { DebouncedSync as D, FieldOptions as F, Normalizer as N, RegisteredField as R, SyncTarget as S, DebouncedSyncOptions as a, NormalizeContext as b };
|
package/dist/file.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"file.cjs"}
|
package/dist/file.d.cts
ADDED
package/dist/file.d.ts
ADDED
package/dist/file.js
ADDED
package/dist/file.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"file.js"}
|