@timeax/form-palette 0.0.1

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.
Files changed (109) hide show
  1. package/.scaffold-cache.json +537 -0
  2. package/package.json +42 -0
  3. package/src/.scaffold-cache.json +544 -0
  4. package/src/adapters/axios.ts +117 -0
  5. package/src/adapters/index.ts +91 -0
  6. package/src/adapters/inertia.ts +187 -0
  7. package/src/core/adapter-registry.ts +87 -0
  8. package/src/core/bound/bind-host.ts +14 -0
  9. package/src/core/bound/observe-bound-field.ts +172 -0
  10. package/src/core/bound/wait-for-bound-field.ts +57 -0
  11. package/src/core/context.ts +23 -0
  12. package/src/core/core-provider.tsx +818 -0
  13. package/src/core/core-root.tsx +72 -0
  14. package/src/core/core-shell.tsx +44 -0
  15. package/src/core/errors/error-strip.tsx +71 -0
  16. package/src/core/errors/index.ts +2 -0
  17. package/src/core/errors/map-error-bag.ts +51 -0
  18. package/src/core/errors/map-zod.ts +39 -0
  19. package/src/core/hooks/use-button.ts +220 -0
  20. package/src/core/hooks/use-core-context.ts +20 -0
  21. package/src/core/hooks/use-core-utility.ts +0 -0
  22. package/src/core/hooks/use-core.ts +13 -0
  23. package/src/core/hooks/use-field.ts +497 -0
  24. package/src/core/hooks/use-optional-field.ts +28 -0
  25. package/src/core/index.ts +0 -0
  26. package/src/core/registry/binder-registry.ts +82 -0
  27. package/src/core/registry/field-registry.ts +187 -0
  28. package/src/core/test.tsx +17 -0
  29. package/src/global.d.ts +14 -0
  30. package/src/index.ts +68 -0
  31. package/src/input/index.ts +4 -0
  32. package/src/input/input-field.tsx +854 -0
  33. package/src/input/input-layout-graph.ts +230 -0
  34. package/src/input/input-props.ts +190 -0
  35. package/src/lib/get-global-countries.ts +87 -0
  36. package/src/lib/utils.ts +6 -0
  37. package/src/presets/index.ts +0 -0
  38. package/src/presets/shadcn-preset.ts +0 -0
  39. package/src/presets/shadcn-variants/checkbox.tsx +849 -0
  40. package/src/presets/shadcn-variants/chips.tsx +756 -0
  41. package/src/presets/shadcn-variants/color.tsx +284 -0
  42. package/src/presets/shadcn-variants/custom.tsx +227 -0
  43. package/src/presets/shadcn-variants/date.tsx +796 -0
  44. package/src/presets/shadcn-variants/file.tsx +764 -0
  45. package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
  46. package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
  47. package/src/presets/shadcn-variants/number.tsx +176 -0
  48. package/src/presets/shadcn-variants/password.tsx +737 -0
  49. package/src/presets/shadcn-variants/phone.tsx +628 -0
  50. package/src/presets/shadcn-variants/radio.tsx +578 -0
  51. package/src/presets/shadcn-variants/select.tsx +956 -0
  52. package/src/presets/shadcn-variants/slider.tsx +622 -0
  53. package/src/presets/shadcn-variants/text.tsx +343 -0
  54. package/src/presets/shadcn-variants/textarea.tsx +66 -0
  55. package/src/presets/shadcn-variants/toggle.tsx +218 -0
  56. package/src/presets/shadcn-variants/treeselect.tsx +784 -0
  57. package/src/presets/ui/badge.tsx +46 -0
  58. package/src/presets/ui/button.tsx +60 -0
  59. package/src/presets/ui/calendar.tsx +214 -0
  60. package/src/presets/ui/checkbox.tsx +115 -0
  61. package/src/presets/ui/custom.tsx +0 -0
  62. package/src/presets/ui/dialog.tsx +141 -0
  63. package/src/presets/ui/field.tsx +246 -0
  64. package/src/presets/ui/input-mask.tsx +739 -0
  65. package/src/presets/ui/input-otp.tsx +77 -0
  66. package/src/presets/ui/input.tsx +1011 -0
  67. package/src/presets/ui/label.tsx +22 -0
  68. package/src/presets/ui/number.tsx +1370 -0
  69. package/src/presets/ui/popover.tsx +46 -0
  70. package/src/presets/ui/radio-group.tsx +43 -0
  71. package/src/presets/ui/scroll-area.tsx +56 -0
  72. package/src/presets/ui/select.tsx +190 -0
  73. package/src/presets/ui/separator.tsx +28 -0
  74. package/src/presets/ui/slider.tsx +61 -0
  75. package/src/presets/ui/switch.tsx +32 -0
  76. package/src/presets/ui/textarea.tsx +634 -0
  77. package/src/presets/ui/time-dropdowns.tsx +350 -0
  78. package/src/schema/adapter.ts +217 -0
  79. package/src/schema/core.ts +429 -0
  80. package/src/schema/field-map.ts +0 -0
  81. package/src/schema/field.ts +224 -0
  82. package/src/schema/index.ts +0 -0
  83. package/src/schema/input-field.ts +260 -0
  84. package/src/schema/presets.ts +0 -0
  85. package/src/schema/variant.ts +216 -0
  86. package/src/variants/core/checkbox.tsx +54 -0
  87. package/src/variants/core/chips.tsx +22 -0
  88. package/src/variants/core/color.tsx +16 -0
  89. package/src/variants/core/custom.tsx +18 -0
  90. package/src/variants/core/date.tsx +25 -0
  91. package/src/variants/core/file.tsx +9 -0
  92. package/src/variants/core/keyvalue.tsx +12 -0
  93. package/src/variants/core/multiselect.tsx +28 -0
  94. package/src/variants/core/number.tsx +115 -0
  95. package/src/variants/core/password.tsx +35 -0
  96. package/src/variants/core/phone.tsx +16 -0
  97. package/src/variants/core/radio.tsx +38 -0
  98. package/src/variants/core/select.tsx +15 -0
  99. package/src/variants/core/slider.tsx +55 -0
  100. package/src/variants/core/text.tsx +114 -0
  101. package/src/variants/core/textarea.tsx +22 -0
  102. package/src/variants/core/toggle.tsx +50 -0
  103. package/src/variants/core/treeselect.tsx +11 -0
  104. package/src/variants/helpers/selection-summary.tsx +236 -0
  105. package/src/variants/index.ts +75 -0
  106. package/src/variants/registry.ts +38 -0
  107. package/src/variants/select-shared.ts +0 -0
  108. package/src/variants/shared.ts +126 -0
  109. package/tsconfig.json +14 -0
