@wiltech-labs/ngx-forms 0.1.0 → 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/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,28 @@ 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
+ | `CHIPS` | `chips()` | `ChipsField` | `string[]` | `mat-chip-grid`, one token typed at a time |
214
+ | `RANGE` | `range()` | `SliderField` | `number` | `mat-slider` (`min`/`max`/`step`, default 0–100) |
215
+ | `BUSINESS_DATE` | `businessDate()` | `BusinessDateField` | `'YYYY-MM-DD'` | Datepicker |
216
+ | `BUSINESS_TIME` | `businessTime()` | `BusinessTimeField` | `'HH:mm'` | Timepicker |
217
+ | `INSTANT_DATE_TIME` | `instantDateTime()` | `InstantDateTimeField` | UTC `'YYYY-MM-DDThh:mm:ssZ'` | Datepicker + timepicker |
186
218
 
187
219
  Use `hidden(name)` on the builder for a field that belongs to the model and its validation but is never shown.
188
220
 
@@ -195,36 +227,59 @@ Use `hidden(name)` on the builder for a field that belongs to the model and its
195
227
  All three display and parse dates in `dateTimeConfig.locale` (default `'en-GB'`, so `31/12/2026`),
196
228
  never the browser's locale — typing `31/12/2026` works for any day-first locale.
197
229
 
230
+ ### App-wide default locale
231
+
232
+ `dateTimeConfig.locale` set on a field always wins. When it's unset, the fallback comes from
233
+ `NGX_FORMS_LOCALE` (an `InjectionToken<() => string>`), not a hardcoded constant — provide it once
234
+ in `app.config.ts` to change every field's default locale together, e.g. wired to
235
+ [`@wiltech-labs/ngx-translations`](../translations):
236
+
237
+ ```ts
238
+ import { inject } from '@angular/core';
239
+ import { NGX_FORMS_LOCALE } from '@wiltech-labs/ngx-forms';
240
+ import { TranslationsService } from '@wiltech-labs/ngx-translations';
241
+
242
+ {
243
+ provide: NGX_FORMS_LOCALE,
244
+ useFactory: () => { const translations = inject(TranslationsService); return () => translations.locale(); }
245
+ }
246
+ ```
247
+
248
+ Unlike `ngx-dates`' `NGX_DATES_LOCALE` (read-only display text), this locale also decides which
249
+ _typed_ day/month order the picker's input accepts — changing it changes user input behaviour, not
250
+ just wording, so it isn't defaulted to the browser's own language the way `ngx-dates` is. Leave it
251
+ unset and every field keeps today's default (`'en-GB'`).
252
+
198
253
  ## Field definition
199
254
 
200
255
  ```typescript
201
256
  interface FieldDef {
202
- name: string; // Property name in the model
257
+ name: string; // Property name in the model
203
258
  type: FormFieldType;
204
259
  label: string;
205
260
  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 }]
261
+ minLength?: number; // Text fields
262
+ maxLength?: number; // Text fields
263
+ pattern?: RegExp | string; // Text fields: a RegExp is used as-is; a string must match the whole value
264
+ patternMessage?: string; // Error when `pattern` doesn't match. Default: "<label> is not in the expected format"
265
+ min?: number; // Number and range fields
266
+ max?: number; // Number and range fields
267
+ step?: number; // Range fields
268
+ options?: FieldOption[]; // Radio and select: [{ label, value }]
214
269
  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%'
270
+ dateTimeConfig?: DateTimeConfig; // Business date/time and instant date-time
271
+ hidden?: boolean; // Part of the model, never rendered
272
+ disabled?: boolean; // Rendered but not editable
273
+ hint?: string; // Help text under the field
274
+ maxWidth?: string; // DynamicForm only: caps this field's width, Eg '400px'
220
275
  }
221
276
 
222
277
  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'
278
+ locale?: string; // Display/typing format, Eg 'en-GB', 'en-US', 'el-CY'. Default 'en-GB'.
279
+ min?: string; // In the field's own value format: '2026-12-25', '09:00' or '2026-01-01T00:00:00Z'
225
280
  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
281
+ timeZone?: string; // Instant only: IANA zone, Eg 'Europe/London'. Default: the user's timezone.
282
+ disambiguation?: 'earlier' | 'later'; // Instant only: which occurrence when a time happens twice
228
283
  }
229
284
  ```
230
285
 
@@ -232,15 +287,15 @@ interface DateTimeConfig {
232
287
 
233
288
  `toSchema()` turns each `FieldDef` into Signals Forms rules, with messages built from the label:
234
289
 
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) |
290
+ | Config | Rule | Message |
291
+ | ------------------------------ | ----------------------------- | ------------------------------------------------------------------ |
292
+ | `required: true` | `required()` | "Email is required" |
293
+ | `minLength` / `maxLength` | `minLength()` / `maxLength()` | "Username must be at least 3 characters" |
294
+ | `min` / `max` | `min()` / `max()` | "Age must be at least 18" |
295
+ | type `EMAIL` | `email()` | "Email must be a valid email address" |
296
+ | `pattern` (+ `patternMessage`) | `pattern()` | your `patternMessage`, or "Username is not in the expected format" |
297
+ | `dateTimeConfig.min` / `max` | custom | "Closed on cannot be after 2026-12-31" |
298
+ | `disabled: true` | `disabled()` | — (field shown but not editable) |
244
299
 
245
300
  There's no separate `requiredTrue`: `required` treats `false` as empty, so `required: true` on a
246
301
  checkbox means it must be ticked (Eg "Accept terms").
@@ -249,8 +304,8 @@ checkbox means it must be ticked (Eg "Accept terms").
249
304
  formConfig<SignUp>('signUp')
250
305
  .text('username', 'Username', {
251
306
  required: true,
252
- pattern: '[a-z0-9.]+', // whole value must match
253
- patternMessage: 'Use lowercase letters, digits and dots only'
307
+ pattern: '[a-z0-9.]+', // whole value must match
308
+ patternMessage: 'Use lowercase letters, digits and dots only',
254
309
  })
255
310
  .email('email', 'Email', { required: true })
256
311
  .checkbox('terms', 'I accept the terms', { required: true })