@coherent.js/forms 1.0.0-rc.6 → 1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coherent.js/forms",
3
- "version": "1.0.0-rc.6",
3
+ "version": "1.0.1",
4
4
  "description": "SSR + Hydration form system for Coherent.js applications",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -23,8 +23,8 @@
23
23
  "author": "Coherent.js Team",
24
24
  "license": "MIT",
25
25
  "peerDependencies": {
26
- "@coherent.js/core": "1.0.0-rc.6",
27
- "@coherent.js/state": "1.0.0-rc.6"
26
+ "@coherent.js/state": "1.0.1",
27
+ "@coherent.js/core": "1.0.1"
28
28
  },
29
29
  "repository": {
30
30
  "type": "git",
@@ -52,6 +52,7 @@
52
52
  "build": "node build.mjs",
53
53
  "clean": "rm -rf dist",
54
54
  "test": "vitest run",
55
- "test:watch": "vitest"
55
+ "test:watch": "vitest",
56
+ "typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
56
57
  }
57
58
  }
package/types/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * @module @coherent.js/forms
4
4
  */
5
5
 
6
- import type { CoherentNode, CoherentElement, StrictCoherentElement } from '@coherent.js/core';
6
+ import type { CoherentNode } from '@coherent.js/core';
7
7
 
8
8
  // ============================================================================
9
9
  // Form Field Types
