@wiltech-labs/ngx-forms 0.1.0 → 1.0.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/README.md CHANGED
@@ -21,7 +21,9 @@ npm install @wiltech-labs/ngx-forms
21
21
  Peer dependencies: `@angular/core`, `@angular/common`, `@angular/forms`, `@angular/material` (all `^22`).
22
22
  `temporal-polyfill` is installed with the package.
23
23
 
24
- The fields are Angular Material components, so the app needs a Material theme and the Roboto font:
24
+ The fields are Angular Material components and take the app's own M3 theme (`mat.theme()`) — in an
25
+ app that follows the house conventions there's nothing to add. An app without a theme needs one, and
26
+ the Roboto font, e.g.:
25
27
 
26
28
  ```css
27
29
  /* styles.css */
@@ -31,7 +33,10 @@ The fields are Angular Material components, so the app needs a Material theme an
31
33
  ```html
32
34
  <!-- index.html -->
33
35
  <link rel="preconnect" href="https://fonts.gstatic.com" />
34
- <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet" />
36
+ <link
37
+ href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
38
+ rel="stylesheet"
39
+ />
35
40
  ```
36
41
 
37
42
  ## Usage with DynamicForm
@@ -42,7 +47,8 @@ The fields are Angular Material components, so the app needs a Material theme an
42
47
  import { formConfig } from '@wiltech-labs/ngx-forms';
43
48
  import type { BaseSchema, SchemaConfig } from '@wiltech-labs/ngx-forms';
44
49
 
45
- export interface FlightSchema extends BaseSchema { // BaseSchema adds `id` and `schemaType`
50
+ export interface FlightSchema extends BaseSchema {
51
+ // BaseSchema adds `id` and `schemaType`
46
52
  schemaType: 'flight';
47
53
  from: string;
48
54
  to: string;
@@ -57,12 +63,17 @@ export const flightFormConfig: SchemaConfig<FlightSchema> = formConfig<FlightSch
57
63
  .instantDateTime('departure', 'Departure', {
58
64
  required: true,
59
65
  hint: 'Local time at the departure airport',
60
- dateTimeConfig: { timeZone: 'Europe/London', min: '2026-01-01T00:00:00Z' }
66
+ dateTimeConfig: { timeZone: 'Europe/London', min: '2026-01-01T00:00:00Z' },
61
67
  })
62
- .select('cabin', 'Cabin', [
63
- { label: 'Economy', value: 'economy' },
64
- { label: 'Business', value: 'business' }
65
- ], { required: true })
68
+ .select(
69
+ 'cabin',
70
+ 'Cabin',
71
+ [
72
+ { label: 'Economy', value: 'economy' },
73
+ { label: 'Business', value: 'business' },
74
+ ],
75
+ { required: true },
76
+ )
66
77
  .checkbox('delayed', 'Delayed')
67
78
  .build({ from: '', to: '', departure: '', cabin: 'economy', delayed: false });
68
79
  ```
@@ -83,18 +94,21 @@ import type { FlightSchema } from './flight-form.config';
83
94
  selector: 'app-flight-form',
84
95
  imports: [DynamicForm],
85
96
  template: `
86
- <app-dynamic-form
97
+ <ngx-dynamic-form
87
98
  [metaInfo]="config.fields"
88
99
  [dynamicForm]="flightForm"
89
100
  (onFormSubmit)="save()"
90
101
  (onFormClear)="clear()"
91
102
  />
92
- `
103
+ `,
93
104
  })
94
105
  export class FlightFormComponent {
95
106
  protected readonly config = flightFormConfig;
96
107
  protected readonly flight = signal(flightFormConfig.initialValue);
97
- protected readonly flightForm = form(this.flight, toSchema<FlightSchema>(flightFormConfig.fields));
108
+ protected readonly flightForm = form(
109
+ this.flight,
110
+ toSchema<FlightSchema>(flightFormConfig.fields),
111
+ );
98
112
 
99
113
  protected save(): void {
100
114
  console.log('Saved', this.flight());
@@ -107,6 +121,10 @@ export class FlightFormComponent {
107
121
  }
108
122
  ```
109
123
 
124
+ The actions sit right-aligned: a text **Clear** and a filled **Save**. Name them after what they do
125
+ with `clearLabel`/`submitLabel` — e.g. `clearLabel="Cancel"` when that button leaves the form (it
126
+ still emits `onFormClear`).
127
+
110
128
  `toSchema()` turns the field definitions into Signals Forms validation rules. Save is only enabled
111
129
  while the form is valid, and `onFormSubmit` only fires for a valid form.
112
130
 
@@ -120,7 +138,11 @@ object; it doesn't need `id` or `schemaType`.
120
138
  import { Component, signal } from '@angular/core';
121
139
  import { form } from '@angular/forms/signals';
