formstand 0.6.0 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,241 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.8.0 — 2026-07-10
6
+
7
+ #### Fixed (2026-07 full-repo review)
8
+
9
+ - `valuesEqual` compared only the first object's keys, so two objects with
10
+ equal key counts but different key sets (one holding `undefined`) read as
11
+ equal — dirty flags, `diff()`, and server-error release could all treat a
12
+ real change as clean. Key sets must now match (`Object.hasOwn`), and the
13
+ compare is symmetric.
14
+ - `setAtPath` no longer spreads a `Date`/`Map`/`Set`/class instance at an
15
+ intermediate path into a plain object (silently destroying it); the write
16
+ is refused with a console warning, like other unwritable shapes.
17
+ - Path reads return own properties only: `getAtPath(values,
18
+ "lookup.constructor")` on a `z.record` is `undefined` instead of leaking
19
+ `Object.prototype` members as field values.
20
+ - A throwing `onInvalid` handler now resolves `submit()` with
21
+ `{ kind: "error" }` like a throwing `onValid`, instead of rejecting out of
22
+ a DOM event handler.
23
+ - `restore()` no longer resurrects a snapshotted `isSubmitting` — the flag
24
+ reflects live submit passes, so a mid-submit snapshot restored later can't
25
+ disable submit buttons forever (same rule the validation flags already
26
+ followed).
27
+ - `SelectField` on a nullable field keeps its empty option visible and
28
+ selectable after a choice, so the field can be cleared back to `null`
29
+ through the UI (the `emptyValue` round-trip `selectProps` always
30
+ supported). Non-nullable fields keep the disabled placeholder.
31
+ - `useFieldArray` no longer hands a removed row's id to a row appended in
32
+ the same update (React treated it as a reorder and resurrected the dead
33
+ row's DOM state); the same-index fallback still keeps an edited row's id.
34
+ - `useForm` now also warns when the CONTENT of `initialValues` changes after
35
+ mount (the async-fetch pattern with a module-hoisted schema — previously
36
+ silently ignored with no warning at all). Inline literals with identical
37
+ content stay silent.
38
+ - Docs: `field-arrays.md` claimed array ops re-key `isValidating`; they
39
+ deliberately drop in-flight flags (the pass that set them is stale) — the
40
+ guide now says so. `components.md` documents the `checkboxProps`
41
+ unchecked-writes-`false` tradeoff for nullable booleans.
42
+
43
+ ### Docs & examples (no package changes)
44
+
45
+ - The docs nav gains a "Built on" menu and the footer links to
46
+ [zod](https://zod.dev) and [zustand](https://zustand.docs.pmnd.rs).
47
+
48
+ - A "Schema builder" tab: formstand-gen running in the browser. A formstand
49
+ form describes a schema (fields, sections, arrays, enum options — with
50
+ duplicate-name and enum-needs-options cross-field validation), its values
51
+ map straight onto the CLI's IR, and the REAL emitters (imported from
52
+ cli/src — pure string builders, no Node APIs) regenerate the output files
53
+ on every keystroke, shown in a file tree with copy-file / copy-all. All
54
+ the CLI axes are selects: --ui, --layout, --sections, --columns.
55
+ - The Schema builder's "Copy all files" is now "Download .zip" — a
56
+ dependency-free STORE-only zip writer (~50 lines, in the demo's own
57
+ source) bundles the generated files under a folder named for the
58
+ component, exactly the tree `--out` would have written.
59
+ - Every demo has a direct link: hash routes like
60
+ `examples/#/schema-builder` (kebab-cased tab keys), synced both ways so
61
+ back/forward and hand-edited URLs work. Hash routing because GitHub Pages
62
+ is static hosting.
63
+ - The CLI command builder gains Sections and Field columns selects, and the
64
+ Generated Onboarding tab now showcases `--sections panel --columns 2`
65
+ (still CI-verified as the CLI's untouched output).
66
+
67
+ - A "CLI command builder" tab: a formstand form (cross-field superRefine,
68
+ conditional fields) whose live-updating formstand-gen command line is
69
+ useFormSelector-derived state, with a copy button.
70
+ - A "Generated" playground group whose Onboarding tab is formstand-gen's
71
+ untouched `--layout module --ui mui` output for the Onboarding schema —
72
+ CI regenerates it and fails on drift, so the demo is provably what the
73
+ current CLI emits (scripts/generate-cli-demos.mjs).
74
+
75
+ - MUI and shadcn variants of the Onboarding feature module (31 demos now):
76
+ same shared schema, option lists, and blank draft as the plain module
77
+ (re-exported, so the three can't drift), each with its own form instance
78
+ and pre-wired hooks — MUI sections are Accordions with dirty/valid chips,
79
+ shadcn sections are collapsible with badges. The CLI generates the same
80
+ architecture from the same schema (`--layout module --ui mui|shadcn`).
81
+ - The sidebar demo list is a tree view (x-tree-view): brass group folders,
82
+ form-check leaf icons, brass selection.
83
+
84
+ - The playground gets a real app layout: grouped demo navigation in a
85
+ sidebar (Core / Patterns / Material UI / shadcn/ui — the group map is
86
+ exhaustive over the tab keys, so an unassigned demo is a compile error),
87
+ a sticky scrollable sidebar from tablet width up, and responsive tiers
88
+ through HD to 4K; on mobile the nav becomes wrapping chips above the
89
+ content. `/guide/` on the docs site now lands on a guide index instead
90
+ of a 404.
91
+ - Fixed: clicking a checkbox's label made the field and everything below
92
+ it jump down for a frame and snap back (Chrome's label-forwarded
93
+ activation briefly rendered the checkbox as a padded text-input box —
94
+ reproduced and verified fixed in real Chromium via Playwright). The
95
+ playground's text-input chrome now excludes checkboxes, which also gain
96
+ a brass accent-color and a focus-visible ring.
97
+ - View state / View code move to a panel on the demo's right (stacked
98
+ below on narrow screens), toggled by the same two buttons; multi-file
99
+ demos (Onboarding) get a file-tree navigator (@mui/x-tree-view with
100
+ folder/file icons) opening on hooks.ts; the sidebar brand links back to
101
+ the docs and stays pinned while the demo list scrolls.
102
+ - The Perf demo registers its form like every other tab, so View state
103
+ works (the shell only mounts the panel while open, so a closed panel
104
+ costs the benchmark nothing — the copy says to close it while running).
105
+ The benchmark writes `field0` instead of the mid-grid field, so the
106
+ change is visible without scrolling.
107
+
108
+ ## formstand-cli 0.5.2 — 2026-07-10
109
+
110
+ #### Fixed (2026-07 full-repo review)
111
+
112
+ - Digit-leading schema keys ("2fa", "2ndOwners") emitted invalid
113
+ identifiers (`const 2ndOwnersArray`); pascalCase/camelCase results are
114
+ now underscore-prefixed when they'd start with a digit.
115
+ - Array-row fields named JS reserved words ("new", "delete") emitted
116
+ reserved-word `const` declarations in module sections; row bindings now
117
+ use an identifier-safe variant (`new_`).
118
+ - A field named after the module prefix (field "contact" in ContactForm)
119
+ collided with the bound `useContactField` hook from `hooks.ts` — a
120
+ duplicate declaration plus self-recursion; component names are now
121
+ deduped against the hook names too (`ContactField2`).
122
+ - Boolean-only schemas with `--ui mui --layout module` emitted an
123
+ `adapter.ts` using `ChangeEvent` without importing it.
124
+ - `--name` is validated as an identifier (reserved words rejected) instead
125
+ of interpolating verbatim into declarations and file names.
126
+ - Cross-drive input/`--out` on Windows produced an unresolvable
127
+ `"./D:/..."` import specifier; the CLI now fails loudly at generation
128
+ time.
129
+ - zod's `.nonoptional()` unwrapped transparently, letting an inner
130
+ `.optional()` win — the field's checked `initialValues` annotation then
131
+ failed to typecheck; `.nonoptional()` now re-requires the field.
132
+ - A field literally named `__proto__` silently vanished from emitted object
133
+ literals (prototype-setter semantics); it's now emitted as a computed key.
134
+ - `--sections panel` with `--ui mui` rendered different chrome per
135
+ `--layout` (single-file used `CardHeader`, module used `Typography` in
136
+ `CardContent`); both emitters now share the module shape, the grid
137
+ strings come from one set of helpers instead of six inline copies, and
138
+ `emitInitialValues`/`blankNeedsCast` read one shared blank-value table so
139
+ they can't drift.
140
+
141
+ ## formstand-cli 0.5.1 — 2026-07-09
142
+
143
+ ### Fixed
144
+
145
+ - Generated `initialValues` (and array empty-item constants) only use the
146
+ `as unknown as` escape hatch when the blank draft genuinely can't
147
+ typecheck (a required number/date/enum starts `undefined`); every other
148
+ schema gets a checked type annotation, so typos in edited initial values
149
+ are compile errors again.
150
+ - `--layout module`: the generated `index.ts` now re-exports `./schema` and
151
+ `./types` alongside `./hooks`, so consumers can type submit handlers and
152
+ server code (`ProfileValues`, `profileSchema`) off the module's public
153
+ API instead of deep-importing its files.
154
+
155
+ ## formstand-cli 0.5.0 — 2026-07-09
156
+
157
+ ### Added
158
+
159
+ - `--sections flat|panel|collapsible` and `--columns 1|2|3`: minimal visual
160
+ layout options, working with both `--layout single` and `--layout module`
161
+ and all three uis. Sections render as flat headings (default, unchanged
162
+ output), bordered panels, or collapsible sections; fields inside each
163
+ section flow into 1–3 evenly spaced columns, with nested sections
164
+ spanning the full row. Each ui speaks its own dialect: inline styles for
165
+ `plain` (`<details>`/`<summary>` when collapsible), `Card`/`Accordion` +
166
+ `sx` grids for `mui`, Tailwind classes (`md:grid-cols-2`,
167
+ `bg-card … shadow-sm`, `<details>`) for `shadcn`. The defaults emit
168
+ byte-identical output to 0.4.0.
169
+
170
+ ## formstand-cli 0.4.0 — 2026-07-09
171
+
172
+ ### Added
173
+
174
+ - `--layout module` works with `--ui mui` and `--ui shadcn`: kit modules
175
+ get a shared `adapter.ts` / `adapter.tsx` exporting the adapter the
176
+ single-file backends inline, and field/section files import it. The
177
+ emitted prop builders are now generic over the field's value type
178
+ (matching the documented example adapters), because the module layout's
179
+ typed hooks surfaced what the single-file layout's widened hooks masked —
180
+ `UseFieldReturn<string>` wasn't assignable to a monomorphic builder.
181
+ Emitted kit modules are typechecked in CI against the MUI stub and both
182
+ the shadcn stub and the repo's real Radix components.
183
+
184
+ ### Docs & examples (no package changes)
185
+
186
+ - Brand pass on the docs site and playground: the formstand mark (a form
187
+ with its green check, resting on a music stand) as logo and favicon, a
188
+ brass/ink palette replacing the stock VitePress indigo, hand-drawn SVG
189
+ feature icons replacing the emoji, and restrained motion (hero float,
190
+ card lift, tab/button transitions, demo fade-in) — all disabled under
191
+ prefers-reduced-motion. The playground gains a branded header and
192
+ focus-visible states.
193
+
194
+ ## formstand-cli 0.3.0 — 2026-07-09
195
+
196
+ ### Added
197
+
198
+ - `--layout module`: emits a feature-module folder instead of one file —
199
+ `schema.ts` (re-exported in zod mode, generated in type mode), `types.ts`,
200
+ `hooks.ts` (`createForm` + `createFormHooks`, so the whole module shares
201
+ one pre-wired hook API), one file per scalar field (props type + field
202
+ hook + component), one file per top-level object/array section (props
203
+ type + section hook built on the path-scoped dirty/valid flags +
204
+ component), the form body, and `index.ts` — the shape of the Onboarding
205
+ playground demo. `--out` names the folder with all destinations
206
+ pre-checked; without it the files stream to stdout with `// --- file:`
207
+ headers. Emitted modules are typechecked in CI against the library
208
+ source, including hostile-name, colliding-name, and leaf-free schemas.
209
+ Requires formstand ≥ 0.7; `--ui plain` only for now.
210
+ - `emitModuleForm` / `joinModuleFiles` join the programmatic API.
211
+
212
+ ## 0.7.0 — 2026-07-09
213
+
214
+ ### Added
215
+
216
+ - `createFormHooks(form, name?)` — every hook pre-wired to one form, the
217
+ provider-free way to share a module-singleton form. The optional name is
218
+ baked into the hook names at the type level and at runtime
219
+ (`createFormHooks(form, "invoice")` → `useInvoiceField`,
220
+ `useInvoiceFieldArray`, `useInvoiceSelector`, `useInvoiceSelectorShallow`,
221
+ `useInvoiceError`, `useInvoiceIsDirty`, `useInvoiceIsValid`,
222
+ `useInvoiceIsSubmitting`, `useInvoiceSubmitCount`), so a typo'd
223
+ destructure is a compile error; omit the name for unprefixed keys. Every
224
+ bound hook keeps its unbound signature minus the `form` argument — typed
225
+ paths, array item inference, path-scoped flags. Documented alongside
226
+ `createFormContext` with the singleton/SSR caveat; new "Hooks factory"
227
+ playground tab.
228
+
229
+ ### Docs & examples (no package changes)
230
+
231
+ - New "Onboarding" playground tab: a 26-field, five-section feature module
232
+ built on `createFormHooks` — `schema.ts` / `types.ts` / `hooks.ts`, one
233
+ file per field, one per section (section headers run the path-scoped
234
+ `useIsDirty`/`useIsValid` flags), with the whole folder shown in the
235
+ View code panel.
236
+ - The playground's View code panel is syntax-highlighted (Prism, ~25KB —
237
+ read-only, so no editor bundle).
238
+
3
239
  ## 0.6.0 — 2026-07-09
4
240
 
5
241
  ### Added
package/dist/index.cjs CHANGED
@@ -59,7 +59,7 @@ var valuesEqual = (a, b) => {
59
59
  if (isPlainObject(a) && isPlainObject(b)) {
60
60
  const aKeys = Object.keys(a);
61
61
  const bKeys = Object.keys(b);
62
- return aKeys.length === bKeys.length && aKeys.every((k) => valuesEqual(a[k], b[k]));
62
+ return aKeys.length === bKeys.length && aKeys.every((k) => Object.hasOwn(b, k) && valuesEqual(a[k], b[k]));
63
63
  }
64
64
  return false;
65
65
  };
@@ -94,7 +94,7 @@ var readSegment = (obj, segment) => {
94
94
  if (Array.isArray(obj)) {
95
95
  return typeof segment === "number" ? obj[segment] : void 0;
96
96
  }
97
- return isPlainRecord(obj) ? obj[String(segment)] : void 0;
97
+ return isPlainRecord(obj) && Object.hasOwn(obj, String(segment)) ? obj[String(segment)] : void 0;
98
98
  };
99
99
  var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
100
100
  var slotAtPath = (obj, path) => {
@@ -137,6 +137,12 @@ var writeSegments = (obj, segments, value) => {
137
137
  return writeIntoArray(obj, head, rest, value);
138
138
  }
139
139
  if (isPlainRecord(obj)) {
140
+ if (!isPlainObject(obj)) {
141
+ console.warn(
142
+ `[formstand] cannot write key "${String(head)}" through a non-plain object (Date/Map/Set/class instance); value left unchanged.`
143
+ );
144
+ return obj;
145
+ }
140
146
  const key = String(head);
141
147
  return { ...obj, [key]: writeSegments(obj[key], rest, value) };
142
148
  }
@@ -670,7 +676,11 @@ var createForm = (schema, options) => {
670
676
  });
671
677
  }
