@wiltech-labs/ngx-forms 1.0.4 → 1.0.6

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
@@ -1,374 +1,389 @@
1
- # @wiltech-labs/ngx-forms
2
-
3
- Configuration-driven Angular forms built on the **Signals Forms API** (`@angular/forms/signals`)
4
- and **Angular Material**. Describe your fields once, then either render the whole form with
5
- `DynamicForm` or place individual field components in your own layout.
6
-
7
- ## Features
8
-
9
- - **`DynamicForm`** — renders a full form (fields + Save/Clear) from a list of field definitions
10
- - **Field components** — every field type is a standalone Material component you can use on its own
11
- - **`formConfig<T>()` builder** — fluent, type-checked way to write the config; field names are checked against your model
12
- - **Validation from config** — `required`, `minLength`/`maxLength`, `min`/`max`, date/time ranges and `disabled`, shown inline under each field once touched
13
- - **Date and time done properly** — timezone-aware instants and timezone-free business dates/times, displayed in a chosen locale rather than the browser's
14
-
15
- ## Installation
16
-
17
- ```bash
18
- npm install @wiltech-labs/ngx-forms
19
- ```
20
-
21
- Peer dependencies: `@angular/core`, `@angular/common`, `@angular/forms`, `@angular/material` (all `^22`).
22
- `temporal-polyfill` is installed with the package.
23
-
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.:
27
-
28
- ```css
29
- /* styles.css */
30
- @import '@angular/material/prebuilt-themes/azure-blue.css';
31
- ```
32
-
33
- ```html
34
- <!-- index.html -->
35
- <link rel="preconnect" href="https://fonts.gstatic.com" />
36
- <link
37
- href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
38
- rel="stylesheet"
39
- />
40
- ```
41
-
42
- ## Usage with DynamicForm
43
-
44
- ### 1. Describe the model and its fields
45
-
46
- ```typescript
47
- import { formConfig } from '@wiltech-labs/ngx-forms';
48
- import type { BaseSchema, SchemaConfig } from '@wiltech-labs/ngx-forms';
49
-
50
- export interface FlightSchema extends BaseSchema {
51
- // BaseSchema adds `id` and `schemaType`
52
- schemaType: 'flight';
53
- from: string;
54
- to: string;
55
- departure: string;
56
- cabin: string;
57
- delayed: boolean;
58
- }
59
-
60
- export const flightFormConfig: SchemaConfig<FlightSchema> = formConfig<FlightSchema>('flight')
61
- .text('from', 'Departure City', { required: true, minLength: 3, maxLength: 20 })
62
- .text('to', 'Destination City', { required: true, minLength: 3, maxLength: 20 })
63
- .instantDateTime('departure', 'Departure', {
64
- required: true,
65
- hint: 'Local time at the departure airport',
66
- dateTimeConfig: { timeZone: 'Europe/London', min: '2026-01-01T00:00:00Z' },
67
- })
68
- .select(
69
- 'cabin',
70
- 'Cabin',
71
- [
72
- { label: 'Economy', value: 'economy' },
73
- { label: 'Business', value: 'business' },
74
- ],
75
- { required: true },
76
- )
77
- .checkbox('delayed', 'Delayed')
78
- .build({ from: '', to: '', departure: '', cabin: 'economy', delayed: false });
79
- ```
80
-
81
- `build()` returns `{ schemaType, fields, initialValue }`, with `id` and `schemaType` filled in on the
82
- initial value. A misspelled or renamed field name is a compile error.
83
-
84
- ### 2. Render it
85
-
86
- ```typescript
87
- import { Component, signal } from '@angular/core';
88
- import { form } from '@angular/forms/signals';
89
- import { DynamicForm, toSchema } from '@wiltech-labs/ngx-forms';
90
- import { flightFormConfig } from './flight-form.config';
91
- import type { FlightSchema } from './flight-form.config';
92
-
93
- @Component({
94
- selector: 'app-flight-form',
95
- imports: [DynamicForm],
96
- template: `
97
- <ngx-dynamic-form
98
- [metaInfo]="config.fields"
99
- [dynamicForm]="flightForm"
100
- (onFormSubmit)="save()"
101
- (onFormClear)="clear()"
102
- />
103
- `,
104
- })
105
- export class FlightFormComponent {
106
- protected readonly config = flightFormConfig;
107
- protected readonly flight = signal(flightFormConfig.initialValue);
108
- protected readonly flightForm = form(
109
- this.flight,
110
- toSchema<FlightSchema>(flightFormConfig.fields),
111
- );
112
-
113
- protected save(): void {
114
- console.log('Saved', this.flight());
115
- }
116
-
117
- protected clear(): void {
118
- this.flight.set(flightFormConfig.initialValue);
119
- this.flightForm().reset();
120
- }
121
- }
122
- ```
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
-
128
- `toSchema()` turns the field definitions into Signals Forms validation rules. Save is only enabled
129
- while the form is valid, and `onFormSubmit` only fires for a valid form.
130
-
131
- ## Usage with individual fields
132
-
133
- Every field component takes the same two inputs — a `[fieldDef]` and the `[field]` from your
134
- Signals Form — so you can use them without `DynamicForm`, in any layout. The model can be any plain
135
- object; it doesn't need `id` or `schemaType`.
136
-
137
- ```typescript
138
- import { Component, signal } from '@angular/core';
139
- import { form } from '@angular/forms/signals';
140
- import {
141
- BusinessDateField,
142
- CheckboxField,
143
- FormFieldType,
144
- TextField,
145
- toSchema,
146
- } from '@wiltech-labs/ngx-forms';
147
- import type { FieldDef } from '@wiltech-labs/ngx-forms';
148
-
149
- interface Newsletter {
150
- email: string;
151
- startDate: string;
152
- agree: boolean;
153
- }
154
-
155
- @Component({
156
- selector: 'app-newsletter',
157
- imports: [TextField, BusinessDateField, CheckboxField],
158
- template: `
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" />
162
- <button [disabled]="newsletterForm().invalid()" (click)="subscribe()">Subscribe</button>
163
- `,
164
- })
165
- export class NewsletterComponent {
166
- protected readonly fields = {
167
- email: { name: 'email', type: FormFieldType.TEXT, label: 'Email', required: true },
168
- startDate: {
169
- name: 'startDate',
170
- type: FormFieldType.BUSINESS_DATE,
171
- label: 'Start from',
172
- required: true,
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,
180
- },
181
- } satisfies Record<keyof Newsletter, FieldDef>;
182
-
183
- protected readonly newsletter = signal<Newsletter>({ email: '', startDate: '', agree: false });
184
- protected readonly newsletterForm = form(
185
- this.newsletter,
186
- toSchema<Newsletter>(Object.values(this.fields)),
187
- );
188
-
189
- protected subscribe(): void {
190
- console.log(this.newsletter());
191
- }
192
- }
193
- ```
194
-
195
- Each component renders its own label, hint, required marker and errors, and fills the width of its
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`.
198
-
199
- ## Field types
200
-
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
- | `THEME` | `theme(name, label, choices?)` | `ThemeField` | `'light'` / `'dark'` / `'system'` | `mat-button-toggle-group`, sun/moon/auto icon row |
215
- | `RANGE` | `range()` | `SliderField` | `number` | `mat-slider` (`min`/`max`/`step`, default 0–100) |
216
- | `BUSINESS_DATE` | `businessDate()` | `BusinessDateField` | `'YYYY-MM-DD'` | Datepicker |
217
- | `BUSINESS_TIME` | `businessTime()` | `BusinessTimeField` | `'HH:mm'` | Timepicker |
218
- | `INSTANT_DATE_TIME` | `instantDateTime()` | `InstantDateTimeField` | UTC `'YYYY-MM-DDThh:mm:ssZ'` | Datepicker + timepicker |
219
-
220
- Use `hidden(name)` on the builder for a field that belongs to the model and its validation but is never shown.
221
-
222
- ### Choosing a date/time field
223
-
224
- - **`BUSINESS_DATE`** — a calendar date with no timezone: birthdays, holidays, due dates. Christmas Day is the 25th wherever it's read.
225
- - **`BUSINESS_TIME`** — a time of day with no date or timezone: opening hours, daily schedules. "Opens at 09:00" means 09:00 in whichever shop you walk into.
226
- - **`INSTANT_DATE_TIME`** — an exact moment, stored in UTC and edited as the wall-clock time of `dateTimeConfig.timeZone`: meetings, deadlines, flights. When a time falls in a daylight-saving gap or overlap, the field explains how it was resolved.
227
-
228
- All three display and parse dates in `dateTimeConfig.locale` (default `'en-GB'`, so `31/12/2026`),
229
- never the browser's locale — typing `31/12/2026` works for any day-first locale.
230
-
231
- ### App-wide default locale
232
-
233
- `dateTimeConfig.locale` set on a field always wins. When it's unset, the fallback comes from
234
- `NGX_FORMS_LOCALE` (an `InjectionToken<() => string>`), not a hardcoded constant — provide it once
235
- in `app.config.ts` to change every field's default locale together, e.g. wired to
236
- [`@wiltech-labs/ngx-translations`](../translations):
237
-
238
- ```ts
239
- import { inject } from '@angular/core';
240
- import { NGX_FORMS_LOCALE } from '@wiltech-labs/ngx-forms';
241
- import { TranslationsService } from '@wiltech-labs/ngx-translations';
242
-
243
- {
244
- provide: NGX_FORMS_LOCALE,
245
- useFactory: () => { const translations = inject(TranslationsService); return () => translations.locale(); }
246
- }
247
- ```
248
-
249
- Unlike `ngx-dates`' `NGX_DATES_LOCALE` (read-only display text), this locale also decides which
250
- _typed_ day/month order the picker's input accepts — changing it changes user input behaviour, not
251
- just wording, so it isn't defaulted to the browser's own language the way `ngx-dates` is. Leave it
252
- unset and every field keeps today's default (`'en-GB'`).
253
-
254
- ### Theme field labels
255
-
256
- `ThemeField`'s three icon toggles (sun = `'light'`, moon = `'dark'`, auto = `'system'`, i.e. follow
257
- the OS preference) have no visible text — their accessible label comes from `choices`, same
258
- `FieldOption[]` mechanism `radio()`/`select()` already use, not a separate config or token. The three
259
- values are always fixed, each tied to its own icon, so `choices` only ever supplies each one's label
260
- text — defaults to English `'Light'`/`'Dark'`/`'System'` when omitted (any you leave out keep their
261
- default):
262
-
263
- ```ts
264
- formConfig<MySchema>('settings').theme('colorScheme', 'Color Scheme', [
265
- { label: 'Day', value: 'light' },
266
- { label: 'Night', value: 'dark' },
267
- { label: 'Auto', value: 'system' },
268
- ]);
269
- ```
270
-
271
- ## Field definition
272
-
273
- ```typescript
274
- interface FieldDef {
275
- name: string; // Property name in the model
276
- type: FormFieldType;
277
- label: string;
278
- required?: boolean;
279
- minLength?: number; // Text fields
280
- maxLength?: number; // Text fields
281
- pattern?: RegExp | string; // Text fields: a RegExp is used as-is; a string must match the whole value
282
- patternMessage?: string; // Error when `pattern` doesn't match. Default: "<label> is not in the expected format"
283
- min?: number; // Number and range fields
284
- max?: number; // Number and range fields
285
- step?: number; // Range fields
286
- options?: FieldOption[]; // Radio and select: [{ label, value }]. Theme: label text only, value is always 'light'/'dark'/'system'
287
- orientation?: 'horizontal' | 'vertical'; // Radio: a wrapping row (default) or one option per line
288
- dateTimeConfig?: DateTimeConfig; // Business date/time and instant date-time
289
- hidden?: boolean; // Part of the model, never rendered
290
- disabled?: boolean; // Rendered but not editable
291
- hint?: string; // Help text under the field
292
- maxWidth?: string; // DynamicForm only: caps this field's width, Eg '400px'
293
- }
294
-
295
- interface DateTimeConfig {
296
- locale?: string; // Display/typing format, Eg 'en-GB', 'en-US', 'el-CY'. Default 'en-GB'.
297
- min?: string; // In the field's own value format: '2026-12-25', '09:00' or '2026-01-01T00:00:00Z'
298
- max?: string;
299
- timeZone?: string; // Instant only: IANA zone, Eg 'Europe/London'. Default: the user's timezone.
300
- disambiguation?: 'earlier' | 'later'; // Instant only: which occurrence when a time happens twice
301
- }
302
- ```
303
-
304
- ## Validation
305
-
306
- `toSchema()` turns each `FieldDef` into Signals Forms rules, with messages built from the label:
307
-
308
- | Config | Rule | Message |
309
- | ------------------------------ | ----------------------------- | ------------------------------------------------------------------ |
310
- | `required: true` | `required()` | "Email is required" |
311
- | `minLength` / `maxLength` | `minLength()` / `maxLength()` | "Username must be at least 3 characters" |
312
- | `min` / `max` | `min()` / `max()` | "Age must be at least 18" |
313
- | type `EMAIL` | `email()` | "Email must be a valid email address" |
314
- | `pattern` (+ `patternMessage`) | `pattern()` | your `patternMessage`, or "Username is not in the expected format" |
315
- | `dateTimeConfig.min` / `max` | custom | "Closed on cannot be after 2026-12-31" |
316
- | `disabled: true` | `disabled()` | — (field shown but not editable) |
317
-
318
- There's no separate `requiredTrue`: `required` treats `false` as empty, so `required: true` on a
319
- checkbox means it must be ticked (Eg "Accept terms").
320
-
321
- ```typescript
322
- formConfig<SignUp>('signUp')
323
- .text('username', 'Username', {
324
- required: true,
325
- pattern: '[a-z0-9.]+', // whole value must match
326
- patternMessage: 'Use lowercase letters, digits and dots only',
327
- })
328
- .email('email', 'Email', { required: true })
329
- .checkbox('terms', 'I accept the terms', { required: true })
330
- .build({ username: '', email: '', terms: false });
331
- ```
332
-
333
- A string `pattern` is wrapped as `^(?:…)$`, like HTML's `pattern` attribute, and can be stored in
334
- JSON config. Pass a `RegExp` instead when you want a partial match or flags.
335
-
336
- ## Other exports
337
-
338
- - `toSchema(fields)` — field definitions to Signals Forms rules
339
- - `createEmptyEntity(schemaType, values)` / `defineSchema(config)` — build a `SchemaConfig` by hand instead of with the builder
340
- - `ZonedDateTimeService` — converts between UTC instants and picker `Date`s in a given timezone, resolving daylight-saving gaps and overlaps
341
-
342
- ## Layout
343
-
344
- ```
345
- src/
346
- ├── public-api.ts # barrel — the entire public surface; nothing outside this is exported
347
- └── lib/dynamic-form/
348
- ├── components/ # DynamicForm + one Material component per field type
349
- ├── builders/ # formConfig<T>() / FormConfigBuilder
350
- ├── adapters/ # Locale-aware DateAdapter for the pickers
351
- ├── services/ # ZonedDateTimeService
352
- ├── shared/ # Hint/error line for controls without a mat-form-field
353
- ├── interfaces/ # FieldDef, FieldOption, BaseSchema, SchemaConfig
354
- ├── constants/ # FormFieldType, DateTimeConfig
355
- ├── styles/ # Shared SCSS mixins
356
- └── utils/ # toSchema, date/time parsing and validation
357
- ```
358
-
359
- ## Status
360
-
361
- Not yet published to npm — under development. The `apps/showcase` app demonstrates every field type.
362
-
363
- ## Publishing
364
-
365
- To publish this package to npm:
366
-
367
- ```bash
368
- cd packages/forms
369
- npm run build
370
- cd dist
371
- npm publish
372
- ```
373
-
374
- Ensure `version` in `package.json` is updated before publishing per semver conventions.
1
+ # @wiltech-labs/ngx-forms
2
+
3
+ Configuration-driven Angular forms built on the **Signals Forms API** (`@angular/forms/signals`)
4
+ and **Angular Material**. Describe your fields once, then either render the whole form with
5
+ `DynamicForm` or place individual field components in your own layout.
6
+
7
+ ## Features
8
+
9
+ - **`DynamicForm`** — renders a full form (fields + Save/Clear) from a list of field definitions
10
+ - **Field components** — every field type is a standalone Material component you can use on its own
11
+ - **`formConfig<T>()` builder** — fluent, type-checked way to write the config; field names are checked against your model
12
+ - **Validation from config** — `required`, `minLength`/`maxLength`, `min`/`max`, date/time ranges and `disabled`, shown inline under each field once touched
13
+ - **Date and time done properly** — timezone-aware instants and timezone-free business dates/times, displayed in a chosen locale rather than the browser's
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @wiltech-labs/ngx-forms
19
+ ```
20
+
21
+ Peer dependencies: `@angular/core`, `@angular/common`, `@angular/forms`, `@angular/material` (all `^22`).
22
+ `temporal-polyfill` is installed with the package.
23
+
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.:
27
+
28
+ ```css
29
+ /* styles.css */
30
+ @import '@angular/material/prebuilt-themes/azure-blue.css';
31
+ ```
32
+
33
+ ```html
34
+ <!-- index.html -->
35
+ <link rel="preconnect" href="https://fonts.gstatic.com" />
36
+ <link
37
+ href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
38
+ rel="stylesheet"
39
+ />
40
+ ```
41
+
42
+ ## Usage with DynamicForm
43
+
44
+ ### 1. Describe the model and its fields
45
+
46
+ ```typescript
47
+ import { formConfig } from '@wiltech-labs/ngx-forms';
48
+ import type { BaseSchema, SchemaConfig } from '@wiltech-labs/ngx-forms';
49
+
50
+ export interface FlightSchema extends BaseSchema {
51
+ // BaseSchema adds `id` and `schemaType`
52
+ schemaType: 'flight';
53
+ from: string;
54
+ to: string;
55
+ departure: string;
56
+ cabin: string;
57
+ delayed: boolean;
58
+ }
59
+
60
+ export const flightFormConfig: SchemaConfig<FlightSchema> = formConfig<FlightSchema>('flight')
61
+ .text('from', 'Departure City', { required: true, minLength: 3, maxLength: 20 })
62
+ .text('to', 'Destination City', { required: true, minLength: 3, maxLength: 20 })
63
+ .instantDateTime('departure', 'Departure', {
64
+ required: true,
65
+ hint: 'Local time at the departure airport',
66
+ dateTimeConfig: { timeZone: 'Europe/London', min: '2026-01-01T00:00:00Z' },
67
+ })
68
+ .select(
69
+ 'cabin',
70
+ 'Cabin',
71
+ [
72
+ { label: 'Economy', value: 'economy' },
73
+ { label: 'Business', value: 'business' },
74
+ ],
75
+ { required: true },
76
+ )
77
+ .checkbox('delayed', 'Delayed')
78
+ .build({ from: '', to: '', departure: '', cabin: 'economy', delayed: false });
79
+ ```
80
+
81
+ `build()` returns `{ schemaType, fields, initialValue }`, with `id` and `schemaType` filled in on the
82
+ initial value. A misspelled or renamed field name is a compile error.
83
+
84
+ ### 2. Render it
85
+
86
+ ```typescript
87
+ import { Component, signal } from '@angular/core';
88
+ import { form } from '@angular/forms/signals';
89
+ import { DynamicForm, toSchema } from '@wiltech-labs/ngx-forms';
90
+ import { flightFormConfig } from './flight-form.config';
91
+ import type { FlightSchema } from './flight-form.config';
92
+
93
+ @Component({
94
+ selector: 'app-flight-form',
95
+ imports: [DynamicForm],
96
+ template: `
97
+ <ngx-dynamic-form
98
+ [metaInfo]="config.fields"
99
+ [dynamicForm]="flightForm"
100
+ (onFormSubmit)="save()"
101
+ (onFormClear)="clear()"
102
+ />
103
+ `,
104
+ })
105
+ export class FlightFormComponent {
106
+ protected readonly config = flightFormConfig;
107
+ protected readonly flight = signal(flightFormConfig.initialValue);
108
+ protected readonly flightForm = form(
109
+ this.flight,
110
+ toSchema<FlightSchema>(flightFormConfig.fields),
111
+ );
112
+
113
+ protected save(): void {
114
+ console.log('Saved', this.flight());
115
+ }
116
+
117
+ protected clear(): void {
118
+ this.flight.set(flightFormConfig.initialValue);
119
+ this.flightForm().reset();
120
+ }
121
+ }
122
+ ```
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
+
128
+ `toSchema()` turns the field definitions into Signals Forms validation rules. Save is only enabled
129
+ while the form is valid, and `onFormSubmit` only fires for a valid form.
130
+
131
+ ## Usage with individual fields
132
+
133
+ Every field component takes the same two inputs — a `[fieldDef]` and the `[field]` from your
134
+ Signals Form — so you can use them without `DynamicForm`, in any layout. The model can be any plain
135
+ object; it doesn't need `id` or `schemaType`.
136
+
137
+ ```typescript
138
+ import { Component, signal } from '@angular/core';
139
+ import { form } from '@angular/forms/signals';
140
+ import {
141
+ BusinessDateField,
142
+ CheckboxField,
143
+ FormFieldType,
144
+ TextField,
145
+ toSchema,
146
+ } from '@wiltech-labs/ngx-forms';
147
+ import type { FieldDef } from '@wiltech-labs/ngx-forms';
148
+
149
+ interface Newsletter {
150
+ email: string;
151
+ startDate: string;
152
+ agree: boolean;
153
+ }
154
+
155
+ @Component({
156
+ selector: 'app-newsletter',
157
+ imports: [TextField, BusinessDateField, CheckboxField],
158
+ template: `
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" />
162
+ <button [disabled]="newsletterForm().invalid()" (click)="subscribe()">Subscribe</button>
163
+ `,
164
+ })
165
+ export class NewsletterComponent {
166
+ protected readonly fields = {
167
+ email: { name: 'email', type: FormFieldType.TEXT, label: 'Email', required: true },
168
+ startDate: {
169
+ name: 'startDate',
170
+ type: FormFieldType.BUSINESS_DATE,
171
+ label: 'Start from',
172
+ required: true,
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,
180
+ },
181
+ } satisfies Record<keyof Newsletter, FieldDef>;
182
+
183
+ protected readonly newsletter = signal<Newsletter>({ email: '', startDate: '', agree: false });
184
+ protected readonly newsletterForm = form(
185
+ this.newsletter,
186
+ toSchema<Newsletter>(Object.values(this.fields)),
187
+ );
188
+
189
+ protected subscribe(): void {
190
+ console.log(this.newsletter());
191
+ }
192
+ }
193
+ ```
194
+
195
+ Each component renders its own label, hint, required marker and errors, and fills the width of its
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`.
198
+
199
+ ## Field types
200
+
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
+ | `THEME` | `theme(name, label, choices?)` | `ThemeField` | `'light'` / `'dark'` / `'system'` | `mat-button-toggle-group`, sun/moon/auto icon row |
215
+ | `RANGE` | `range()` | `SliderField` | `number` | `mat-slider` (`min`/`max`/`step`, default 0–100) |
216
+ | `BUSINESS_DATE` | `businessDate()` | `BusinessDateField` | `'YYYY-MM-DD'` | Datepicker |
217
+ | `BUSINESS_TIME` | `businessTime()` | `BusinessTimeField` | `'HH:mm'` | Timepicker |
218
+ | `INSTANT_DATE_TIME` | `instantDateTime()` | `InstantDateTimeField` | UTC `'YYYY-MM-DDThh:mm:ssZ'` | Datepicker + timepicker |
219
+
220
+ Use `hidden(name)` on the builder for a field that belongs to the model and its validation but is never shown.
221
+
222
+ ### Choosing a date/time field
223
+
224
+ - **`BUSINESS_DATE`** — a calendar date with no timezone: birthdays, holidays, due dates. Christmas Day is the 25th wherever it's read.
225
+ - **`BUSINESS_TIME`** — a time of day with no date or timezone: opening hours, daily schedules. "Opens at 09:00" means 09:00 in whichever shop you walk into.
226
+ - **`INSTANT_DATE_TIME`** — an exact moment, stored in UTC and edited as the wall-clock time of `dateTimeConfig.timeZone`: meetings, deadlines, flights. When a time falls in a daylight-saving gap or overlap, the field explains how it was resolved.
227
+
228
+ All three display and parse dates in `dateTimeConfig.locale` (default `'en-GB'`, so `31/12/2026`),
229
+ never the browser's locale — typing `31/12/2026` works for any day-first locale.
230
+
231
+ ### App-wide default locale
232
+
233
+ `dateTimeConfig.locale` set on a field always wins. When it's unset, the fallback comes from
234
+ `NGX_FORMS_LOCALE` (an `InjectionToken<() => string>`), not a hardcoded constant — provide it once
235
+ in `app.config.ts` to change every field's default locale together, e.g. wired to
236
+ [`@wiltech-labs/ngx-translations`](../translations):
237
+
238
+ ```ts
239
+ import { inject } from '@angular/core';
240
+ import { NGX_FORMS_LOCALE } from '@wiltech-labs/ngx-forms';
241
+ import { TranslationsService } from '@wiltech-labs/ngx-translations';
242
+
243
+ {
244
+ provide: NGX_FORMS_LOCALE,
245
+ useFactory: () => { const translations = inject(TranslationsService); return () => translations.locale(); }
246
+ }
247
+ ```
248
+
249
+ Unlike `ngx-dates`' `NGX_DATES_LOCALE` (read-only display text), this locale also decides which
250
+ _typed_ day/month order the picker's input accepts — changing it changes user input behaviour, not
251
+ just wording, so it isn't defaulted to the browser's own language the way `ngx-dates` is. Leave it
252
+ unset and every field keeps today's default (`'en-GB'`).
253
+
254
+ ### Theme field labels
255
+
256
+ `ThemeField`'s three icon toggles (sun = `'light'`, moon = `'dark'`, auto = `'system'`, i.e. follow
257
+ the OS preference) have no visible text — their accessible label comes from `choices`, same
258
+ `FieldOption[]` mechanism `radio()`/`select()` already use, not a separate config or token. The three
259
+ values are always fixed, each tied to its own icon, so `choices` only ever supplies each one's label
260
+ text — defaults to English `'Light'`/`'Dark'`/`'System'` when omitted (any you leave out keep their
261
+ default):
262
+
263
+ ```ts
264
+ formConfig<MySchema>('settings').theme('colorScheme', 'Color Scheme', [
265
+ { label: 'Day', value: 'light' },
266
+ { label: 'Night', value: 'dark' },
267
+ { label: 'Auto', value: 'system' },
268
+ ]);
269
+ ```
270
+
271
+ ## Field definition
272
+
273
+ ```typescript
274
+ interface FieldDef {
275
+ name: string; // Property name in the model
276
+ type: FormFieldType;
277
+ label: string;
278
+ required?: boolean;
279
+ minLength?: number; // Text fields
280
+ maxLength?: number; // Text fields
281
+ pattern?: RegExp | string; // Text fields: a RegExp is used as-is; a string must match the whole value
282
+ patternMessage?: string; // Error when `pattern` doesn't match. Default: "<label> is not in the expected format"
283
+ min?: number; // Number and range fields
284
+ max?: number; // Number and range fields
285
+ step?: number; // Range fields
286
+ options?: FieldOption[]; // Radio and select: [{ label, value }]. Theme: label text only, value is always 'light'/'dark'/'system'
287
+ orientation?: 'horizontal' | 'vertical'; // Radio: a wrapping row (default) or one option per line
288
+ dateTimeConfig?: DateTimeConfig; // Business date/time and instant date-time
289
+ hidden?: boolean; // Part of the model, never rendered
290
+ disabled?: boolean; // Rendered but not editable
291
+ hint?: string; // Help text under the field
292
+ maxWidth?: string; // DynamicForm only: caps this field's width, Eg '400px'
293
+ }
294
+
295
+ interface DateTimeConfig {
296
+ locale?: string; // Display/typing format, Eg 'en-GB', 'en-US', 'el-CY'. Default 'en-GB'.
297
+ min?: string; // In the field's own value format: '2026-12-25', '09:00' or '2026-01-01T00:00:00Z'
298
+ max?: string;
299
+ timeZone?: string; // Instant only: IANA zone, Eg 'Europe/London'. Default: the user's timezone.
300
+ disambiguation?: 'earlier' | 'later'; // Instant only: which occurrence when a time happens twice
301
+ }
302
+ ```
303
+
304
+ ## Validation
305
+
306
+ `toSchema()` turns each `FieldDef` into Signals Forms rules, with messages built from the label:
307
+
308
+ | Config | Rule | Message |
309
+ | ------------------------------ | ----------------------------- | ------------------------------------------------------------------ |
310
+ | `required: true` | `required()` | "Email is required" |
311
+ | `minLength` / `maxLength` | `minLength()` / `maxLength()` | "Username must be at least 3 characters" |
312
+ | `min` / `max` | `min()` / `max()` | "Age must be at least 18" |
313
+ | type `EMAIL` | `email()` | "Email must be a valid email address" |
314
+ | `pattern` (+ `patternMessage`) | `pattern()` | your `patternMessage`, or "Username is not in the expected format" |
315
+ | `dateTimeConfig.min` / `max` | custom | "Closed on cannot be after 2026-12-31" |
316
+ | `disabled: true` | `disabled()` | — (field shown but not editable) |
317
+
318
+ There's no separate `requiredTrue`: `required` treats `false` as empty, so `required: true` on a
319
+ checkbox means it must be ticked (Eg "Accept terms").
320
+
321
+ ```typescript
322
+ formConfig<SignUp>('signUp')
323
+ .text('username', 'Username', {
324
+ required: true,
325
+ pattern: '[a-z0-9.]+', // whole value must match
326
+ patternMessage: 'Use lowercase letters, digits and dots only',
327
+ })
328
+ .email('email', 'Email', { required: true })
329
+ .checkbox('terms', 'I accept the terms', { required: true })
330
+ .build({ username: '', email: '', terms: false });
331
+ ```
332
+
333
+ A string `pattern` is wrapped as `^(?:…)$`, like HTML's `pattern` attribute, and can be stored in
334
+ JSON config. Pass a `RegExp` instead when you want a partial match or flags.
335
+
336
+ ## Styling
337
+
338
+ Two CSS custom properties, set on any ancestor of the form (or app-wide on `:root`):
339
+
340
+ | Property | Default | What it does |
341
+ |---|---|---|
342
+ | `--ngx-form-gap` | `16px` | The space between a `DynamicForm`'s fields, and above its actions row |
343
+ | `--ngx-field-max-width` | `none` | Caps a field's width; `DynamicForm` sets it per field from `FieldDef.maxWidth` |
344
+
345
+ ```scss
346
+ :root {
347
+ --ngx-form-gap: 20px;
348
+ }
349
+ ```
350
+
351
+ ## Other exports
352
+
353
+ - `toSchema(fields)` — field definitions to Signals Forms rules
354
+ - `createEmptyEntity(schemaType, values)` / `defineSchema(config)` — build a `SchemaConfig` by hand instead of with the builder
355
+ - `ZonedDateTimeService` — converts between UTC instants and picker `Date`s in a given timezone, resolving daylight-saving gaps and overlaps
356
+
357
+ ## Layout
358
+
359
+ ```
360
+ src/
361
+ ├── public-api.ts # barrel — the entire public surface; nothing outside this is exported
362
+ └── lib/dynamic-form/
363
+ ├── components/ # DynamicForm + one Material component per field type
364
+ ├── builders/ # formConfig<T>() / FormConfigBuilder
365
+ ├── adapters/ # Locale-aware DateAdapter for the pickers
366
+ ├── services/ # ZonedDateTimeService
367
+ ├── shared/ # Hint/error line for controls without a mat-form-field
368
+ ├── interfaces/ # FieldDef, FieldOption, BaseSchema, SchemaConfig
369
+ ├── constants/ # FormFieldType, DateTimeConfig
370
+ ├── styles/ # Shared SCSS mixins
371
+ └── utils/ # toSchema, date/time parsing and validation
372
+ ```
373
+
374
+ ## Status
375
+
376
+ Not yet published to npm — under development. The `apps/showcase` app demonstrates every field type.
377
+
378
+ ## Publishing
379
+
380
+ To publish this package to npm:
381
+
382
+ ```bash
383
+ cd packages/forms
384
+ npm run build
385
+ cd dist
386
+ npm publish
387
+ ```
388
+
389
+ Ensure `version` in `package.json` is updated before publishing per semver conventions.