122
140
  import {
123
- BusinessDateField, CheckboxField, FormFieldType, TextField, toSchema
141
+ BusinessDateField,
142
+ CheckboxField,
143
+ FormFieldType,
144
+ TextField,
145
+ toSchema,
124
146
  } from '@wiltech-labs/ngx-forms';
125
147
  import type { FieldDef } from '@wiltech-labs/ngx-forms';
126
148
 
@@ -134,11 +156,11 @@ interface Newsletter {
134
156
  selector: 'app-newsletter',
135
157
  imports: [TextField, BusinessDateField, CheckboxField],
136
158
  template: `
137
- <app-text-field [fieldDef]="fields.email" [field]="newsletterForm.email" />
138
- <app-business-date-field [fieldDef]="fields.startDate" [field]="newsletterForm.startDate" />
139
- <app-checkbox-field [fieldDef]="fields.agree" [field]="newsletterForm.agree" />
159
+ <ngx-text-field [fieldDef]="fields.email" [field]="newsletterForm.email" />
160
+ <ngx-business-date-field [fieldDef]="fields.startDate" [field]="newsletterForm.startDate" />
161
+ <ngx-checkbox-field [fieldDef]="fields.agree" [field]="newsletterForm.agree" />
140
162
  <button [disabled]="newsletterForm().invalid()" (click)="subscribe()">Subscribe</button>
141
- `
163
+ `,
142
164
  })
143
165
  export class NewsletterComponent {
144
166
  protected readonly fields = {
@@ -148,13 +170,21 @@ export class NewsletterComponent {
148
170
  type: FormFieldType.BUSINESS_DATE,
149
171
  label: 'Start from',
150
172
  required: true,
151
- dateTimeConfig: { min: '2026-01-01', locale: 'en-GB' }
173
+ dateTimeConfig: { min: '2026-01-01', locale: 'en-GB' },
174
+ },
175
+ agree: {
176
+ name: 'agree',
177
+ type: FormFieldType.CHECKBOX,
178
+ label: 'I agree to receive emails',
179
+ required: true,
152
180
  },
153
- agree: { name: 'agree', type: FormFieldType.CHECKBOX, label: 'I agree to receive emails', required: true }
154
181
  } satisfies Record<keyof Newsletter, FieldDef>;
155
182
 
156
183
  protected readonly newsletter = signal<Newsletter>({ email: '', startDate: '', agree: false });
157
- protected readonly newsletterForm = form(this.newsletter, toSchema<Newsletter>(Object.values(this.fields)));
184
+ protected readonly newsletterForm = form(
185
+ this.newsletter,
186
+ toSchema<Newsletter>(Object.values(this.fields)),
187
+ );
158
188
 
159
189
  protected subscribe(): void {
160
190
  console.log(this.newsletter());
@@ -163,26 +193,27 @@ export class NewsletterComponent {
163
193
  ```
164
194
 
165
195
  Each component renders its own label, hint, required marker and errors, and fills the width of its
166
- container (`DynamicForm` caps each field at 400px, overridable per field with `maxWidth`).
196
+ container. `DynamicForm` adds no padding or background of its own — put it in the app's surface
197
+ (e.g. a tonal panel); cap a single field with `maxWidth`.
167
198
 
168
199
  ## Field types
169
200
 
170
- | `FormFieldType` | Builder method | Component | Value | Material control |
171
- |---|---|---|---|---|
172
- | `TEXT` | `text()` | `TextField` | `string` | `matInput` |
173
- | `EMAIL` | `email()` | `TextField` | `string` | `matInput` (`type="email"`), plus a valid-address check |
174
- | `PASSWORD` | `password()` | `TextField` | `string` | `matInput` (masked) |
175
- | `SEARCH` | `search()` | `TextField` | `string` | `matInput` |
176
- | `NUMBER` | `number()` | `TextField` | `number` | `matInput` |
177
- | `TEXTAREA` | `textarea()` | `TextareaField` | `string` | `matInput` textarea, auto-growing |
178
- | `CODE` | `code()` | `TextareaField` | `string` | Monospaced textarea, Tab indents |
179
- | `CHECKBOX` | `checkbox()` | `CheckboxField` | `boolean` | `mat-checkbox` |
180
- | `RADIO` | `radio(name, label, choices)` | `RadioField` | option value | `mat-radio-group` |
181
- | `SELECT` | `select(name, label, choices)` | `SelectField` | option value | `mat-select` |
182
- | `RANGE` | `range()` | `SliderField` | `number` | `mat-slider` (`min`/`max`/`step`, default 0–100) |
183
- | `BUSINESS_DATE` | `businessDate()` | `BusinessDateField` | `'YYYY-MM-DD'` | Datepicker |
184
- | `BUSINESS_TIME` | `businessTime()` | `BusinessTimeField` | `'HH:mm'` | Timepicker |
185
- | `INSTANT_DATE_TIME` | `instantDateTime()` | `InstantDateTimeField` | UTC `'YYYY-MM-DDThh:mm:ssZ'` | Datepicker + timepicker |
201
+ | `FormFieldType` | Builder method | Component | Value | Material control |
202
+ | ------------------- | ------------------------------ | ---------------------- | ---------------------------- | ------------------------------------------------------- |
203
+ | `TEXT` | `text()` | `TextField` | `string` | `matInput` |
204
+ | `EMAIL` | `email()` | `TextField` | `string` | `matInput` (`type="email"`), plus a valid-address check |
205
+ | `PASSWORD` | `password()` | `TextField` | `string` | `matInput` (masked) |
206
+ | `SEARCH` | `search()` | `TextField` | `string` | `matInput` |
207
+ | `NUMBER` | `number()` | `TextField` | `number` | `matInput` |
208
+ | `TEXTAREA` | `textarea()` | `TextareaField` | `string` | `matInput` textarea, auto-growing |
209
+ | `CODE` | `code()` | `TextareaField` | `string` | Monospaced textarea, Tab indents |
210
+ | `CHECKBOX` | `checkbox()` | `CheckboxField` | `boolean` | `mat-checkbox` |
211
+ | `RADIO` | `radio(name, label, choices)` | `RadioField` | option value | `mat-radio-group` |
212
+ | `SELECT` | `select(name, label, choices)` | `SelectField` | option value | `mat-select` |
213
+ | `RANGE` | `range()` | `SliderField` | `number` | `mat-slider` (`min`/`max`/`step`, default 0–100) |
214
+ | `BUSINESS_DATE` | `businessDate()` | `BusinessDateField` | `'YYYY-MM-DD'` | Datepicker |
215
+ | `BUSINESS_TIME` | `businessTime()` | `BusinessTimeField` | `'HH:mm'` | Timepicker |
216
+ | `INSTANT_DATE_TIME` | `instantDateTime()` | `InstantDateTimeField` | UTC `'YYYY-MM-DDThh:mm:ssZ'` | Datepicker + timepicker |
186
217
 
187
218
  Use `hidden(name)` on the builder for a field that belongs to the model and its validation but is never shown.
188
219
 
@@ -195,36 +226,59 @@ Use `hidden(name)` on the builder for a field that belongs to the model and its
195
226
  All three display and parse dates in `dateTimeConfig.locale` (default `'en-GB'`, so `31/12/2026`),
196
227
  never the browser's locale — typing `31/12/2026` works for any day-first locale.
197
228
 
229
+ ### App-wide default locale
230
+
231
+ `dateTimeConfig.locale` set on a field always wins. When it's unset, the fallback comes from
232
+ `NGX_FORMS_LOCALE` (an `InjectionToken<() => string>`), not a hardcoded constant — provide it once
233
+ in `app.config.ts` to change every field's default locale together, e.g. wired to
234
+ [`@wiltech-labs/ngx-translations`](../translations):
235
+
236
+ ```ts
237
+ import { inject } from '@angular/core';
238
+ import { NGX_FORMS_LOCALE } from '@wiltech-labs/ngx-forms';
239
+ import { TranslationsService } from '@wiltech-labs/ngx-translations';
240
+
241
+ {
242
+ provide: NGX_FORMS_LOCALE,
243
+ useFactory: () => { const translations = inject(TranslationsService); return () => translations.locale(); }
244
+ }
245
+ ```
246
+
247
+ Unlike `ngx-dates`' `NGX_DATES_LOCALE` (read-only display text), this locale also decides which
248
+ _typed_ day/month order the picker's input accepts — changing it changes user input behaviour, not
249
+ just wording, so it isn't defaulted to the browser's own language the way `ngx-dates` is. Leave it
250
+ unset and every field keeps today's default (`'en-GB'`).
251
+
198
252
  ## Field definition
199
253
 
200
254
  ```typescript
201
255
  interface FieldDef {
202
- name: string; // Property name in the model
256
+ name: string; // Property name in the model
203
257
  type: FormFieldType;
204
258
  label: string;
205
259
  required?: boolean;
206
- minLength?: number; // Text fields
207
- maxLength?: number; // Text fields
208
- pattern?: RegExp | string; // Text fields: a RegExp is used as-is; a string must match the whole value
209
- patternMessage?: string; // Error when `pattern` doesn't match. Default: "<label> is not in the expected format"
210
- min?: number; // Number and range fields
211
- max?: number; // Number and range fields
212
- step?: number; // Range fields
213
- options?: FieldOption[]; // Radio and select: [{ label, value }]
260
+ minLength?: number; // Text fields
261
+ maxLength?: number; // Text fields
262
+ pattern?: RegExp | string; // Text fields: a RegExp is used as-is; a string must match the whole value
263
+ patternMessage?: string; // Error when `pattern` doesn't match. Default: "<label> is not in the expected format"
264
+ min?: number; // Number and range fields
265
+ max?: number; // Number and range fields
266
+ step?: number; // Range fields
267
+ options?: FieldOption[]; // Radio and select: [{ label, value }]
214
268
  orientation?: 'horizontal' | 'vertical'; // Radio: a wrapping row (default) or one option per line
215
- dateTimeConfig?: DateTimeConfig; // Business date/time and instant date-time
216
- hidden?: boolean; // Part of the model, never rendered
217
- disabled?: boolean; // Rendered but not editable
218
- hint?: string; // Help text under the field
219
- maxWidth?: string; // DynamicForm only: overrides the 400px cap, Eg '900px' or '100%'
269
+ dateTimeConfig?: DateTimeConfig; // Business date/time and instant date-time
270
+ hidden?: boolean; // Part of the model, never rendered
271
+ disabled?: boolean; // Rendered but not editable
272
+ hint?: string; // Help text under the field
273
+ maxWidth?: string; // DynamicForm only: caps this field's width, Eg '400px'
220
274
  }
221
275
 
222
276
  interface DateTimeConfig {
223
- locale?: string; // Display/typing format, Eg 'en-GB', 'en-US', 'el-CY'. Default 'en-GB'.
224
- min?: string; // In the field's own value format: '2026-12-25', '09:00' or '2026-01-01T00:00:00Z'
277
+ locale?: string; // Display/typing format, Eg 'en-GB', 'en-US', 'el-CY'. Default 'en-GB'.
278
+ min?: string; // In the field's own value format: '2026-12-25', '09:00' or '2026-01-01T00:00:00Z'
225
279
  max?: string;
226
- timeZone?: string; // Instant only: IANA zone, Eg 'Europe/London'. Default: the user's timezone.
227
- disambiguation?: 'earlier' | 'later'; // Instant only: which occurrence when a time happens twice
280
+ timeZone?: string; // Instant only: IANA zone, Eg 'Europe/London'. Default: the user's timezone.
281
+ disambiguation?: 'earlier' | 'later'; // Instant only: which occurrence when a time happens twice
228
282
  }
229
283
  ```
230
284
 
@@ -232,15 +286,15 @@ interface DateTimeConfig {
232
286
 
233
287
  `toSchema()` turns each `FieldDef` into Signals Forms rules, with messages built from the label:
234
288
 
235
- | Config | Rule | Message |
236
- |---|---|---|
237
- | `required: true` | `required()` | "Email is required" |
238
- | `minLength` / `maxLength` | `minLength()` / `maxLength()` | "Username must be at least 3 characters" |
239
- | `min` / `max` | `min()` / `max()` | "Age must be at least 18" |
240
- | type `EMAIL` | `email()` | "Email must be a valid email address" |
241
- | `pattern` (+ `patternMessage`) | `pattern()` | your `patternMessage`, or "Username is not in the expected format" |
242
- | `dateTimeConfig.min` / `max` | custom | "Closed on cannot be after 2026-12-31" |
243
- | `disabled: true` | `disabled()` | — (field shown but not editable) |
289
+ | Config | Rule | Message |
290
+ | ------------------------------ | ----------------------------- | ------------------------------------------------------------------ |
291
+ | `required: true` | `required()` | "Email is required" |
292
+ | `minLength` / `maxLength` | `minLength()` / `maxLength()` | "Username must be at least 3 characters" |
293
+ | `min` / `max` | `min()` / `max()` | "Age must be at least 18" |
294
+ | type `EMAIL` | `email()` | "Email must be a valid email address" |
295
+ | `pattern` (+ `patternMessage`) | `pattern()` | your `patternMessage`, or "Username is not in the expected format" |
296
+ | `dateTimeConfig.min` / `max` | custom | "Closed on cannot be after 2026-12-31" |
297
+ | `disabled: true` | `disabled()` | — (field shown but not editable) |
244
298
 
245
299
  There's no separate `requiredTrue`: `required` treats `false` as empty, so `required: true` on a
246
300
  checkbox means it must be ticked (Eg "Accept terms").
@@ -249,8 +303,8 @@ checkbox means it must be ticked (Eg "Accept terms").
249
303
  formConfig<SignUp>('signUp')
250
304
  .text('username', 'Username', {
251
305
  required: true,
252
- pattern: '[a-z0-9.]+', // whole value must match
253
- patternMessage: 'Use lowercase letters, digits and dots only'
306
+ pattern: '[a-z0-9.]+', // whole value must match
307
+ patternMessage: 'Use lowercase letters, digits and dots only',
254
308
  })
255
309
  .email('email', 'Email', { required: true })
256
310
  .checkbox('terms', 'I accept the terms', { required: true })