672
678
  const merged = stale ? result.errors : store.getState().errors;
673
- onInvalid?.(merged);
679
+ try {
680
+ onInvalid?.(merged);
681
+ } catch (error) {
682
+ return { kind: "error", error };
683
+ }
674
684
  return { kind: "invalid", errors: merged };
675
685
  }
676
686
  if (!stale) {
@@ -930,9 +940,13 @@ var createForm = (schema, options) => {
930
940
  // In-flight state is owned by live passes, never by snapshots: a
931
941
  // snapshotted flag has no pass left to clear it (the pass that set
932
942
  // it cleared its ORIGINAL entry when it settled), so restoring one
933
- // would stick it forever.
943
+ // would stick it forever. isSubmitting follows the same rule — a
944
+ // mid-submit snapshot restored later must not disable submit
945
+ // buttons forever, so it reflects the LIVE submit count instead of
946
+ // the snapshotted flag (a live pass's finally still resets it).
934
947
  isValidating: emptyBools,
935
- isValidatingForm: false
948
+ isValidatingForm: false,
949
+ isSubmitting: inFlight.count > 0
936
950
  };
937
951
  })
938
952
  });
@@ -960,15 +974,17 @@ var useForm = (schema, options) => {
960
974
  formRef.current = createForm(schema, options);
961
975
  }