@@ -113,80 +113,126 @@ export interface FieldValidation<T = unknown> {
113
113
  * Form configuration options
114
114
  */
115
115
  export interface FormConfig {
116
- /** Form fields */
117
- fields?: FormField[];
116
+ /** Fields, as an array of `{ name, ...config }` or keyed by field name */
117
+ fields?: FormField[] | Record<string, Omit<FormField, 'name'>>;
118
118
  /** Form action URL */
119
119
  action?: string;
120
120
  /** Form submission method */
121
- method?: 'get' | 'post';
121
+ method?: 'get' | 'post' | (string & {});
122
+ /** Form name attribute; defaults to `'form'` */
123
+ name?: string;
122
124
  /** Form CSS class name */
123
125
  className?: string;
124
126
  /** Submit button text */
125
127
  submitText?: string;
126
128
  /** Form submit handler */
127
- onSubmit?: (data: FormData) => void | Promise<void>;
129
+ onSubmit?: (data: Record<string, unknown>) => void | Promise<void>;
128
130
  /** Form encoding type */
129
131
  enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
130
132
  /** Whether to disable browser validation */
131
133
  novalidate?: boolean;
132
134
  /** Form ID */
133
135
  id?: string;
136
+ /** Validate a field as it changes; defaults to `true` */
137
+ validateOnChange?: boolean;
138
+ /** Validate a field when it loses focus; defaults to `true` */
139
+ validateOnBlur?: boolean;
140
+ [option: string]: unknown;
134
141
  }
135
142
 
136
143
  /**
137
- * Typed form builder with generic form data shape
144
+ * Accumulates fields and renders them as a Coherent component.
145
+ *
146
+ * Every mutator is chainable, and `build()`, `render()` and `buildForm()` all
147
+ * return the same node — `toHTML()` is that node rendered to a string.
148
+ *
149
+ * ```ts
150
+ * const form = new FormBuilder({ name: 'signup' })
151
+ * .field('email', { type: 'email', label: 'Email', required: true })
152
+ * .setAction('/subscribe')
153
+ * .setMethod('post')
154
+ * .build();
155
+ * ```
156
+ *
138
157
  * @template T - The shape of the form data
139
158
  */
140
- export interface FormBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
141
- /**
142
- * Add a field to the form
143
- * @template K - The key in the form data
144
- */
145
- addField<K extends keyof T>(name: K, field: Omit<FormField<T[K]>, 'name'>): FormBuilder<T>;
146
-
147
- /**
148
- * Remove a field from the form
149
- */
150
- removeField(name: keyof T): FormBuilder<T>;
159
+ export class FormBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
160
+ constructor(options?: FormConfig);
161
+
162
+ options: FormConfig;
163
+ fields: Map<string, FormField>;
164
+ values: Partial<T>;
165
+ errors: Record<string, string>;
166
+ touched: Record<string, boolean>;
167
+
168
+ /** Define a field */
169
+ field<K extends keyof T & string>(name: K, config?: Omit<FormField<T[K]>, 'name'>): this;
170
+ /** Alias of {@link FormBuilder.field} */
171
+ addField<K extends keyof T & string>(name: K, config?: Omit<FormField<T[K]>, 'name'>): this;
172
+ removeField(name: keyof T & string): this;
173
+ /** Merge changes into an existing field */
174
+ updateField(name: keyof T & string, config: Partial<FormField>): this;
175
+
176
+ /** Every field definition, in insertion order */
177
+ getFields(): FormField[];
178
+ getField(name: keyof T & string): FormField | undefined;
151
179
 
152
- /**
153
- * Set the form action URL
154
- */
155
- setAction(action: string): FormBuilder<T>;
180
+ /** Group fields for layout */
181
+ addGroup(name: string, config?: Record<string, unknown>): this;
182
+ getGroup(name: string): Record<string, unknown> | undefined;
156
183
 
157
- /**
158
- * Set the form submission method
159
- */
160
- setMethod(method: 'get' | 'post'): FormBuilder<T>;
184
+ setValue<K extends keyof T & string>(name: K, value: T[K]): this;
185
+ setValues(values: Partial<T>): this;
186
+ getValue<K extends keyof T & string>(name: K): T[K] | undefined;
187
+ getValues(): Partial<T>;
161
188
 
162
- /**
163
- * Set the form submit handler
164
- */
165
- onSubmit(handler: (data: T) => void | Promise<void>): FormBuilder<T>;
189
+ /** Run one field's validators; returns the error or `null` */
190
+ validateField(name: keyof T & string): string | null;
166
191
 
167
192
  /**
168
- * Build the form as a CoherentNode
193
+ * Validate every visible field and store the result. Returns the errors
194
+ * keyed by field name — empty when the form is valid.
169
195
  */
170
- build(): CoherentNode;
196
+ validate(): Record<string, string>;
171
197
 
172
- /**
173
- * Render the form (alias for build)
174
- */
175
- render(): CoherentNode;
198
+ getFieldError(name: keyof T & string): string | null;
199
+ hasErrors(): boolean;
200
+ clearErrors(): this;
201
+ isValid(): boolean;
202
+ /** Mark a field as touched */
203
+ touch(name: keyof T & string): void;
204
+ /** Whether any value differs from its initial value */
205
+ isDirty(): boolean;
176
206
 
177
- /**
178
- * Validate form data
179
- */
180
- validate(data: unknown): { valid: boolean; errors: Record<keyof T, string[]> };
207
+ onSubmit(handler: (data: Partial<T>) => void | Promise<void>): this;
208
+ onError(handler: (error: unknown) => void): this;
209
+ isSubmitting(): boolean;
181
210
 
182
- /**
183
- * Get current field definitions
184
- */
185
- getFields(): FormField[];
211
+ setAction(action: string): this;
212
+ setMethod(method: 'get' | 'post' | (string & {})): this;
213
+
214
+ /** Build the form component */
215
+ buildForm(options?: FormConfig): CoherentNode;
216
+ /** Alias of {@link FormBuilder.buildForm} */
217
+ build(options?: FormConfig): CoherentNode;
218
+ /** Alias of {@link FormBuilder.buildForm} */
219
+ render(options?: FormConfig): CoherentNode;
220
+ /** The built form, rendered to an HTML string */
221
+ toHTML(options?: FormConfig): string;
222
+
223
+ /** Build the node for one field, including its label and error */
224
+ buildField(name: keyof T & string): CoherentNode;
225
+
226
+ /** Copy of the current values */
227
+ serialize(): Partial<T>;
228
+ /** Whether a field's `showWhen`/`showIf` condition currently holds */
229
+ isFieldVisible(name: keyof T & string): boolean;
230
+ /** Restore default values and clear errors and touched state */
231
+ reset(): this;
186
232
  }
187
233
 
188
234
  /**
189
- * Create a typed form builder
235
+ * Create a form builder, optionally seeding it from `config.fields`.
190
236
  * @template T - The shape of the form data
191
237
  */
192
238
  export function createFormBuilder<T extends Record<string, unknown> = Record<string, unknown>>(
@@ -194,35 +240,12 @@ export function createFormBuilder<T extends Record<string, unknown> = Record<str
194
240
  ): FormBuilder<T>;
195
241
 
196
242
  /**
197
- * Build a form from configuration
198
- */
199
- export function buildForm(config: FormConfig): CoherentNode;
200
-
201
- /**
202
- * Validate a single field value
203
- * @returns Error message or null if valid
204
- */
205
- export function validateField<T>(field: FormField<T>, value: unknown): string | null;
206
-
207
- // ============================================================================
208
- // Form Builder Class
209
- // ============================================================================
210
-
211
- /**
212
- * Form builder class implementation
243
+ * Build a form component from configuration, in one call.
244
+ *
245
+ * `fields` may be an array of `{ name, ...config }` objects or an object
246
+ * keyed by field name; passing a bare array is shorthand for `{ fields }`.
213
247
  */
