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 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: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](/LICENSE)
8
+ [![Status](https://img.shields.io/badge/status-active%20development-orange.svg)]()
9
+ [![Tests](https://img.shields.io/badge/tests-124%20passing-brightgreen.svg)]()
10
+ [![Core size](https://img.shields.io/badge/core-3.4%20KB%20gzip-success.svg)]()
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,4 @@
1
+ 'use strict';
2
+
3
+ //# sourceMappingURL=array.cjs.map
4
+ //# sourceMappingURL=array.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"array.cjs"}
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/array.js ADDED
@@ -0,0 +1,3 @@
1
+
2
+ //# sourceMappingURL=array.js.map
3
+ //# sourceMappingURL=array.js.map
@@ -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,4 @@
1
+ 'use strict';
2
+
3
+ //# sourceMappingURL=file.cjs.map
4
+ //# sourceMappingURL=file.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"file.cjs"}
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/file.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/file.js ADDED
@@ -0,0 +1,3 @@
1
+
2
+ //# sourceMappingURL=file.js.map
3
+ //# sourceMappingURL=file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"file.js"}