962
976
  const form = formRef.current;
977
+ const mountInitialsRef = react.useRef(options.initialValues);
963
978
  const warnedRef = react.useRef(false);
964
979
  react.useEffect(() => {
965
- if (!warnedRef.current && form.schema !== schema) {
980
+ const changedInitials = !Object.is(options.initialValues, mountInitialsRef.current) && !valuesEqual(options.initialValues, mountInitialsRef.current);
981
+ if (!warnedRef.current && (form.schema !== schema || changedInitials)) {
966
982
  warnedRef.current = true;
967
983
  console.warn(
968
- "[formstand] useForm received a different schema reference after the first render; schema and options changes are ignored for the life of the component. If the schema is defined inline this is harmless (but consider hoisting it to module scope). For initial values that arrive later (e.g. from a fetch), call form.adoptValues(values) or form.reset(values) instead."
984
+ "[formstand] useForm received a different schema or initial values after the first render; schema and options changes are ignored for the life of the component. If the schema is defined inline this is harmless (but consider hoisting it to module scope). For initial values that arrive later (e.g. from a fetch), call form.adoptValues(values) or form.reset(values) instead."
969
985
  );
970
986
  }
971
- }, [form, schema]);
987
+ }, [form, schema, options.initialValues]);
972
988
  return form;