214
- export class FormBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
215
- constructor(config?: FormConfig);
216
- addField<K extends keyof T>(name: K, field: Omit<FormField<T[K]>, 'name'>): this;
217
- removeField(name: keyof T): this;
218
- setAction(action: string): this;
219
- setMethod(method: 'get' | 'post'): this;
220
- onSubmit(handler: (data: T) => void | Promise<void>): this;
221
- build(): CoherentNode;
222
- render(): CoherentNode;
223
- validate(data: unknown): { valid: boolean; errors: Record<keyof T, string[]> };
224
- getFields(): FormField[];
225
- }
248
+ export function buildForm(config?: FormConfig | FormField[]): CoherentNode;
226
249
 
227
250
  // ============================================================================
228
251
  // Form Hydration Types
@@ -232,51 +255,75 @@ export class FormBuilder<T extends Record<string, unknown> = Record<string, unkn
232
255
  * Options for hydrating a form on the client
233
256
  */
234
257
  export interface HydrationOptions {
235
- /** Enable validation */
236
- validation?: boolean;
237
- /** Enable real-time validation as user types */
238
- realTimeValidation?: boolean;
239
- /** Form submit handler */
240
- onSubmit?: (event: Event, data: FormData) => void | Promise<void>;
241
- /** Validation error handler */
242
- onValidate?: (errors: ValidationErrors) => void;
243
- /** Prevent default form submission */
244
- preventSubmit?: boolean;
258
+ /** Validate a field when it loses focus; defaults to `true` */
259
+ validateOnBlur?: boolean;
260
+ /** Validate a field as it changes; defaults to `false` */
261
+ validateOnChange?: boolean;
262
+ /** Validate everything on submit; defaults to `true` */
263
+ validateOnSubmit?: boolean;
264
+ /** Only show a field's error once it has been touched; defaults to `true` */
265
+ showErrorsOnTouch?: boolean;
266
+ /** Debounce window for change validation, in ms; defaults to `300` */
267
+ debounce?: number;
268
+ /**
269
+ * Called instead of the browser's native submit. Return `false` to cancel,
270
+ * or a promise to defer completion.
271
+ */
272
+ onSubmit?: (data: Record<string, unknown>, event: Event) => unknown;
273
+ /** Called with the field errors on a failed submit, or a rejected `onSubmit` */
274
+ onError?: (errors: ValidationErrors | unknown) => void;
275
+ /** Called after a promise returned by `onSubmit` resolves */
276
+ onSuccess?: (data: Record<string, unknown>) => void;
277
+ [option: string]: unknown;
245
278
  }
246
279
 
247
280
  /**
248
- * Hydrated form interface for client-side interaction
281
+ * Controller returned by {@link hydrateForm}.
249
282
  */
250
283
  export interface HydratedForm {
251
- /** The form DOM element */
252
- element: HTMLFormElement;
253
- /** Validate all form fields */
254
- validate(): ValidationResult;
255
- /** Reset form to initial values */
284
+ /** Validate one field and record the result */
285
+ validateField(name: string): string | null;
286
+ /** Validate every field; `true` when all pass */
287
+ validateForm(): boolean;
288
+
289
+ setFieldValue(name: string, value: unknown): void;
290
+ getFieldValue(name: string): unknown;
291
+
292
+ /** Error currently shown for a field, or `undefined` */
293
+ getError(name: string): string | undefined;
294
+ /** Copy of the current errors */
295
+ getErrors(): ValidationErrors;
296
+ /** Copy of the current values */
297
+ getValues(): Record<string, unknown>;
298
+
299
+ setTouched(name: string, touched?: boolean): void;
300
+
301
+ /** Restore initial values and clear errors */
256
302
  reset(): void;
257
- /** Get current form data */
258
- getData(): FormData;
259
- /** Get form data as object */
260
- getValues<T = Record<string, unknown>>(): T;
261
- /** Set form field values */
262
- setData(data: Record<string, unknown>): void;
263
- /** Set a single field value */
264
- setValue(name: string, value: unknown): void;
265
- /** Destroy hydration and clean up event listeners */
303
+ /** Detach every listener and cancel pending debounces */
266
304
  destroy(): void;
267
- /** Check if form is valid */
305
+
268
306
  isValid(): boolean;
269
- /** Get validation errors */
270
- getErrors(): ValidationErrors;
307
+ isSubmitting(): boolean;
308
+
309
+ /** Snapshot of values, errors, touched flags and submit state */
310
+ getState(): {
311
+ values: Record<string, unknown>;
312
+ errors: ValidationErrors;
313
+ touched: Record<string, boolean>;
314
+ isSubmitting: boolean;
315
+ };
271
316
  }