@@ -0,0 +1,818 @@
1
+ // src/core/core-provider.tsx
2
+ // noinspection JSConstantReassignment,JSUnusedGlobalSymbols,GrazieInspection
3
+
4
+ import * as React from "react";
5
+
6
+ import { CoreContextReact } from "@/core/context";
7
+ import { mapZodError } from "@/core/errors/map-zod";
8
+ import { mapErrorBag } from "@/core/errors/map-error-bag";
9
+ import { getAdapter, localAdapter } from "@/core/adapter-registry";
10
+ import { FieldRegistry } from "@/core/registry/field-registry";
11
+
12
+ import type { z } from "zod";
13
+ import type { AdapterKey, AdapterResult, Method } from "@/schema/adapter";
14
+ import type {
15
+ CoreContext,
16
+ CoreProps,
17
+ Dict,
18
+ InferFromSchema,
19
+ SubmitEvent,
20
+ ValuesResult,
21
+ } from "@/schema/core";
22
+ import type { ButtonRef, Field } from "@/schema/field";
23
+
24
+ type Props<
25
+ V extends Dict,
26
+ S extends z.ZodType | undefined,
27
+ K extends AdapterKey,
28
+ > = CoreProps<V, S, K> & {
29
+ children?: React.ReactNode;
30
+ };
31
+
32
+ // ─────────────────────────────────────────────────────────────
33
+ // Internal helpers (generic utils)
34
+ // ─────────────────────────────────────────────────────────────
35
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ function deepEqual(a: unknown, b: unknown): boolean {
40
+ if (a === b) return true;
41
+
42
+ // NaN === NaN
43
+ if (typeof a === "number" && typeof b === "number") {
44
+ if (Number.isNaN(a) && Number.isNaN(b)) return true;
45
+ }
46
+
47
+ if (Array.isArray(a) && Array.isArray(b)) {
48
+ if (a.length !== b.length) return false;
49
+ for (let i = 0; i < a.length; i++) {
50
+ if (!deepEqual(a[i], b[i])) return false;
51
+ }
52
+ return true;
53
+ }
54
+
55
+ if (isPlainObject(a) && isPlainObject(b)) {
56
+ const aKeys = Object.keys(a);
57
+ const bKeys = Object.keys(b);
58
+ if (aKeys.length !== bKeys.length) return false;
59
+ for (const key of aKeys) {
60
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
61
+ if (!deepEqual((a as any)[key], (b as any)[key])) return false;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ return false;
67
+ }
68
+ // ─────────────────────────────────────────────────────────────
69
+ // CoreProvider
70
+ // ─────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * CoreProvider: owns the form/core runtime state and implements CoreContext.
74
+ *
75
+ * - Tracks all inputs in a single store (inputsRef)
76
+ * - Supports:
77
+ * - named inputs via `name`
78
+ * - bound inputs via `bindId`
79
+ * - grouped inputs via `groupId`
80
+ * - Manages errors + uncaught messages
81
+ * - Builds values snapshots (including bucket values)
82
+ * - Orchestrates submission via the adapter registry
83
+ */
84
+ export function CoreProvider<
85
+ V extends Dict,
86
+ S extends z.ZodType | undefined,
87
+ K extends AdapterKey = "local",
88
+ >(props: Props<V, S, K>) {
89
+ type Values = InferFromSchema<S, V>;
90
+
91
+ // Single input store: FieldRegistry
92
+ const registryRef = React.useRef<FieldRegistry>(new FieldRegistry());
93
+
94
+ // bucket, errors, button
95
+ const bucketRef = React.useRef<Dict>({});
96
+ const uncaughtRef = React.useRef<string[]>([]);
97
+ const buttonRef = React.useRef<ButtonRef | null>(null);
98
+ const activeButtonNameRef = React.useRef<string | null>(null);
99
+
100
+ /**
101
+ * Original snapshot used for "dirty" checks.
102
+ * Lazily captured on first dirty-check.
103
+ */
104
+ const originalRef = React.useRef<Values | null>(null);
105
+
106
+ // latest props
107
+ const propsRef = React.useRef(props);
108
+ React.useEffect(() => {
109
+ propsRef.current = props;
110
+ }, [props]);
111
+
112
+ const adapterKey = (props.adapter ?? "local") as AdapterKey;
113
+ const schema = props.schema;
114
+
115
+ let context!: CoreContext<Values>;
116
+
117
+ // ─────────────────────────────────────────────────────────
118
+ // Common helpers
119
+ // ─────────────────────────────────────────────────────────
120
+
121
+ function fetchAllNamedFields(): Field[] {
122
+ return registryRef.current.getAllNamed();
123
+ }
124
+
125
+ function clearFieldErrors() {
126
+ for (const field of fetchAllNamedFields()) {
127
+ const anyField = field as any;
128
+ if (typeof anyField.setError === "function") {
129
+ anyField.setError(undefined);
130
+ } else if ("error" in anyField) {
131
+ anyField.error = undefined;
132
+ }
133
+ }
134
+ }
135
+
136
+ function findFieldForErrorKey(key: string): Field | undefined {
137
+ if (!key) return undefined;
138
+ return fetchAllNamedFields().find((f) => {
139
+ const raw = f.name;
140
+ if (!raw) return false;
141
+ const trimmed = raw.trim();
142
+ if (!trimmed) return false;
143
+
144
+ const base = trimmed.replace(/\[]$/, "");
145
+ if (key === base || key === trimmed) return true;
146
+
147
+ const sharedKey = (f as any).shared as string | undefined;
148
+ if (!sharedKey) return false;
149
+
150
+ const sharedBase = `${sharedKey}.${base}`;
151
+ const sharedRaw = `${sharedKey}.${trimmed}`;
152
+ return key === sharedBase || key === sharedRaw;
153
+ });
154
+ }
155
+
156
+ function setFieldError(name: string, message: string) {
157
+ const field = findFieldForErrorKey(name);
158
+ if (field) {
159
+ const anyField = field as any;
160
+ if (typeof anyField.setError === "function") {
161
+ anyField.setError(message);
162
+ } else {
163
+ anyField.error = message;
164
+ }
165
+ } else {
166
+ uncaughtRef.current.push(message);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Collect values from inputs into a Values object.
172
+ *
173
+ * Semantics:
174
+ * - `name="tags[]"` ⇒ `values.tags: unknown[]`
175
+ * - `shared="profile", name="first_name"` ⇒ `values.profile.first_name`
176
+ * - bucketRef.current is merged in and overridden by live field values.
177
+ * - `exceptions` can hide keys (e.g. ["password", "profile.ssn"])
178
+ */
179
+ function collectValues(): Values {
180
+ const exceptions = propsRef.current.exceptions ?? [];
181
+ const list: Dict = {};
182
+ const shared: Dict<Dict> = {};
183
+
184
+ for (const item of fetchAllNamedFields()) {
185
+ const rawName = item.name;
186
+ if (!rawName) continue;
187
+
188
+ const trimmed = rawName.trim();
189
+ if (!trimmed) continue;
190
+
191
+ const isArray = trimmed.endsWith("[]");
192
+ const base = trimmed.replace(/\[]$/, "");
193
+ const sharedKey = (item as any).shared as string | undefined;
194
+
195
+ const target = sharedKey
196
+ ? (shared[sharedKey] ?? (shared[sharedKey] = {}))
197
+ : list;
198
+
199
+ const fullPath = sharedKey ? `${sharedKey}.${base}` : base;
200
+ if (
201
+ exceptions.includes(trimmed) ||
202
+ exceptions.includes(base) ||
203
+ exceptions.includes(fullPath)
204
+ ) {
205
+ continue;
206
+ }
207
+
208
+ const anyField = item as any;
209
+ const val =
210
+ typeof anyField.getValue === "function"
211
+ ? (anyField.getValue() as unknown)
212
+ : (anyField.value as unknown);
213
+
214
+ if (isArray) {
215
+ const existing = target[base];
216
+ if (Array.isArray(existing)) {
217
+ target[base] = [...existing, val];
218
+ } else if (typeof existing === "undefined") {
219
+ target[base] = [val];
220
+ } else {
221
+ target[base] = [existing, val];
222
+ }
223
+ } else {
224
+ target[base] = val;
225
+ }
226
+ }
227
+
228
+ const fromFields: Dict = { ...list, ...shared };
229
+ const merged: Dict = {
230
+ ...bucketRef.current,
231
+ ...fromFields,
232
+ };
233
+
234
+ return merged as Values;
235
+ }
236
+
237
+ function validateInternal(report: boolean = false): boolean {
238
+ let valid = true;
239
+
240
+ if (report) {
241
+ uncaughtRef.current = [];
242
+ clearFieldErrors();
243
+ }
244
+
245
+ // field-level
246
+ for (const field of fetchAllNamedFields()) {
247
+ const anyField = field as any;
248
+ if (typeof anyField.validate === "function") {
249
+ const ok = anyField.validate(report);
250
+ if (!ok) valid = false;
251
+ }
252
+ }
253
+
254
+ // schema-level
255
+ if (schema) {
256
+ try {
257
+ schema.parse(collectValues());
258
+ } catch (err: unknown) {
259
+ valid = false;
260
+
261
+ if (report && err && typeof err === "object") {
262
+ const anyErr = err as any;
263
+ if (anyErr.issues) {
264
+ const { fieldErrors, uncaught } = mapZodError(anyErr);
265
+ for (const [name, message] of Object.entries(
266
+ fieldErrors
267
+ )) {
268
+ setFieldError(name, message);
269
+ }
270
+ if (uncaught.length) {
271
+ uncaughtRef.current.push(...uncaught);
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ return valid;
279
+ }
280
+
281
+ // ─────────────────────────────────────────────────────────
282
+ // Submission
283
+ // ─────────────────────────────────────────────────────────
284
+
285
+ async function submitWithAdapter(
286
+ method: Method,
287
+ route: string,
288
+ extra?: Partial<Values>,
289
+ ignoreForm?: boolean,
290
+ autoErr: boolean = true,
291
+ autoRun: boolean = true
292
+ ): Promise<AdapterResult<any> | undefined> {
293
+ const currentProps = propsRef.current;
294
+
295
+ // active button + loading
296
+ const btn = buttonRef.current as any;
297
+ const activeName = activeButtonNameRef.current;
298
+ const isActiveButton =
299
+ !!btn && typeof btn === "object" && btn.name === activeName;
300
+
301
+ const setButtonLoading = (loading: boolean) => {
302
+ if (!isActiveButton) return;
303
+ if (typeof btn.setLoading === "function") {
304
+ btn.setLoading(loading);
305
+ } else if ("loading" in btn) {
306
+ btn.loading = loading;
307
+ }
308
+ };
309
+
310
+ setButtonLoading(true);
311
+
312
+ let finished = false;
313
+ const finish = () => {
314
+ if (finished) return;
315
+ finished = true;
316
+ setButtonLoading(false);
317
+ };
318
+
319
+ if (!ignoreForm) {
320
+ const ok = validateInternal(true);
321
+ if (!ok) {
322
+ finish();
323
+ return undefined;
324
+ }
325
+ }
326
+
327
+ let submissionValues: Values = {
328
+ ...collectValues(),
329
+ ...(extra ?? {}),
330
+ };
331
+
332
+ const event: SubmitEvent<Values> = {
333
+ preventDefault() {
334
+ this.continue = false;
335
+ },
336
+ editData(cb) {
337
+ const result = cb(submissionValues);
338
+ if (result) {
339
+ submissionValues = result;
340
+ }
341
+ },
342
+ setRoute(newRoute: string) {
343
+ route = newRoute;
344
+ },
345
+ setMethod(newMethod: Method) {
346
+ method = newMethod;
347
+ },
348
+
349
+ button: buttonRef.current ?? undefined,
350
+ get formData() {
351
+ return submissionValues;
352
+ },
353
+
354
+ form: context,
355
+ continue: true,
356
+ };
357
+
358
+ if (currentProps.onSubmit) {
359
+ try {
360
+ await currentProps.onSubmit(event as any);
361
+ } catch (err) {
362
+ // host blew up: end this submit cycle
363
+ finish();
364
+ throw err;
365
+ }
366
+ }
367
+
368
+ if (!event.continue) {
369
+ finish();
370
+ return undefined;
371
+ }
372
+
373
+ const factory =
374
+ getAdapter(adapterKey) ??
375
+ (localAdapter as unknown as (cfg: any) => AdapterResult<any>);
376
+
377
+ const adapter = factory({
378
+ method,
379
+ url: route,
380
+ data: submissionValues,
381
+ callbacks: {
382
+ onSuccess(ok: unknown) {
383
+ const maybe = propsRef.current.onSubmitted;
384
+ if (maybe) {
385
+ void maybe(context, ok as any, () => {
386
+ finish();
387
+ });
388
+ }
389
+ },
390
+ onError(err: unknown) {
391
+ if (!autoErr || !err || typeof err !== "object") {
392
+ return;
393
+ }
394
+
395
+ const anyErr = err as any;
396
+ if (anyErr.errors && typeof anyErr.errors === "object") {
397
+ const { fieldErrors, uncaught } = mapErrorBag(
398
+ anyErr.errors
399
+ );
400
+ for (const [name, message] of Object.entries(
401
+ fieldErrors
402
+ )) {
403
+ setFieldError(name, message);
404
+ }
405
+ if (uncaught.length) {
406
+ uncaughtRef.current.push(...uncaught);
407
+ }
408
+ }
409
+ },
410
+ onFinish() {
411
+ const maybe = propsRef.current.onFinish;
412
+ if (maybe) {
413
+ maybe(context);
414
+ }
415
+ finish();
416
+ },
417
+ },
418
+ });
419
+
420
+ if (autoRun) {
421
+ try {
422
+ await adapter.send();
423
+ } catch {
424
+ // errors flow via callbacks; adapter may still call onFinish
425
+ }
426
+ }
427
+
428
+ return adapter;
429
+ }
430
+
431
+ // No separate inputs view: expose registry directly via context.inputs
432
+
433
+ // ─────────────────────────────────────────────────────────
434
+ // CoreContext implementation
435
+ // ─────────────────────────────────────────────────────────
436
+
437
+ context = {
438
+ values(): Values {
439
+ return collectValues();
440
+ },
441
+
442
+ submit(): ValuesResult<Values> {
443
+ const valid = validateInternal(true);
444
+ const vals = collectValues();
445
+ return { values: vals, valid };
446
+ },
447
+
448
+ getBind(id: string): Field | undefined {
449
+ return registryRef.current.getByBind(id);
450
+ },
451
+
452
+ validate(report?: boolean): boolean {
453
+ return validateInternal(report);
454
+ },
455
+
456
+ addField(field: Field): void {
457
+ // Normalise name
458
+ const rawName = field.name ?? "";
459
+ (field as any).name = rawName.trim();
460
+
461
+ // hydrate from valueBag before registering
462
+ const { valueBag, valueFeed } = propsRef.current;
463
+ const trimmed = (field.name ?? "").trim();
464
+ const hasName = !!trimmed;
465
+ const isArray = hasName && trimmed.endsWith("[]");
466
+ const base = hasName ? trimmed.replace(/\[]$/, "") : "";
467
+ const sharedKey = (field as any).shared as string | undefined;
468
+
469
+ if (valueBag && !(field as any).ignore && hasName) {
470
+ const sourceRoot: any =
471
+ sharedKey && (valueBag as any)[sharedKey]
472
+ ? (valueBag as any)[sharedKey]
473
+ : valueBag;
474
+
475
+ let value: unknown = undefined;
476
+
477
+ if (sourceRoot && typeof sourceRoot === "object") {
478
+ if (isArray && Array.isArray(sourceRoot[base])) {
479
+ const siblings = fetchAllNamedFields().filter((f) => {
480
+ const rn = (f.name ?? "").trim();
481
+ return (
482
+ rn === trimmed &&
483
+ ((f as any).shared as string | undefined) ===
484
+ sharedKey
485
+ );
486
+ });
487
+ const idx = siblings.length;
488
+ value = (sourceRoot[base] as unknown[])[idx];
489
+ } else {
490
+ value = sourceRoot[base];
491
+ }
492
+ }
493
+
494
+ let hydrated: unknown = value;
495
+ if (valueFeed) {
496
+ const maybe = valueFeed(
497
+ base as keyof Values,
498
+ value as any,
499
+ context as any
500
+ );
501
+ if (typeof maybe !== "undefined") {
502
+ hydrated = maybe;
503
+ }
504
+ }
505
+
506
+ if (typeof hydrated !== "undefined") {
507
+ const anyField = field as any;
508
+ if (typeof anyField.setValue === "function") {
509
+ anyField.setValue(hydrated);
510
+ } else {
511
+ anyField.value = hydrated;
512
+ }
513
+ }
514
+ }
515
+
516
+ // finally register into the single store (name/bindId/groupId)
517
+ registryRef.current.add(field);
518
+ },
519
+
520
+ // Expose registry view as inputs (delegates to FieldRegistry instance)
521
+ inputs: registryRef.current,
522
+
523
+ // Also expose raw list of fields for compatibility is defined later as a getter
524
+
525
+ bucket: bucketRef.current,
526
+
527
+ error(
528
+ nameOrBag: string | Record<string, string>,
529
+ maybeMsg?: string
530
+ ): void {
531
+ if (typeof nameOrBag === "string") {
532
+ if (!maybeMsg) return;
533
+ setFieldError(nameOrBag, maybeMsg);
534
+ return;
535
+ }
536
+
537
+ const { fieldErrors, uncaught } = mapErrorBag(nameOrBag);
538
+ for (const [name, message] of Object.entries(fieldErrors)) {
539
+ setFieldError(name, message);
540
+ }
541
+ if (uncaught.length) {
542
+ uncaughtRef.current.push(...uncaught);
543
+ }
544
+ },
545
+
546
+ controlButton(): void {
547
+ const { activateButtonOnChange } = propsRef.current;
548
+ if (!activateButtonOnChange) return;
549
+
550
+ const btn = buttonRef.current as any;
551
+ const activeName = activeButtonNameRef.current;
552
+
553
+ // If there is no active button or it doesn't match, nothing to control.
554
+ if (!btn || btn.name !== activeName) {
555
+ return;
556
+ }
557
+
558
+ // Capture original snapshot lazily.
559
+ if (!originalRef.current) {
560
+ originalRef.current = collectValues();
561
+ }
562
+
563
+ const current = collectValues();
564
+ const original = originalRef.current!;
565
+
566
+ const dirty = !deepEqual(original, current);
567
+
568
+ const setDisabled = (disabled: boolean) => {
569
+ if (typeof btn.setDisabled === "function") {
570
+ btn.setDisabled(disabled);
571
+ } else if ("disabled" in btn) {
572
+ btn.disabled = disabled;
573
+ }
574
+ };
575
+
576
+ // Dirty ⇒ enable button, clean ⇒ disable button
577
+ setDisabled(!dirty);
578
+ },
579
+
580
+ isDirty() {
581
+ if (!originalRef.current) {
582
+ originalRef.current = collectValues();
583
+ }
584
+
585
+ const current = collectValues();
586
+ const original = originalRef.current!;
587
+
588
+ return !deepEqual(original, current);
589
+ },
590
+
591
+ async prepare(
592
+ type: Method,
593
+ route: string,
594
+ extra?: Partial<Values>,
595
+ ignoreForm?: boolean,
596
+ autoErr?: boolean
597
+ ): Promise<AdapterResult<any> | undefined> {
598
+ return submitWithAdapter(
599
+ type,
600
+ route,
601
+ extra,
602
+ ignoreForm,
603
+ autoErr,
604
+ false
605
+ );
606
+ },
607
+
608
+ persist(
609
+ data: Partial<Values>,
610
+ feed?: (name: string, value: unknown, original: unknown) => unknown
611
+ ): void {
612
+ const seen: Record<string, number> = {};
613
+ const root = data as any;
614
+
615
+ const useFeed =
616
+ feed ||
617
+ (propsRef.current.valueFeed
618
+ ? (
619
+ name: string,
620
+ value: unknown,
621
+ original: unknown
622
+ ): unknown => {
623
+ const vf = propsRef.current.valueFeed!;
624
+ const maybe = vf(
625
+ name as keyof Values,
626
+ value as any,
627
+ context as any
628
+ );
629
+ return typeof maybe === "undefined"
630
+ ? original
631
+ : maybe;
632
+ }
633
+ : undefined);
634
+
635
+ for (const field of fetchAllNamedFields()) {
636
+ const rawName = field.name;
637
+ if (!rawName) continue;
638
+ if ((field as any).ignore) continue;
639
+
640
+ const trimmed = rawName.trim();
641
+ if (!trimmed) continue;
642
+
643
+ const isArray = trimmed.endsWith("[]");
644
+ const base = trimmed.replace(/\[]$/, "");
645
+ const sharedKey = (field as any).shared as string | undefined;
646
+ const key = sharedKey ? `${sharedKey}.${base}` : base;
647
+
648
+ let value: unknown = undefined;
649
+
650
+ if (sharedKey) {
651
+ const group = root[sharedKey];
652
+ if (group && typeof group === "object") {
653
+ if (isArray && Array.isArray(group[base])) {
654
+ const idx = seen[key] ?? 0;
655
+ value = (group[base] as unknown[])[idx];
656
+ seen[key] = idx + 1;
657
+ } else {
658
+ value = group[base];
659
+ }
660
+ }
661
+ } else {
662
+ if (isArray && Array.isArray(root[base])) {
663
+ const idx = seen[key] ?? 0;
664
+ value = (root[base] as unknown[])[idx];
665
+ seen[key] = idx + 1;
666
+ } else {
667
+ value = root[base];
668
+ }
669
+ }
670
+
671
+ const anyField = field as any;
672
+ const original =
673
+ typeof anyField.getValue === "function"
674
+ ? anyField.getValue()
675
+ : anyField.value;
676
+
677
+ let next = value;
678
+ if (useFeed) {
679
+ const maybe = useFeed(base, value, original);
680
+ if (typeof maybe === "undefined") {
681
+ continue;
682
+ }
683
+ next = maybe;
684
+ }
685
+
686
+ if (typeof anyField.setValue === "function") {
687
+ anyField.setValue(next);
688
+ } else {
689
+ anyField.value = next;
690
+ }
691
+ }
692
+
693
+ if (propsRef.current.onUpdate) {
694
+ propsRef.current.onUpdate(collectValues());
695
+ }
696
+ },
697
+
698
+ setValue(name: string, value: unknown): void {
699
+ if (!name) return;
700
+
701
+ let sharedKey: string | undefined;
702
+ let base = name;
703
+
704
+ if (name.includes(".")) {
705
+ const [group, field] = name.split(".", 2);
706
+ sharedKey = group;
707
+ base = field;
708
+ }
709
+
710
+ const targetField = fetchAllNamedFields().find((f) => {
711
+ const raw = (f.name ?? "").trim();
712
+ if (!raw) return false;
713
+
714
+ const isArray = raw.endsWith("[]");
715
+ const rawBase = raw.replace(/\[]$/, "");
716
+ const fShared = (f as any).shared as string | undefined;
717
+
718
+ const sameGroup = fShared === sharedKey;
719
+ const sameName =
720
+ raw === name ||
721
+ rawBase === base ||
722
+ `${fShared}.${rawBase}` === name;
723
+
724
+ return (!sharedKey || sameGroup) && sameName && !isArray;
725
+ });
726
+
727
+ if (targetField) {
728
+ const anyField = targetField as any;
729
+ if (typeof anyField.setValue === "function") {
730
+ anyField.setValue(value);
731
+ } else {
732
+ anyField.value = value;
733
+ }
734
+ } else {
735
+ bucketRef.current[name] = value;
736
+ }
737
+
738
+ if (propsRef.current.onUpdate) {
739
+ propsRef.current.onUpdate(collectValues());
740
+ }
741
+ },
742
+
743
+ go(data?: Partial<Values>, ignoreForm?: boolean): void {
744
+ void submitWithAdapter("post", "", data, ignoreForm, true, true);
745
+ },
746
+
747
+ reset(inputs: string[]): void {
748
+ if (!inputs.length) return;
749
+
750
+ for (const field of fetchAllNamedFields()) {
751
+ const raw = field.name;
752
+ if (!raw) continue;
753
+ if (!inputs.includes(raw)) continue;
754
+
755
+ const anyField = field as any;
756
+ if (typeof anyField.reset === "function") {
757
+ anyField.reset();
758
+ } else if (typeof anyField.setValue === "function") {
759
+ anyField.setValue(undefined);
760
+ } else {
761
+ anyField.value = undefined;
762
+ }
763
+ }
764
+ },
765
+
766
+ set button(btn: ButtonRef) {
767
+ buttonRef.current = btn;
768
+ },
769
+
770
+ async forceSubmit(): Promise<void> {
771
+ await submitWithAdapter("post", "", undefined, false, true, true);
772
+ },
773
+
774
+ get fields(): Field[] {
775
+ return fetchAllNamedFields();
776
+ },
777
+
778
+ get props() {
779
+ const { formRef, valueBag, ...rest } = propsRef.current;
780
+ return rest as any;
781
+ },
782
+
783
+ setActiveButton(name: string): void {
784
+ activeButtonNameRef.current = name;
785
+ },
786
+
787
+ getUncaught(): readonly string[] {
788
+ return uncaughtRef.current;
789
+ },
790
+ } as CoreContext<Values>;
791
+
792
+ // formRef exposure
793
+ React.useEffect(() => {
794
+ if (!props.formRef) return;
795
+
796
+ props.formRef.current = context;
797
+ return () => {
798
+ if (props.formRef) {
799
+ props.formRef.current = null;
800
+ }
801
+ };
802
+ // eslint-disable-next-line react-hooks/exhaustive-deps
803
+ }, [context]);
804
+
805
+ // init hook once
806
+ React.useEffect(() => {
807
+ if (props.init) {
808
+ props.init(context);
809
+ }
810
+ // eslint-disable-next-line react-hooks/exhaustive-deps
811
+ }, []);
812
+
813
+ return (
814
+ <CoreContextReact.Provider value={context as any}>
815
+ {props.children}
816
+ </CoreContextReact.Provider>
817
+ );
818
+ }