@vobs/forms 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vobsjs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,296 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/forms
4
+ */
5
+ import { type ReadonlySignal, type WritableSignal } from '@vobs/reactivity';
6
+ import { type InjectionKey, type Owner } from '@vobs/runtime-core';
7
+ export type FormFieldType = 'checkbox' | 'custom' | 'date' | 'number' | 'select' | 'text' | 'textarea';
8
+ export type FormFieldRenderControl = 'checkbox' | 'custom' | 'input' | 'select' | 'textarea';
9
+ export interface FormOption<Value> {
10
+ readonly label: FormLabel;
11
+ readonly value: Value;
12
+ readonly disabled?: boolean;
13
+ }
14
+ /** Render options (after view-layer translation): label is display text (re-translated reactively). */
15
+ export interface FormRenderOption<Value> {
16
+ readonly label: string;
17
+ readonly value: Value;
18
+ readonly disabled?: boolean;
19
+ }
20
+ export interface FormFieldContext<Values extends FormValues> {
21
+ readonly values: Values;
22
+ }
23
+ /** Structured validation error params (aligned with TranslationAdapter.t params). */
24
+ export type FormErrorParams = Readonly<Record<string, string | number | boolean | null | undefined>>;
25
+ /**
26
+ * Structured validation error: `{ code, message?, params? }`.
27
+ * - `code`: stable identifier for i18n keys / automated assertions (independent of display text).
28
+ * - `message`: already-translated display text (optional; preferred over adapter translation when present).
29
+ * - `params`: interpolation params for `code` (`{name}` placeholders).
30
+ * A plain string is equivalent to `{ code: string, message: string }` for backward compatibility.
31
+ */
32
+ export interface FormErrorCode {
33
+ readonly code: string;
34
+ readonly message?: string;
35
+ readonly params?: FormErrorParams;
36
+ }
37
+ /** Field validation error: a string (translated text) or a structured error. */
38
+ export type FormError = string | FormErrorCode;
39
+ /**
40
+ * Field label: a static string or a structured translation descriptor (`{ code, message?, params? }`).
41
+ * `{ code }` is translated via TranslationAdapter (view-layer labelText re-translates reactively),
42
+ * isomorphic to FormError (reuses the FormErrorCode shape).
43
+ */
44
+ export type FormLabel = string | FormErrorCode;
45
+ /**
46
+ * Resolves a structured error into display text:
47
+ * - strings are returned as-is;
48
+ * - `{ code, message }` prefers `message`;
49
+ * - when `message` is absent and `translate` is provided, `code` is translated (params passed through);
50
+ * - when neither is available, falls back to the raw `code` (never throws or breaks rendering).
51
+ */
52
+ export declare function resolveFormError(error: FormError, translate?: (code: string, params?: FormErrorParams) => string): string;
53
+ /**
54
+ * Default error resolver (used when no TranslationAdapter is injected):
55
+ * - `{ code: 'forms.required', params: { label } }` degrades to default English `"{label} is required"`;
56
+ * - other `{code}` errors show the raw code (same semantics as resolveFormError).
57
+ * When an adapter is present, useForm's injected resolver takes precedence (`forms.required` is translated via the i18n catalog).
58
+ */
59
+ export declare function defaultResolveFormError(error: FormError): string;
60
+ export type FormRule<Value, Values extends FormValues> = (value: Value, context: FormFieldContext<Values>) => FormError | readonly FormError[] | undefined | Promise<FormError | readonly FormError[] | undefined>;
61
+ export type FormSwitch<Values extends FormValues> = boolean | ((context: FormFieldContext<Values>) => boolean);
62
+ export interface FormFieldSchema<Value, Values extends FormValues = FormValues> {
63
+ readonly label: FormLabel;
64
+ readonly defaultValue: Value;
65
+ readonly type?: FormFieldType;
66
+ readonly required?: FormSwitch<Values>;
67
+ readonly disabled?: FormSwitch<Values>;
68
+ readonly options?: readonly FormOption<Value>[];
69
+ readonly validate?: FormRule<Value, Values>;
70
+ }
71
+ export type FormFieldSchemaRecord = Readonly<Record<string, FormFieldSchema<unknown>>>;
72
+ export type FormFieldValue<T> = T extends string ? string : T extends number ? number : T extends boolean ? boolean : T;
73
+ export type FormValues<Fields extends FormFieldSchemaRecord = FormFieldSchemaRecord> = {
74
+ readonly [Name in keyof Fields]: FormFieldValue<Fields[Name]['defaultValue']>;
75
+ };
76
+ export type FormModels<Fields extends FormFieldSchemaRecord> = {
77
+ readonly [Name in keyof Fields]: WritableSignal<FormValues<Fields>[Name]>;
78
+ };
79
+ export type FormErrors<Fields extends FormFieldSchemaRecord> = {
80
+ readonly [Name in keyof Fields]: WritableSignal<readonly FormError[]>;
81
+ };
82
+ export type FormTouched<Fields extends FormFieldSchemaRecord> = {
83
+ readonly [Name in keyof Fields]: WritableSignal<boolean>;
84
+ };
85
+ export interface FormFieldController<Name extends string = string, Value = unknown> {
86
+ readonly name: Name;
87
+ readonly label: FormLabel;
88
+ readonly type: FormFieldType;
89
+ readonly model: WritableSignal<Value>;
90
+ readonly errors: WritableSignal<readonly FormError[]>;
91
+ readonly touched: WritableSignal<boolean>;
92
+ readonly required: ReadonlySignal<boolean>;
93
+ readonly disabled: ReadonlySignal<boolean>;
94
+ readonly options: readonly FormOption<Value>[];
95
+ validate(): Promise<readonly FormError[]>;
96
+ /** @internal Batch validation entry: receives a values snapshot to avoid per-field O(n) rebuild (used internally by validate()). */
97
+ validateWith?(values: FormValues): Promise<readonly FormError[]>;
98
+ }
99
+ export type FormFieldControllers<Fields extends FormFieldSchemaRecord> = {
100
+ readonly [Name in keyof Fields & string]: FormFieldController<Name, FormValues<Fields>[Name]>;
101
+ };
102
+ export interface FormSchema<Fields extends FormFieldSchemaRecord> {
103
+ readonly fields: Fields;
104
+ }
105
+ export interface FormMessages {
106
+ readonly required?: (label: string) => string;
107
+ }
108
+ /** Auto re-validation mode: submit (default, validates only on submit) / change (validates on field change, microtask-debounced) / blur (validates after view-layer touchField bridge). */
109
+ export type FormValidateOn = 'submit' | 'change' | 'blur';
110
+ export interface FormOptions {
111
+ readonly messages?: FormMessages;
112
+ /** Owner the form belongs to (default currentOwner, i.e. the page Owner inside page setup); form state is released when the Owner is disposed. */
113
+ readonly owner?: Owner;
114
+ /** Auto re-validation mode (default 'submit'). 'change' is driven by an internal effect watching field changes; 'blur' requires the view layer to call touchField. */
115
+ readonly validateOn?: FormValidateOn;
116
+ /**
117
+ * Field label resolver (default: strings pass through; `{code, message}` prefers message, falls back to code).
118
+ * useForm automatically supplies a resolver that translates code via TranslationAdapter; overridable.
119
+ * Used for the `{label}` interpolation param in required messages.
120
+ */
121
+ readonly resolveLabel?: (label: FormLabel) => string;
122
+ }
123
+ export interface FormInstance<Fields extends FormFieldSchemaRecord> {
124
+ readonly schema: FormSchema<Fields>;
125
+ readonly fields: FormFieldControllers<Fields>;
126
+ readonly items: readonly FormFieldControllers<Fields>[keyof FormFieldControllers<Fields>][];
127
+ readonly models: FormModels<Fields>;
128
+ readonly errors: FormErrors<Fields>;
129
+ readonly touched: FormTouched<Fields>;
130
+ readonly values: ReadonlySignal<FormValues<Fields>>;
131
+ readValues(): FormValues<Fields>;
132
+ setValues(values: Partial<FormValues<Fields>>): void;
133
+ reset(values?: Partial<FormValues<Fields>>): void;
134
+ validate(): Promise<boolean>;
135
+ validateField<Name extends keyof Fields & string>(name: Name): Promise<readonly FormError[]>;
136
+ /** Sets errors for a single field (e.g. server error write-back); marks it touched. Accepts strings or `{code, message?}` structured errors. */
137
+ setFieldErrors<Name extends keyof Fields & string>(name: Name, errors: readonly FormError[]): void;
138
+ /** Batch-writes server validation errors: field name → error array (strings or `{code}`). Server errors are not silently overwritten by later client validation until clearErrors()/reset(). */
139
+ applyServerErrors(errors: Partial<Record<keyof Fields & string, readonly FormError[]>>): void;
140
+ /** Clears all field errors (including server errors). Call after a successful submit. */
141
+ clearErrors(): void;
142
+ /** Marks a field as touched (bridged from view-layer blur events; validates afterwards when validateOn='blur'). */
143
+ touchField<Name extends keyof Fields & string>(name: Name): void;
144
+ /** Releases form state and subscriptions (idempotent). Called automatically on Owner disposal. */
145
+ dispose(): void;
146
+ }
147
+ export interface FormFieldRenderOptions {
148
+ readonly control?: FormFieldRenderControl;
149
+ readonly inputType?: string;
150
+ readonly slot?: string;
151
+ }
152
+ export interface FormRendererOptions<Fields extends FormFieldSchemaRecord> {
153
+ readonly fields?: Partial<Record<keyof Fields & string, FormFieldRenderOptions>>;
154
+ }
155
+ export type FormFieldRenderControlFor<Field extends FormFieldSchema<unknown>> = Field extends {
156
+ readonly type: 'checkbox';
157
+ } ? 'checkbox' : Field extends {
158
+ readonly type: 'custom';
159
+ } ? 'custom' : Field extends {
160
+ readonly type: 'select';
161
+ } ? 'select' : Field extends {
162
+ readonly type: 'textarea';
163
+ } ? 'textarea' : 'input';
164
+ export interface FormFieldRenderItem<Name extends string = string, Value = unknown, Control extends FormFieldRenderControl = FormFieldRenderControl> {
165
+ readonly name: Name;
166
+ readonly label: FormLabel;
167
+ readonly control: Control;
168
+ readonly inputType?: string;
169
+ readonly slot: string;
170
+ readonly field: FormFieldController<Name, Value>;
171
+ readonly model: WritableSignal<Value>;
172
+ readonly errors: WritableSignal<readonly FormError[]>;
173
+ readonly touched: WritableSignal<boolean>;
174
+ readonly required: ReadonlySignal<boolean>;
175
+ readonly disabled: ReadonlySignal<boolean>;
176
+ readonly options: readonly FormOption<Value>[];
177
+ }
178
+ export type FormFieldRenderItems<Fields extends FormFieldSchemaRecord> = {
179
+ readonly [Name in keyof Fields & string]: FormFieldRenderItem<Name, FormValues<Fields>[Name], FormFieldRenderControlFor<Fields[Name]>>;
180
+ };
181
+ export interface FormRenderer<Fields extends FormFieldSchemaRecord> {
182
+ readonly form: FormInstance<Fields>;
183
+ readonly fields: FormFieldRenderItems<Fields>;
184
+ readonly items: readonly FormFieldRenderItems<Fields>[keyof FormFieldRenderItems<Fields>][];
185
+ readonly errorSummary: ReadonlySignal<readonly FormError[]>;
186
+ submit(callback: (values: FormValues<Fields>) => void | Promise<void>): Promise<boolean>;
187
+ }
188
+ export interface FormViewOptions {
189
+ readonly id: string;
190
+ /**
191
+ * Resolves structured errors (`{code, message}`) into display text.
192
+ * Default: strings pass through; `{code, message}` prefers message, falls back to code.
193
+ * useForm automatically supplies a resolver that translates code via TranslationAdapter; overridable.
194
+ */
195
+ readonly resolveError?: (error: FormError) => string;
196
+ /**
197
+ * Field label resolver (default: same semantics as resolveError).
198
+ * useForm automatically supplies a resolver that translates code via TranslationAdapter; drives the labelText signal.
199
+ */
200
+ readonly resolveLabel?: (label: FormLabel) => string;
201
+ }
202
+ export interface FormErrorSummaryItem<Name extends string = string> {
203
+ readonly id: string;
204
+ readonly field: Name;
205
+ readonly controlId: string;
206
+ readonly href: string;
207
+ readonly message: string;
208
+ }
209
+ export interface FormFieldViewItem<Name extends string = string, Value = unknown, Control extends FormFieldRenderControl = FormFieldRenderControl> extends Omit<FormFieldRenderItem<Name, Value, Control>, 'options'> {
210
+ readonly id: string;
211
+ readonly labelId: string;
212
+ readonly controlId: string;
213
+ readonly errorId: string;
214
+ readonly className: ReadonlySignal<string>;
215
+ /** Display text for the field label (`{code}` labels re-translate reactively via TranslationAdapter). */
216
+ readonly labelText: ReadonlySignal<string>;
217
+ /** Display options list (`{code}` option labels re-translate reactively via TranslationAdapter; `#for` template iteration auto-unwraps). */
218
+ readonly options: ReadonlySignal<readonly FormRenderOption<Value>[]>;
219
+ readonly errorText: ReadonlySignal<string>;
220
+ readonly ariaDescribedBy: ReadonlySignal<string | undefined>;
221
+ readonly ariaInvalid: ReadonlySignal<'true' | undefined>;
222
+ readonly ariaRequired: ReadonlySignal<'true' | undefined>;
223
+ readonly ariaDisabled: ReadonlySignal<'true' | undefined>;
224
+ }
225
+ export type FormFieldViewItems<Fields extends FormFieldSchemaRecord> = {
226
+ readonly [Name in keyof Fields & string]: FormFieldViewItem<Name, FormValues<Fields>[Name], FormFieldRenderControlFor<Fields[Name]>>;
227
+ };
228
+ export interface FormView<Fields extends FormFieldSchemaRecord> {
229
+ readonly id: string;
230
+ readonly renderer: FormRenderer<Fields>;
231
+ readonly fields: FormFieldViewItems<Fields>;
232
+ readonly items: readonly FormFieldViewItems<Fields>[keyof FormFieldViewItems<Fields>][];
233
+ readonly className: ReadonlySignal<string>;
234
+ readonly submitting: ReadonlySignal<boolean>;
235
+ readonly errorSummaryId: string;
236
+ readonly errorSummaryLabelId: string;
237
+ readonly errorSummaryClass: string;
238
+ readonly errorSummary: ReadonlySignal<readonly FormErrorSummaryItem<keyof Fields & string>[]>;
239
+ submit(callback: (values: FormValues<Fields>) => void | Promise<void>): Promise<boolean>;
240
+ }
241
+ export declare function defineForm<const Fields extends FormFieldSchemaRecord>(schema: FormSchema<Fields>): FormSchema<Fields>;
242
+ export declare function createForm<const Fields extends FormFieldSchemaRecord>(schema: FormSchema<Fields>, initialValues?: Partial<FormValues<Fields>>, options?: FormOptions): FormInstance<Fields>;
243
+ export declare function createFormRenderer<const Fields extends FormFieldSchemaRecord>(form: FormInstance<Fields>, options?: FormRendererOptions<Fields>): FormRenderer<Fields>;
244
+ export declare function createFormView<const Fields extends FormFieldSchemaRecord>(renderer: FormRenderer<Fields>, options: FormViewOptions): FormView<Fields>;
245
+ /**
246
+ * The injection capability subset useForm needs (structurally compatible with ComponentSetupContext/PageSetupContext maybeInject).
247
+ * forms does not depend on the di package: it only consumes runtime-core's public port (TranslationAdapterKey).
248
+ */
249
+ export interface FormInjectionHost {
250
+ maybeInject<T>(key: InjectionKey<T>): T | undefined;
251
+ }
252
+ export interface FormSetupOptions<Fields extends FormFieldSchemaRecord> {
253
+ readonly initialValues?: Partial<FormValues<Fields>>;
254
+ /** Validation message localization. When required is not provided, the injected TranslationAdapter wires i18n (key: forms.required, param {label}); without an injection, default English is used. */
255
+ readonly messages?: FormMessages;
256
+ /** Owner the form belongs to (default currentOwner, i.e. the page Owner inside page setup). */
257
+ readonly owner?: Owner;
258
+ /** Auto re-validation mode (default 'submit'). */
259
+ readonly validateOn?: FormValidateOn;
260
+ /** Renderer configuration (field control/slot overrides). */
261
+ readonly renderer?: FormRendererOptions<Fields>;
262
+ /** View id (DOM id prefix, default 'form'). */
263
+ readonly viewId?: string;
264
+ /**
265
+ * Structured error resolver (default: `{code}` translated via the injected TranslationAdapter,
266
+ * `message` preferred, raw code shown without an adapter). Overridable.
267
+ */
268
+ readonly resolveError?: (error: FormError) => string;
269
+ /**
270
+ * Field label resolver (default: `{code}` labels translated via the injected TranslationAdapter,
271
+ * driving view.labelText and the `{label}` interpolation in required messages). Overridable.
272
+ */
273
+ readonly resolveLabel?: (label: FormLabel) => string;
274
+ }
275
+ export interface FormSetup<Fields extends FormFieldSchemaRecord> {
276
+ readonly form: FormInstance<Fields>;
277
+ readonly renderer: FormRenderer<Fields>;
278
+ readonly view: FormView<Fields>;
279
+ }
280
+ /**
281
+ * Composition API: single entry to create the form trio (form + renderer + view), replacing three manual calls.
282
+ *
283
+ * Called in page/component setup:
284
+ * ```ts
285
+ * const { form, view } = useForm(context, schema);
286
+ * await view.submit(async (values) => { ... });
287
+ * form.applyServerErrors(serverErrors); // server error write-back
288
+ * ```
289
+ *
290
+ * Automatic behavior:
291
+ * - Owner ownership: calls inside setup join the page Owner tree; page disposal cascades release (no manual dispose)
292
+ * - i18n integration: the injected TranslationAdapter (active after provideI18n assembly) localizes required messages
293
+ * and translates `{code}` structured errors into display text (reactively re-translated on locale change);
294
+ * explicit messages.required / resolveError win; without injection, default English / raw code is shown
295
+ */
296
+ export declare function useForm<const Fields extends FormFieldSchemaRecord>(context: FormInjectionHost, schema: FormSchema<Fields>, options?: FormSetupOptions<Fields>): FormSetup<Fields>;
package/dist/index.js ADDED
@@ -0,0 +1,467 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/forms
4
+ */
5
+ import { computed, effect, EffectScope, signal, } from '@vobs/reactivity';
6
+ import { createRuntimeError, currentOwner, TranslationAdapterKey, } from '@vobs/runtime-core';
7
+ /**
8
+ * Resolves a structured error into display text:
9
+ * - strings are returned as-is;
10
+ * - `{ code, message }` prefers `message`;
11
+ * - when `message` is absent and `translate` is provided, `code` is translated (params passed through);
12
+ * - when neither is available, falls back to the raw `code` (never throws or breaks rendering).
13
+ */
14
+ export function resolveFormError(error, translate) {
15
+ if (typeof error === 'string')
16
+ return error;
17
+ if (error.message !== undefined)
18
+ return error.message;
19
+ if (translate !== undefined)
20
+ return translate(error.code, error.params);
21
+ return error.code;
22
+ }
23
+ /**
24
+ * Default error resolver (used when no TranslationAdapter is injected):
25
+ * - `{ code: 'forms.required', params: { label } }` degrades to default English `"{label} is required"`;
26
+ * - other `{code}` errors show the raw code (same semantics as resolveFormError).
27
+ * When an adapter is present, useForm's injected resolver takes precedence (`forms.required` is translated via the i18n catalog).
28
+ */
29
+ export function defaultResolveFormError(error) {
30
+ return resolveFormError(error, (code, params) => code === 'forms.required' ? `${String(params?.label ?? 'Field')} is required` : code);
31
+ }
32
+ export function defineForm(schema) {
33
+ return schema;
34
+ }
35
+ export function createForm(schema, initialValues = {}, options = {}) {
36
+ const models = {};
37
+ const errors = {};
38
+ const touched = {};
39
+ const fields = {};
40
+ const names = Object.keys(schema.fields);
41
+ // When the user provides an explicit required message, required errors store a message snapshot
42
+ // (not re-translated on locale change); otherwise they store { code: 'forms.required', params: { label } },
43
+ // translated via the adapter at render time (locale-reactive, default English without an adapter).
44
+ const requiredMessage = options.messages?.required;
45
+ const resolveLabel = options.resolveLabel ?? resolveFormError;
46
+ const owner = options.owner ?? currentOwner();
47
+ // Form-specific EffectScope: internal computeds (values/required/disabled) belong to this scope,
48
+ // so dispose() stops the scope and releases all subscriptions; Owner disposal cascades automatically (registered via owner.own below).
49
+ const scope = new EffectScope({ label: 'vobs.form' });
50
+ // Server error table: written by applyServerErrors/setFieldErrors; validate does not silently overwrite
51
+ // until clearErrors()/reset(). Client validation errors take precedence; server errors surface when no client errors exist.
52
+ const serverErrors = new Map();
53
+ const writeFieldErrors = (name, messages) => {
54
+ const server = serverErrors.get(name);
55
+ if (messages.length > 0) {
56
+ errors[name].value = messages;
57
+ }
58
+ else if (server !== undefined && server.length > 0) {
59
+ errors[name].value = server;
60
+ }
61
+ else {
62
+ errors[name].value = [];
63
+ }
64
+ touched[name].value = true;
65
+ };
66
+ for (const name of names) {
67
+ const field = schema.fields[name];
68
+ const initialValue = readInitialValue(field.defaultValue, initialValues[name]);
69
+ const model = signal(initialValue);
70
+ const fieldErrors = signal([]);
71
+ const fieldTouched = signal(false);
72
+ models[name] = model;
73
+ errors[name] = fieldErrors;
74
+ touched[name] = fieldTouched;
75
+ fields[name] = createFieldController(name, field, model, fieldErrors, fieldTouched, () => readValuesFromModels(models, names), requiredMessage, resolveLabel, scope, (messages) => writeFieldErrors(name, messages));
76
+ }
77
+ const values = computed(() => readValuesFromModels(models, names), { scope });
78
+ const items = names.map((name) => fields[name]);
79
+ function validateField(name) {
80
+ assertActive();
81
+ return fields[name].validate().then((client) => {
82
+ const server = serverErrors.get(name);
83
+ if (server !== undefined && server.length > 0) {
84
+ return [...client, ...server];
85
+ }
86
+ return client;
87
+ });
88
+ }
89
+ let disposed = false;
90
+ const dispose = () => {
91
+ if (disposed)
92
+ return;
93
+ disposed = true;
94
+ scope.stop();
95
+ };
96
+ if (owner !== undefined) {
97
+ owner.own(dispose);
98
+ }
99
+ // Terminal semantics (aligned with kernel-runtime §1 Owner VOR502): operations after dispose must fail, not silently succeed.
100
+ const assertActive = () => {
101
+ if (disposed) {
102
+ throw createRuntimeError('VOR581', 'Form is disposed');
103
+ }
104
+ };
105
+ // validateOn='change': an effect watches each field model and only re-validates that field on change (microtask-debounced).
106
+ // Input is treated as touch (marks touched), matching the react-hook-form onChange pattern.
107
+ const validateOn = options.validateOn ?? 'submit';
108
+ if (validateOn === 'change') {
109
+ const pendingFields = new Set();
110
+ let scheduled = false;
111
+ const flushChangeValidate = () => {
112
+ scheduled = false;
113
+ if (disposed)
114
+ return;
115
+ const targets = [...pendingFields];
116
+ pendingFields.clear();
117
+ for (const name of targets) {
118
+ touched[name].value = true;
119
+ void validateField(name).catch(() => undefined);
120
+ }
121
+ };
122
+ for (const name of names) {
123
+ const model = models[name];
124
+ let skipFirst = true;
125
+ effect(() => {
126
+ void model.value;
127
+ if (skipFirst) {
128
+ skipFirst = false; // first run during subscription is not a change
129
+ return;
130
+ }
131
+ pendingFields.add(name);
132
+ if (!scheduled) {
133
+ scheduled = true;
134
+ queueMicrotask(flushChangeValidate);
135
+ }
136
+ }, { scope, flush: 'sync' });
137
+ }
138
+ }
139
+ const touchField = (name) => {
140
+ assertActive();
141
+ touched[name].value = true;
142
+ if (validateOn === 'blur') {
143
+ void validateField(name).catch(() => undefined);
144
+ }
145
+ };
146
+ return {
147
+ schema,
148
+ fields,
149
+ items,
150
+ models,
151
+ errors,
152
+ touched,
153
+ values,
154
+ readValues() {
155
+ assertActive();
156
+ return values.value;
157
+ },
158
+ setValues(values) {
159
+ assertActive();
160
+ for (const name of names) {
161
+ const value = values[name];
162
+ if (value !== undefined) {
163
+ models[name].value = value;
164
+ // User corrected the field value: its server error is now stale, clear it (does not block the next submit)
165
+ if (serverErrors.has(name)) {
166
+ serverErrors.delete(name);
167
+ errors[name].value = [];
168
+ }
169
+ }
170
+ }
171
+ },
172
+ reset(values = {}) {
173
+ assertActive();
174
+ serverErrors.clear();
175
+ for (const name of names) {
176
+ const field = schema.fields[name];
177
+ models[name].value = readInitialValue(field.defaultValue, values[name]);
178
+ errors[name].value = [];
179
+ touched[name].value = false;
180
+ }
181
+ },
182
+ async validate() {
183
+ assertActive();
184
+ // Batch validation: build one values snapshot for all fields, avoiding per-field O(n) rebuild → O(n²)
185
+ const snapshot = readValuesFromModels(models, names);
186
+ const results = await Promise.all(names.map((name) => {
187
+ const controller = fields[name];
188
+ return controller.validateWith !== undefined
189
+ ? controller.validateWith(snapshot)
190
+ : controller.validate();
191
+ }));
192
+ const hasClientErrors = results.flat().length > 0;
193
+ // Server errors survive client validation (not silently overwritten),
194
+ // but when the client has no errors, remaining server errors still make validate() fail.
195
+ const hasServerErrors = [...serverErrors.values()].some((list) => list.length > 0);
196
+ return !hasClientErrors && !hasServerErrors;
197
+ },
198
+ validateField,
199
+ setFieldErrors(name, messages) {
200
+ assertActive();
201
+ serverErrors.set(name, messages);
202
+ errors[name].value = messages;
203
+ touched[name].value = true;
204
+ },
205
+ applyServerErrors(errorMap) {
206
+ assertActive();
207
+ for (const name of names) {
208
+ const messages = errorMap[name];
209
+ if (messages === undefined)
210
+ continue;
211
+ serverErrors.set(name, messages);
212
+ errors[name].value = messages;
213
+ touched[name].value = true;
214
+ }
215
+ },
216
+ clearErrors() {
217
+ assertActive();
218
+ serverErrors.clear();
219
+ for (const name of names) {
220
+ errors[name].value = [];
221
+ }
222
+ },
223
+ touchField,
224
+ dispose,
225
+ };
226
+ }
227
+ export function createFormRenderer(form, options = {}) {
228
+ const fields = {};
229
+ const items = form.items.map((field) => {
230
+ const item = createRenderItem(field, options.fields?.[field.name]);
231
+ fields[field.name] = item;
232
+ return item;
233
+ });
234
+ return {
235
+ form,
236
+ fields,
237
+ items,
238
+ errorSummary: computed(() => items.flatMap((item) => item.errors.value)),
239
+ async submit(callback) {
240
+ if (!(await form.validate()))
241
+ return false;
242
+ await callback(form.readValues());
243
+ return true;
244
+ },
245
+ };
246
+ }
247
+ export function createFormView(renderer, options) {
248
+ const resolveError = options.resolveError ?? defaultResolveFormError;
249
+ const resolveLabel = options.resolveLabel ?? resolveFormError;
250
+ const submitting = signal(false);
251
+ const fields = {};
252
+ const items = renderer.items.map((item) => {
253
+ const controlId = `${options.id}-${item.name}`;
254
+ const errorId = `${controlId}-error`;
255
+ const viewItem = {
256
+ ...item,
257
+ id: `${controlId}-field`,
258
+ labelId: `${controlId}-label`,
259
+ controlId,
260
+ errorId,
261
+ className: computed(() => fieldClassName(item)),
262
+ labelText: computed(() => resolveLabel(item.label)),
263
+ options: computed(() => item.options.map((option) => ({ ...option, label: resolveLabel(option.label) }))),
264
+ errorText: computed(() => item.errors.value.map((error) => resolveError(error)).join(' ')),
265
+ ariaDescribedBy: computed(() => (item.errors.value.length === 0 ? undefined : errorId)),
266
+ ariaInvalid: computed(() => (item.errors.value.length === 0 ? undefined : 'true')),
267
+ ariaRequired: computed(() => (item.required.value ? 'true' : undefined)),
268
+ ariaDisabled: computed(() => (item.disabled.value ? 'true' : undefined)),
269
+ };
270
+ fields[item.name] = viewItem;
271
+ return viewItem;
272
+ });
273
+ const errorSummaryId = `${options.id}-error-summary`;
274
+ return {
275
+ id: options.id,
276
+ renderer,
277
+ fields,
278
+ items,
279
+ className: computed(() => (submitting.value ? 'vobs-form vobs-form--submitting' : 'vobs-form')),
280
+ submitting,
281
+ errorSummaryId,
282
+ errorSummaryLabelId: `${errorSummaryId}-label`,
283
+ errorSummaryClass: 'vobs-form__error-summary',
284
+ errorSummary: computed(() => items.flatMap((item) => item.errors.value.map((error, index) => ({
285
+ id: `${errorSummaryId}-${item.name}-${index}`,
286
+ field: item.name,
287
+ controlId: item.controlId,
288
+ href: `#${item.controlId}`,
289
+ message: resolveError(error),
290
+ })))),
291
+ async submit(callback) {
292
+ if (submitting.value)
293
+ return false;
294
+ submitting.value = true;
295
+ try {
296
+ return await renderer.submit(callback);
297
+ }
298
+ finally {
299
+ submitting.value = false;
300
+ }
301
+ },
302
+ };
303
+ }
304
+ function createFieldController(name, field, model, errors, touched, readValues, requiredMessage, resolveLabel, scope, writeErrors) {
305
+ const context = () => ({ values: readValues() });
306
+ let validationVersion = 0;
307
+ return {
308
+ name,
309
+ label: field.label,
310
+ type: field.type ?? 'text',
311
+ model,
312
+ errors,
313
+ touched,
314
+ required: computed(() => readSwitch(field.required, context()), { scope }),
315
+ disabled: computed(() => readSwitch(field.disabled, context()), { scope }),
316
+ options: field.options ?? [],
317
+ async validate() {
318
+ const values = readValues();
319
+ if (this.validateWith !== undefined) {
320
+ return this.validateWith(values);
321
+ }
322
+ return validateFieldValue(field, model.value, { values }, requiredMessage, resolveLabel);
323
+ },
324
+ async validateWith(input) {
325
+ const version = ++validationVersion;
326
+ const messages = await validateFieldValue(field, model.value, { values: input }, requiredMessage, resolveLabel);
327
+ if (version !== validationVersion) {
328
+ return messages;
329
+ }
330
+ writeErrors(messages);
331
+ return messages;
332
+ },
333
+ };
334
+ }
335
+ async function validateFieldValue(field, value, context, requiredMessage, resolveLabel) {
336
+ const messages = [];
337
+ if (readSwitch(field.required, context) && isEmptyValue(value)) {
338
+ // Structured required error: translated via the adapter at render time (locale-reactive);
339
+ // stores a message snapshot when the user explicitly provides messages.required.
340
+ const label = resolveLabel(field.label);
341
+ messages.push(requiredMessage === undefined
342
+ ? { code: 'forms.required', params: { label } }
343
+ : { code: 'forms.required', message: requiredMessage(label), params: { label } });
344
+ }
345
+ const custom = await field.validate?.(value, context);
346
+ if (custom !== undefined) {
347
+ if (Array.isArray(custom)) {
348
+ messages.push(...custom);
349
+ }
350
+ else {
351
+ messages.push(custom);
352
+ }
353
+ }
354
+ return messages;
355
+ }
356
+ function readSwitch(value, context) {
357
+ return typeof value === 'function' ? value(context) : (value ?? false);
358
+ }
359
+ function readInitialValue(defaultValue, value) {
360
+ return value === undefined ? defaultValue : value;
361
+ }
362
+ function readValuesFromModels(models, names) {
363
+ const values = {};
364
+ for (const name of names) {
365
+ values[name] = models[name].value;
366
+ }
367
+ return values;
368
+ }
369
+ function isEmptyValue(value) {
370
+ if (value === null || value === undefined)
371
+ return true;
372
+ if (typeof value === 'string')
373
+ return value.trim() === '';
374
+ if (Array.isArray(value))
375
+ return value.length === 0;
376
+ return false;
377
+ }
378
+ function createRenderItem(field, options) {
379
+ const defaults = defaultRenderOptions(field.type);
380
+ return {
381
+ name: field.name,
382
+ label: field.label,
383
+ control: options?.control ?? defaults.control,
384
+ ...((options?.inputType ?? defaults.inputType === undefined)
385
+ ? {}
386
+ : { inputType: options?.inputType ?? defaults.inputType }),
387
+ slot: options?.slot ?? field.name,
388
+ field,
389
+ model: field.model,
390
+ errors: field.errors,
391
+ touched: field.touched,
392
+ required: field.required,
393
+ disabled: field.disabled,
394
+ options: field.options,
395
+ };
396
+ }
397
+ function defaultRenderOptions(type) {
398
+ switch (type) {
399
+ case 'checkbox':
400
+ return { control: 'checkbox' };
401
+ case 'custom':
402
+ return { control: 'custom' };
403
+ case 'date':
404
+ return { control: 'input', inputType: 'date' };
405
+ case 'number':
406
+ return { control: 'input', inputType: 'number' };
407
+ case 'select':
408
+ return { control: 'select' };
409
+ case 'textarea':
410
+ return { control: 'textarea' };
411
+ default:
412
+ return { control: 'input', inputType: 'text' };
413
+ }
414
+ }
415
+ function fieldClassName(item) {
416
+ const classes = ['vobs-field'];
417
+ if (item.required.value)
418
+ classes.push('vobs-field--required');
419
+ if (item.disabled.value)
420
+ classes.push('vobs-field--disabled');
421
+ if (item.errors.value.length > 0)
422
+ classes.push('vobs-field--invalid');
423
+ return classes.join(' ');
424
+ }
425
+ /**
426
+ * Composition API: single entry to create the form trio (form + renderer + view), replacing three manual calls.
427
+ *
428
+ * Called in page/component setup:
429
+ * ```ts
430
+ * const { form, view } = useForm(context, schema);
431
+ * await view.submit(async (values) => { ... });
432
+ * form.applyServerErrors(serverErrors); // server error write-back
433
+ * ```
434
+ *
435
+ * Automatic behavior:
436
+ * - Owner ownership: calls inside setup join the page Owner tree; page disposal cascades release (no manual dispose)
437
+ * - i18n integration: the injected TranslationAdapter (active after provideI18n assembly) localizes required messages
438
+ * and translates `{code}` structured errors into display text (reactively re-translated on locale change);
439
+ * explicit messages.required / resolveError win; without injection, default English / raw code is shown
440
+ */
441
+ export function useForm(context, schema, options = {}) {
442
+ const adapter = context.maybeInject(TranslationAdapterKey);
443
+ // required errors are stored as { code: 'forms.required', params } and translated via the adapter at render time (locale-reactive);
444
+ // the adapter's required function is no longer injected at assembly (it would freeze into a message snapshot).
445
+ const messages = options.messages ?? {};
446
+ const resolveError = options.resolveError ??
447
+ (adapter !== undefined
448
+ ? (error) => resolveFormError(error, (code, params) => adapter.t(code, params))
449
+ : defaultResolveFormError);
450
+ const resolveLabel = options.resolveLabel ??
451
+ (adapter !== undefined
452
+ ? (label) => resolveFormError(label, (code, params) => adapter.t(code, params))
453
+ : resolveFormError);
454
+ const form = createForm(schema, options.initialValues, {
455
+ messages,
456
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
457
+ ...(options.validateOn === undefined ? {} : { validateOn: options.validateOn }),
458
+ resolveLabel,
459
+ });
460
+ const renderer = createFormRenderer(form, options.renderer);
461
+ const view = createFormView(renderer, {
462
+ id: options.viewId ?? 'form',
463
+ resolveError,
464
+ resolveLabel,
465
+ });
466
+ return { form, renderer, view };
467
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vobs/forms",
3
+ "version": "0.1.0",
4
+ "description": "Schema-driven form state primitives for vobs.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "license": "MIT",
10
+ "author": "vobsjs",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/vobsjs/vobs.git",
14
+ "directory": "packages/features/forms"
15
+ },
16
+ "bugs": {
17
+ "url": "https://github.com/vobsjs/vobs/issues"
18
+ },
19
+ "homepage": "https://github.com/vobsjs/vobs#readme",
20
+ "dependencies": {
21
+ "@vobs/reactivity": "0.1.0",
22
+ "@vobs/runtime-core": "0.1.0"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "types": "./dist/index.d.ts",
35
+ "module": "./dist/index.js",
36
+ "main": "./dist/index.js",
37
+ "sideEffects": false,
38
+ "engines": {
39
+ "node": ">=20.19.0"
40
+ }
41
+ }