@wiltech-labs/ngx-forms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,301 @@
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, so the app needs a Material theme and the Roboto font:
25
+
26
+ ```css
27
+ /* styles.css */
28
+ @import '@angular/material/prebuilt-themes/azure-blue.css';
29
+ ```
30
+
31
+ ```html
32
+ <!-- index.html -->
33
+ <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" />
35
+ ```
36
+
37
+ ## Usage with DynamicForm
38
+
39
+ ### 1. Describe the model and its fields
40
+
41
+ ```typescript
42
+ import { formConfig } from '@wiltech-labs/ngx-forms';
43
+ import type { BaseSchema, SchemaConfig } from '@wiltech-labs/ngx-forms';
44
+
45
+ export interface FlightSchema extends BaseSchema { // BaseSchema adds `id` and `schemaType`
46
+ schemaType: 'flight';
47
+ from: string;
48
+ to: string;
49
+ departure: string;
50
+ cabin: string;
51
+ delayed: boolean;
52
+ }
53
+
54
+ export const flightFormConfig: SchemaConfig<FlightSchema> = formConfig<FlightSchema>('flight')
55
+ .text('from', 'Departure City', { required: true, minLength: 3, maxLength: 20 })
56
+ .text('to', 'Destination City', { required: true, minLength: 3, maxLength: 20 })
57
+ .instantDateTime('departure', 'Departure', {
58
+ required: true,
59
+ hint: 'Local time at the departure airport',
60
+ dateTimeConfig: { timeZone: 'Europe/London', min: '2026-01-01T00:00:00Z' }
61
+ })
62
+ .select('cabin', 'Cabin', [
63
+ { label: 'Economy', value: 'economy' },
64
+ { label: 'Business', value: 'business' }
65
+ ], { required: true })
66
+ .checkbox('delayed', 'Delayed')
67
+ .build({ from: '', to: '', departure: '', cabin: 'economy', delayed: false });
68
+ ```
69
+
70
+ `build()` returns `{ schemaType, fields, initialValue }`, with `id` and `schemaType` filled in on the
71
+ initial value. A misspelled or renamed field name is a compile error.
72
+
73
+ ### 2. Render it
74
+
75
+ ```typescript
76
+ import { Component, signal } from '@angular/core';
77
+ import { form } from '@angular/forms/signals';
78
+ import { DynamicForm, toSchema } from '@wiltech-labs/ngx-forms';
79
+ import { flightFormConfig } from './flight-form.config';
80
+ import type { FlightSchema } from './flight-form.config';
81
+
82
+ @Component({
83
+ selector: 'app-flight-form',
84
+ imports: [DynamicForm],
85
+ template: `
86
+ <app-dynamic-form
87
+ [metaInfo]="config.fields"
88
+ [dynamicForm]="flightForm"
89
+ (onFormSubmit)="save()"
90
+ (onFormClear)="clear()"
91
+ />
92
+ `
93
+ })
94
+ export class FlightFormComponent {
95
+ protected readonly config = flightFormConfig;
96
+ protected readonly flight = signal(flightFormConfig.initialValue);
97
+ protected readonly flightForm = form(this.flight, toSchema<FlightSchema>(flightFormConfig.fields));
98
+
99
+ protected save(): void {
100
+ console.log('Saved', this.flight());
101
+ }
102
+
103
+ protected clear(): void {
104
+ this.flight.set(flightFormConfig.initialValue);
105
+ this.flightForm().reset();
106
+ }
107
+ }
108
+ ```
109
+
110
+ `toSchema()` turns the field definitions into Signals Forms validation rules. Save is only enabled
111
+ while the form is valid, and `onFormSubmit` only fires for a valid form.
112
+
113
+ ## Usage with individual fields
114
+
115
+ Every field component takes the same two inputs — a `[fieldDef]` and the `[field]` from your
116
+ Signals Form — so you can use them without `DynamicForm`, in any layout. The model can be any plain
117
+ object; it doesn't need `id` or `schemaType`.
118
+
119
+ ```typescript
120
+ import { Component, signal } from '@angular/core';
121
+ import { form } from '@angular/forms/signals';
122
+ import {
123
+ BusinessDateField, CheckboxField, FormFieldType, TextField, toSchema
124
+ } from '@wiltech-labs/ngx-forms';
125
+ import type { FieldDef } from '@wiltech-labs/ngx-forms';
126
+
127
+ interface Newsletter {
128
+ email: string;
129
+ startDate: string;
130
+ agree: boolean;
131
+ }
132
+
133
+ @Component({
134
+ selector: 'app-newsletter',
135
+ imports: [TextField, BusinessDateField, CheckboxField],
136
+ 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" />
140
+ <button [disabled]="newsletterForm().invalid()" (click)="subscribe()">Subscribe</button>
141
+ `
142
+ })
143
+ export class NewsletterComponent {
144
+ protected readonly fields = {
145
+ email: { name: 'email', type: FormFieldType.TEXT, label: 'Email', required: true },
146
+ startDate: {
147
+ name: 'startDate',
148
+ type: FormFieldType.BUSINESS_DATE,
149
+ label: 'Start from',
150
+ required: true,
151
+ dateTimeConfig: { min: '2026-01-01', locale: 'en-GB' }
152
+ },
153
+ agree: { name: 'agree', type: FormFieldType.CHECKBOX, label: 'I agree to receive emails', required: true }
154
+ } satisfies Record<keyof Newsletter, FieldDef>;
155
+
156
+ protected readonly newsletter = signal<Newsletter>({ email: '', startDate: '', agree: false });
157
+ protected readonly newsletterForm = form(this.newsletter, toSchema<Newsletter>(Object.values(this.fields)));
158
+
159
+ protected subscribe(): void {
160
+ console.log(this.newsletter());
161
+ }
162
+ }
163
+ ```
164
+
165
+ 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`).
167
+
168
+ ## Field types
169
+
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 |
186
+
187
+ Use `hidden(name)` on the builder for a field that belongs to the model and its validation but is never shown.
188
+
189
+ ### Choosing a date/time field
190
+
191
+ - **`BUSINESS_DATE`** — a calendar date with no timezone: birthdays, holidays, due dates. Christmas Day is the 25th wherever it's read.
192
+ - **`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.
193
+ - **`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.
194
+
195
+ All three display and parse dates in `dateTimeConfig.locale` (default `'en-GB'`, so `31/12/2026`),
196
+ never the browser's locale — typing `31/12/2026` works for any day-first locale.
197
+
198
+ ## Field definition
199
+
200
+ ```typescript
201
+ interface FieldDef {
202
+ name: string; // Property name in the model
203
+ type: FormFieldType;
204
+ label: string;
205
+ 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 }]
214
+ 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%'
220
+ }
221
+
222
+ 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'
225
+ 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
228
+ }
229
+ ```
230
+
231
+ ## Validation
232
+
233
+ `toSchema()` turns each `FieldDef` into Signals Forms rules, with messages built from the label:
234
+
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) |
244
+
245
+ There's no separate `requiredTrue`: `required` treats `false` as empty, so `required: true` on a
246
+ checkbox means it must be ticked (Eg "Accept terms").
247
+
248
+ ```typescript
249
+ formConfig<SignUp>('signUp')
250
+ .text('username', 'Username', {
251
+ required: true,
252
+ pattern: '[a-z0-9.]+', // whole value must match
253
+ patternMessage: 'Use lowercase letters, digits and dots only'
254
+ })
255
+ .email('email', 'Email', { required: true })
256
+ .checkbox('terms', 'I accept the terms', { required: true })
257
+ .build({ username: '', email: '', terms: false });
258
+ ```
259
+
260
+ A string `pattern` is wrapped as `^(?:…)$`, like HTML's `pattern` attribute, and can be stored in
261
+ JSON config. Pass a `RegExp` instead when you want a partial match or flags.
262
+
263
+ ## Other exports
264
+
265
+ - `toSchema(fields)` — field definitions to Signals Forms rules
266
+ - `createEmptyEntity(schemaType, values)` / `defineSchema(config)` — build a `SchemaConfig` by hand instead of with the builder
267
+ - `ZonedDateTimeService` — converts between UTC instants and picker `Date`s in a given timezone, resolving daylight-saving gaps and overlaps
268
+
269
+ ## Layout
270
+
271
+ ```
272
+ src/
273
+ ├── public-api.ts # barrel — the entire public surface; nothing outside this is exported
274
+ └── lib/dynamic-form/
275
+ ├── components/ # DynamicForm + one Material component per field type
276
+ ├── builders/ # formConfig<T>() / FormConfigBuilder
277
+ ├── adapters/ # Locale-aware DateAdapter for the pickers
278
+ ├── services/ # ZonedDateTimeService
279
+ ├── shared/ # Hint/error line for controls without a mat-form-field
280
+ ├── interfaces/ # FieldDef, FieldOption, BaseSchema, SchemaConfig
281
+ ├── constants/ # FormFieldType, DateTimeConfig
282
+ ├── styles/ # Shared SCSS mixins
283
+ └── utils/ # toSchema, date/time parsing and validation
284
+ ```
285
+
286
+ ## Status
287
+
288
+ Not yet published to npm — under development. The `apps/showcase` app demonstrates every field type.
289
+
290
+ ## Publishing
291
+
292
+ To publish this package to npm:
293
+
294
+ ```bash
295
+ cd packages/forms
296
+ npm run build
297
+ cd dist
298
+ npm publish
299
+ ```
300
+
301
+ Ensure `version` in `package.json` is updated before publishing per semver conventions.