272
317
 
273
318
  /**
274
- * Hydrate a form element for client-side interactivity
319
+ * Attach client-side behavior to a server-rendered form.
320
+ *
321
+ * Returns `null` outside a browser, or when the selector matches nothing.
275
322
  */
276
323
  export function hydrateForm(
277
- formElement: HTMLFormElement | string,
324
+ formSelector: HTMLFormElement | string,
278
325
  options?: HydrationOptions
279
- ): HydratedForm;
326
+ ): HydratedForm | null;
280
327
 
281
328
  // ============================================================================
282
329
  // Validation Types
@@ -286,9 +333,9 @@ export function hydrateForm(
286
333
  * Validation result
287
334
  */
288
335
  export interface ValidationResult {
289
- /** Whether all fields are valid */
290
- valid: boolean;
291
- /** Validation errors by field name */
336
+ /** Whether every field passed */
337
+ isValid: boolean;
338
+ /** The first error per failing field; passing fields are absent */
292
339
  errors: ValidationErrors;
293
340
  }
294
341
 
@@ -296,86 +343,159 @@ export interface ValidationResult {
296
343
  * Validation errors mapped by field name
297
344
  */
298
345
  export interface ValidationErrors {
299
- [fieldName: string]: string[];
346
+ [fieldName: string]: string;
300
347
  }
301
348
 
302
349
  /**
303
- * Validator function type
350
+ * A field check: returns an error message, or `null` when the value passes.
351
+ *
352
+ * The second argument is the whole form, so validators like
353
+ * `validators.matches` can compare fields.
304
354
  */
305
355
  export interface Validator {
306
- (value: unknown): boolean | string | Promise<boolean | string>;
356
+ (value: unknown, formData?: Record<string, unknown>): string | null;
307
357
  }
308
358
 
309
359
  /**
310
- * Form validator class
360
+ * Per-field validators. A field maps to one validator or a list run in order,
361
+ * stopping at the first error.
362
+ */
363
+ export type ValidationSchema = Record<string, Validator | Validator[]>;
364
+
365
+ /**
366
+ * Runs a {@link ValidationSchema} and tracks errors and touched fields.
367
+ *
368
+ * ```ts
369
+ * const validator = new FormValidator({
370
+ * email: [validators.required(), validators.email()]
371
+ * });
372
+ * const { isValid, errors } = validator.validate({ email: '' });
373
+ * ```
311
374
  */
312
375
  export class FormValidator {
313
- constructor(rules: Record<string, Validator[]>);
314
- /** Synchronous validation */
315
- validate(data: Record<string, unknown>): ValidationResult;
316
- /** Asynchronous validation */
317
- validateAsync(data: Record<string, unknown>): Promise<ValidationResult>;
318
- /** Add a validation rule */
319
- addRule(field: string, validator: Validator): void;
320
- /** Remove a validation rule */
321
- removeRule(field: string, validator: Validator): void;
322
- /** Clear all rules for a field */
323
- clearRules(field: string): void;
376
+ constructor(schema?: ValidationSchema);
377
+
378
+ schema: ValidationSchema;
379
+ /** Errors from the last `validate()` call */
380
+ errors: ValidationErrors;
381
+ touched: Record<string, boolean>;
382
+
383
+ /**
384
+ * Check one field. Returns the first error, or `null` when it passes or has
385
+ * no validators.
386
+ */
387
+ validateField(
388
+ name: string,
389
+ value: unknown,
390
+ formData?: Record<string, unknown>
391
+ ): string | null;
392
+
393
+ /**
394
+ * Check every field in `formData` plus any schema field it omits, and store
395
+ * the result in `errors`.
396
+ */
397
+ validate(formData: Record<string, unknown>): ValidationResult;
398
+
399
+ /** Mark a field as touched */
400
+ touch(name: string): void;
401
+ isTouched(name: string): boolean;
402
+
403
+ /** Error recorded for a field by the last `validate()`, or `null` */
404
+ getError(name: string): string | null;
405
+ hasError(name: string): boolean;
406
+
407
+ clearErrors(): void;
408
+ clearTouched(): void;
409
+ /** Clear both errors and touched state */
410
+ reset(): void;
324
411
  }
325
412
 
326
413
  /**
327
414
  * Create a form validator
328
415
  */
329
- export function createValidator(rules: Record<string, Validator[]>): FormValidator;
416
+ export function createValidator(schema?: ValidationSchema): FormValidator;
330
417
 
331
418
  /**
332
- * Validate data against rules
419
+ * Validate data against a schema with a throwaway validator
333
420
  */
334
421
  export function validate(
335
- data: Record<string, unknown>,
336
- rules: Record<string, Validator[]>
422
+ formData: Record<string, unknown>,
423
+ schema?: ValidationSchema
337
424
  ): ValidationResult;
338
425
 
426
+ /**
427
+ * Run a list of validators against one value, returning the first error or
428
+ * `null`.
429
+ */
430
+ export function validateField(
431
+ value: unknown,
432
+ validatorList: Validator[],
433
+ formData?: Record<string, unknown>
434
+ ): string | null;
435
+
436
+ /**
437
+ * Run per-field validator lists over a whole form. Returns `null` when
438
+ * everything passes, rather than an empty object.
439
+ */
440
+ export function validateForm(
441
+ formData: Record<string, unknown>,
442
+ fieldValidators: Record<string, Validator[]>
443
+ ): ValidationErrors | null;
444
+
445
+ /**
446
+ * Add a validator to {@link validators} under `name`.
447
+ *
448
+ * Unlike the built-ins, which are factories, this stores `validatorFn`
449
+ * directly — so use it as `validators[name]`, not `validators[name]()`.
450
+ * Registering over a built-in therefore changes that name's calling
451
+ * convention.
452
+ */
453
+ export function registerValidator(name: string, validatorFn: Validator): void;
454
+
455
+ /**
456
+ * Combine validators into one that returns the first error, or `null`.
457
+ */
458
+ export function composeValidators(...validatorFns: Validator[]): Validator;
459
+
339
460
  // ============================================================================
340
461
  // Built-in Validators
341
462
  // ============================================================================
342
463
 
343
464
  /**
344
- * Built-in validator functions
465
+ * Built-in validator factories. Each returns a {@link Validator}, so call it
466
+ * before putting it in a schema: `validators.required()`, not
467
+ * `validators.required`.
468
+ *
469
+ * Validators added with {@link registerValidator} also appear here, but are
470
+ * stored as bare validators rather than factories.
345
471
  */
346
472
  export const validators: {
347
- /** Require a value to be present */
473
+ /** Reject `null`, `undefined` and the empty string */
348
474
  required(message?: string): Validator;
349
- /** Validate email format */
475
+ /** Validate email format; empty values pass */
350
476
  email(message?: string): Validator;
351
- /** Minimum string length */
352
- minLength(length: number, message?: string): Validator;
353
- /** Maximum string length */
354
- maxLength(length: number, message?: string): Validator;
477
+ /** Minimum length; empty values pass */
478
+ minLength(min: number, message?: string): Validator;
479
+ /** Maximum length; empty values pass */
480
+ maxLength(max: number, message?: string): Validator;
355
481
  /** Minimum numeric value */
356
- min(value: number, message?: string): Validator;
482
+ min(min: number, message?: string): Validator;
357
483
  /** Maximum numeric value */
358
- max(value: number, message?: string): Validator;
359
- /** Pattern matching */
360
- pattern(regex: RegExp, message?: string): Validator;
361
- /** Match another field's value */
362
- matches(field: string, message?: string): Validator;
363
- /** Validate URL format */
484
+ max(max: number, message?: string): Validator;
485
+ /** Parseable as a URL; empty values pass */
364
486
  url(message?: string): Validator;
365
- /** Validate as number */
366
- number(message?: string): Validator;
367
- /** Validate as integer */
368
- integer(message?: string): Validator;
369
- /** Validate as positive number */
370
- positive(message?: string): Validator;
371
- /** Validate as negative number */
372
- negative(message?: string): Validator;
373
- /** Validate date format */
374
- date(message?: string): Validator;
375
- /** Custom validation function */
376
- custom(fn: (value: unknown) => boolean | string, message?: string): Validator;
377
- /** Async validation function */
378
- async(fn: (value: unknown) => Promise<boolean | string>): Validator;
487
+ /** Match a regular expression; empty values pass */
488
+ pattern(regex: RegExp, message?: string): Validator;
489
+ /** Equal another field's value */
490
+ matches(fieldName: string, message?: string): Validator;
491
+ /** One of a fixed set; empty values pass */
492
+ oneOf(options: unknown[], message?: string): Validator;
493
+ /** Fail when `fn` returns falsy */
494
+ custom(
495
+ fn: (value: unknown, formData?: Record<string, unknown>) => boolean,
496
+ message?: string
497
+ ): Validator;
498
+ [name: string]: Validator | ((...args: never[]) => Validator);
379
499
  };
380
500
 
381
501
  // ============================================================================