973
989
  };
974
990
  var createFormContext = () => {
@@ -985,38 +1001,6 @@ var createFormContext = () => {
985
1001
  };
986
1002
  return { Provider, useFormContext };
987
1003
  };
988
- function useFormSelector(form, selector) {
989
- return react$1.useStore(form.store, selector);
990
- }
991
- function useFormSelectorShallow(form, selector) {
992
- return react$1.useStore(form.store, shallow.useShallow(selector));
993
- }
994
- var useFormError = (form) => react$1.useStore(
995
- form.store,
996
- shallow.useShallow((state) => state.errors[""])
997
- );
998
- function useIsDirty(form, path) {
999
- return react$1.useStore(
1000
- form.store,
1001
- (state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
1002
- getAtPath(state.values, path),
1003
- getAtPath(state.initialValues, path)
1004
- )
1005
- );
1006
- }
1007
- function useIsValid(form, path) {
1008
- return react$1.useStore(form.store, (state) => {
1009
- for (const k in state.errors) {
1010
- const errs = state.errors[k];
1011
- if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1012
- return false;
1013
- }
1014
- }
1015
- return true;
1016
- });
1017
- }
1018
- var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1019
- var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1020
1004
  function useField(form, pathArg, options) {
1021
1005
  const slice = react$1.useStore(
1022
1006
  form.store,
@@ -1146,6 +1130,168 @@ function useField(form, pathArg, options) {
1146
1130
  [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1147
1131
  );
1148
1132
  }
1133
+ var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1134
+ var EMPTY_ITEMS = [];
1135
+ var reconcileIds = (prev, nextItems) => {
1136
+ if (prev.items === nextItems) return prev;
1137
+ if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1138
+ return prev;
1139
+ }
1140
+ const buckets = prev.items.reduce((acc, item, i) => {
1141
+ const bucket = acc.get(item);
1142
+ if (bucket === void 0) {
1143
+ acc.set(item, [i]);
1144
+ } else {
1145
+ bucket.push(i);
1146
+ }
1147
+ return acc;
1148
+ }, /* @__PURE__ */ new Map());
1149
+ const matchedIds = nextItems.map((item) => {
1150
+ const prevIndex = buckets.get(item)?.shift();
1151
+ return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
1152
+ });
1153
+ const leftoverAt = new Map(
1154
+ [...buckets.values()].flat().flatMap((i) => {
1155
+ const id = prev.ids[i];
1156
+ return id === void 0 ? [] : [[i, id]];
1157
+ })
1158
+ );
1159
+ const reused = matchedIds.map((id, i) => id ?? leftoverAt.get(i) ?? null);
1160
+ const mintCounts = reused.reduce((acc, id) => {
1161
+ const count = acc[acc.length - 1] ?? 0;
1162
+ return [...acc, id === null ? count + 1 : count];
1163
+ }, []);
1164
+ const minted = mintCounts[mintCounts.length - 1] ?? 0;
1165
+ const ids = reused.map(
1166
+ (id, i) => id ?? `__zfa_${prev.counter + (mintCounts[i] ?? 0)}`
1167
+ );
1168
+ return { items: nextItems, ids, counter: prev.counter + minted };
1169
+ };
1170
+ function useFieldArray(form, pathArg) {
1171
+ const path = react$1.useStore(
1172
+ form.store,
1173
+ (state) => typeof pathArg === "function" ? pathArg(state) : pathArg
1174
+ );
1175
+ const slice = react$1.useStore(
1176
+ form.store,
1177
+ shallow.useShallow((state) => {
1178
+ const value = getAtPath(state.values, path);
1179
+ return {
1180
+ items: Array.isArray(value) ? value : EMPTY_ITEMS,
1181
+ error: state.errors[path]
1182
+ };
1183
+ })
1184
+ );
1185
+ const items = slice.items;
1186
+ const error = slice.error;
1187
+ const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
1188
+ const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1189
+ const nextIdState = reconcileIds(base, items);
1190
+ if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1191
+ setIdEntry({ form, path, state: nextIdState });
1192
+ }
1193
+ const ids = nextIdState.ids;
1194
+ const push = react.useCallback(
1195
+ (item) => form.arrayPush(path, item),
1196
+ [form, path]
1197
+ );
1198
+ const remove = react.useCallback(
1199
+ (index) => form.arrayRemove(path, index),
1200
+ [form, path]
1201
+ );
1202
+ const insert = react.useCallback(
1203
+ (index, item) => form.arrayInsert(path, index, item),
1204
+ [form, path]
1205
+ );
1206
+ const move = react.useCallback(
1207
+ (from, to) => form.arrayMove(path, from, to),
1208
+ [form, path]
1209
+ );
1210
+ const swap = react.useCallback(
1211
+ (a, b) => form.arraySwap(path, a, b),
1212
+ [form, path]
1213
+ );
1214
+ const fields = react.useMemo(
1215
+ () => items.map((value, index) => ({
1216
+ id: ids[index] ?? `__zfa_fallback_${index}`,
1217
+ value
1218
+ })),
1219
+ [items, ids]
1220
+ );
1221
+ return react.useMemo(
1222
+ () => ({
1223
+ fields,
1224
+ items,
1225
+ length: items.length,
1226
+ error,
1227
+ push,
1228
+ remove,
1229
+ insert,
1230
+ move,
1231
+ swap
1232
+ }),
1233
+ [fields, items, error, push, remove, insert, move, swap]
1234
+ );
1235
+ }
1236
+ var useFormError = (form) => react$1.useStore(
1237
+ form.store,
1238
+ shallow.useShallow((state) => state.errors[""])
1239
+ );
1240
+ function useIsDirty(form, path) {
1241
+ return react$1.useStore(
1242
+ form.store,
1243
+ (state) => path === void 0 || path === "" ? isFieldDirty(state.values, state.initialValues) : isFieldDirty(
1244
+ getAtPath(state.values, path),
1245
+ getAtPath(state.initialValues, path)
1246
+ )
1247
+ );
1248
+ }
1249
+ function useIsValid(form, path) {
1250
+ return react$1.useStore(form.store, (state) => {
1251
+ for (const k in state.errors) {
1252
+ const errs = state.errors[k];
1253
+ if (errs !== void 0 && errs.length > 0 && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1254
+ return false;
1255
+ }
1256
+ }
1257
+ return true;
1258
+ });
1259
+ }
1260
+ var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1261
+ var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1262
+ function useFormSelector(form, selector) {
1263
+ return react$1.useStore(form.store, selector);
1264
+ }
1265
+ function useFormSelectorShallow(form, selector) {
1266
+ return react$1.useStore(form.store, shallow.useShallow(selector));
1267
+ }
1268
+
1269
+ // src/react/createFormHooks.ts
1270
+ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase() + name.slice(1);
1271
+ var createFormHooks = (form, name) => {
1272
+ const structural = form;
1273
+ const useBoundField = (path, options) => useField(structural, path, options);
1274
+ const useBoundFieldArray = (path) => useFieldArray(structural, path);
1275
+ const useBoundSelector = (selector) => useFormSelector(structural, selector);
1276
+ const useBoundSelectorShallow = (selector) => useFormSelectorShallow(structural, selector);
1277
+ const useBoundError = () => useFormError(structural);
1278
+ const useBoundIsDirty = (path) => useIsDirty(structural, path);
1279
+ const useBoundIsValid = (path) => useIsValid(structural, path);
1280
+ const useBoundIsSubmitting = () => useIsSubmitting(structural);
1281
+ const useBoundSubmitCount = () => useSubmitCount(structural);
1282
+ const prefix = capitalize(name ?? "");
1283
+ return {
1284
+ [`use${prefix}Field`]: useBoundField,
1285
+ [`use${prefix}FieldArray`]: useBoundFieldArray,
1286
+ [`use${prefix}Selector`]: useBoundSelector,
1287
+ [`use${prefix}SelectorShallow`]: useBoundSelectorShallow,
1288
+ [`use${prefix}Error`]: useBoundError,
1289
+ [`use${prefix}IsDirty`]: useBoundIsDirty,
1290
+ [`use${prefix}IsValid`]: useBoundIsValid,
1291
+ [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1292
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount
1293
+ };
1294
+ };
1149
1295
 
1150
1296
  // src/react/inputProps.ts
1151
1297
  var ariaInvalid = (field) => field.error !== void 0 && field.error.length > 0 ? true : void 0;
@@ -1336,7 +1482,8 @@ var SelectField = ({
1336
1482
  const id = react.useId();
1337
1483
  const errorId = `${id}-error`;
1338
1484
  const field = useField(form, path);
1339
- const showEmptyOption = field.value === void 0 || field.value === null || placeholder !== void 0;
1485
+ const clearable = field.emptyValue === null;
1486
+ const showEmptyOption = clearable || field.value === void 0 || field.value === null || placeholder !== void 0;
1340
1487
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1341
1488
  label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1342
1489
  /* @__PURE__ */ jsxRuntime.jsxs(
@@ -1347,7 +1494,7 @@ var SelectField = ({
1347
1494
  ...selectProps(field),
1348
1495
  "aria-describedby": hasError(field.error) ? errorId : void 0,
1349
1496
  children: [
1350
- showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: placeholder ?? "" }) : null,
1497
+ showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1351
1498
  options.map((opt, index) => (
1352
1499
  // Index-qualified: option lists may legitimately repeat a value
1353
1500
  // (e.g. differently-labelled aliases), and bare value keys would
@@ -1400,110 +1547,6 @@ var focusField = (path, root) => {
1400
1547
  const controls = focusableControls(root ?? document);
1401
1548
  return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
1402
1549
  };
1403
- var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1404
- var EMPTY_ITEMS = [];
1405
- var reconcileIds = (prev, nextItems) => {
1406
- if (prev.items === nextItems) return prev;
1407
- if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1408
- return prev;
1409
- }
1410
- const buckets = prev.items.reduce((acc, item, i) => {
1411
- const bucket = acc.get(item);
1412
- if (bucket === void 0) {
1413
- acc.set(item, [i]);
1414
- } else {
1415
- bucket.push(i);
1416
- }
1417
- return acc;
1418
- }, /* @__PURE__ */ new Map());
1419
- const matchedIds = nextItems.map((item) => {
1420
- const prevIndex = buckets.get(item)?.shift();
1421
- return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
1422
- });
1423
- const leftover = [...buckets.values()].flat().sort((a, b) => a - b).map((i) => prev.ids[i]).filter((id) => id !== void 0);
1424
- const ordinals = matchedIds.reduce((acc, id) => {
1425
- const count = acc.length === 0 ? 0 : acc[acc.length - 1] ?? 0;
1426
- acc.push(id === null ? count + 1 : count);
1427
- return acc;
1428
- }, []);
1429
- const unmatched = ordinals.length === 0 ? 0 : ordinals[ordinals.length - 1] ?? 0;
1430
- const ids = matchedIds.map((id, i) => {
1431
- if (id !== null) return id;
1432
- const ordinal = ordinals[i] ?? 0;
1433
- return leftover[ordinal - 1] ?? `__zfa_${prev.counter + (ordinal - leftover.length)}`;
1434
- });
1435
- return {
1436
- items: nextItems,
1437
- ids,
1438
- counter: prev.counter + Math.max(0, unmatched - leftover.length)
1439
- };
1440
- };
1441
- function useFieldArray(form, pathArg) {
1442
- const path = react$1.useStore(
1443
- form.store,
1444
- (state) => typeof pathArg === "function" ? pathArg(state) : pathArg
1445
- );
1446
- const slice = react$1.useStore(
1447
- form.store,
1448
- shallow.useShallow((state) => {
1449
- const value = getAtPath(state.values, path);
1450
- return {
1451
- items: Array.isArray(value) ? value : EMPTY_ITEMS,
1452
- error: state.errors[path]
1453
- };
1454
- })
1455
- );
1456
- const items = slice.items;
1457
- const error = slice.error;
1458
- const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
1459
- const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1460
- const nextIdState = reconcileIds(base, items);
1461
- if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1462
- setIdEntry({ form, path, state: nextIdState });
1463
- }
1464
- const ids = nextIdState.ids;
1465
- const push = react.useCallback(
1466
- (item) => form.arrayPush(path, item),
1467
- [form, path]
1468
- );
1469
- const remove = react.useCallback(
1470
- (index) => form.arrayRemove(path, index),
1471
- [form, path]
1472
- );
1473
- const insert = react.useCallback(
1474
- (index, item) => form.arrayInsert(path, index, item),
1475
- [form, path]
1476
- );
1477
- const move = react.useCallback(
1478
- (from, to) => form.arrayMove(path, from, to),
1479
- [form, path]
1480
- );
1481
- const swap = react.useCallback(
1482
- (a, b) => form.arraySwap(path, a, b),
1483
- [form, path]
1484
- );
1485
- const fields = react.useMemo(
1486
- () => items.map((value, index) => ({
1487
- id: ids[index] ?? `__zfa_fallback_${index}`,
1488
- value
1489
- })),
1490
- [items, ids]
1491
- );
1492
- return react.useMemo(
1493
- () => ({
1494
- fields,
1495
- items,
1496
- length: items.length,
1497
- error,
1498
- push,
1499
- remove,
1500
- insert,
1501
- move,
1502
- swap
1503
- }),
1504
- [fields, items, error, push, remove, insert, move, swap]
1505
- );
1506
- }
1507
1550
 
1508
1551
  exports.CheckboxField = CheckboxField;
1509
1552
  exports.NumberField = NumberField;
@@ -1512,6 +1555,7 @@ exports.TextField = TextField;
1512
1555
  exports.checkboxProps = checkboxProps;
1513
1556
  exports.createForm = createForm;
1514
1557
  exports.createFormContext = createFormContext;
1558
+ exports.createFormHooks = createFormHooks;
1515
1559
  exports.emptyValueForSchema = emptyValueForSchema;
1516
1560
  exports.flattenIssues = flattenIssues;
1517
1561
  exports.focusField = focusField;