@vobs/forms 0.3.0 → 1.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/dist/index.js DELETED
@@ -1,467 +0,0 @@
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
- }