@zhunam/form-builder 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 +82 -0
- package/fesm2022/zhunam-form-builder.mjs +342 -0
- package/fesm2022/zhunam-form-builder.mjs.map +1 -0
- package/package.json +24 -0
- package/types/zhunam-form-builder.d.ts +221 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @zhunam/form-builder
|
|
2
|
+
|
|
3
|
+
A declarative Angular form renderer — reactive validation, cross-field
|
|
4
|
+
checks, and server error handling from a plain config array, no CSS
|
|
5
|
+
framework dependency, no runtime dependencies beyond Angular itself.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @zhunam/form-builder
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { FormBuilder, FieldConfig } from '@zhunam/form-builder';
|
|
17
|
+
|
|
18
|
+
interface User { name: string; email: string; }
|
|
19
|
+
const fields: FieldConfig<User>[] = [
|
|
20
|
+
{ key: 'name', label: 'Name', type: 'text', validators: { required: true } },
|
|
21
|
+
{ key: 'email', label: 'Email', type: 'email', validators: { required: true, email: true } },
|
|
22
|
+
];
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<lib-form-builder [fields]="fields" (formSubmit)="onSubmit($event)" />
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## API
|
|
30
|
+
|
|
31
|
+
### `FormBuilder<T>`
|
|
32
|
+
|
|
33
|
+
| Name | Type | Default | Description |
|
|
34
|
+
| --------------------- | -------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- |
|
|
35
|
+
| `fields` | `input.required<FieldConfig<T>[]>` | — (required) | Declarative configuration of the fields to render, in display order. |
|
|
36
|
+
| `crossFieldValidators` | `input<CrossFieldValidator<T>[]>` | `[]` | Form-level validators that check values across multiple fields (e.g. confirming a password). |
|
|
37
|
+
| `columns` | `input<number>` | `1` | Number of grid columns fields are laid out in on desktop; always collapses to 1 column on mobile. |
|
|
38
|
+
| `serverErrors` | `input<Partial<Record<keyof T, string>>>` | `{}` | Errors returned by the backend after a submit, keyed by field; auto-clears once the user edits that field. |
|
|
39
|
+
| `formSubmit` | `output<T>` | — | Emitted with the typed form values, only when the native form and every `crossFieldValidators` check pass. |
|
|
40
|
+
|
|
41
|
+
### `FieldConfig<T>`
|
|
42
|
+
|
|
43
|
+
| Property | Type | Default | Description |
|
|
44
|
+
| ------------- | ---------------------- | ------------ | ---------------------------------------------------------------------------- |
|
|
45
|
+
| `key` | `keyof T` | — (required) | Property of `T` this field reads and writes its value from. |
|
|
46
|
+
| `label` | `string` | — (required) | Text displayed as the field's label. |
|
|
47
|
+
| `type` | `FieldType` | — (required) | Input type this field renders as: `text`, `number`, `email`, `password`, `textarea`, `select`, `radio`, `checkbox`, or `date`. |
|
|
48
|
+
| `placeholder` | `string` | — | Placeholder text shown when the field is empty. |
|
|
49
|
+
| `options` | `FieldOption[]` | — | Choices available for `select` and `radio` fields. |
|
|
50
|
+
| `validators` | `FieldValidatorConfig` | — | Native Angular validation rules for this field: `required`, `min`, `max`, `minLength`, `maxLength`, `pattern`, `email`, plus optional custom `errorMessages`. |
|
|
51
|
+
| `defaultValue`| `T[keyof T]` | — | Initial value assigned to the field before user interaction. |
|
|
52
|
+
| `colSpan` | `1 \| 2` | `1` | How many grid columns this field spans, when the component's `columns` input is 2 or more. |
|
|
53
|
+
| `disabled` | `boolean` | `false` | Renders the control disabled from the start; still included in the value `formSubmit` emits. |
|
|
54
|
+
|
|
55
|
+
## Compatibility
|
|
56
|
+
|
|
57
|
+
`@angular/core` and `@angular/forms` `^20.0.0 || ^21.0.0 || ^22.0.0`.
|
|
58
|
+
|
|
59
|
+
## Why this one
|
|
60
|
+
|
|
61
|
+
- Declarative validation, per-field and cross-field (e.g. confirming a
|
|
62
|
+
password), without ever exposing Angular's `ValidatorFn` or
|
|
63
|
+
`AbstractControl` in the public API.
|
|
64
|
+
- Heuristic ReDoS protection on validation `pattern`s, with dedicated
|
|
65
|
+
tests — a catastrophic-backtracking regex in your config throws
|
|
66
|
+
instead of hanging the browser.
|
|
67
|
+
- Sanitization guaranteed by interpolation, never `innerHTML` — safe to
|
|
68
|
+
render even if your field config ends up sourced from outside your
|
|
69
|
+
own codebase.
|
|
70
|
+
- Accessible by default: `aria-invalid`/`aria-describedby` stay in sync
|
|
71
|
+
with whichever error message is actually visible, and radio groups
|
|
72
|
+
get a correctly labelled `radiogroup` role.
|
|
73
|
+
- Server-side error handling with automatic clearing — show a backend
|
|
74
|
+
rejection per field with one input, no manual cleanup wiring.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
Built by Ariana Mora — [LinkedIn](https://www.linkedin.com/in/ariana-andreina-mora) · [GitHub](https://github.com/zhunam)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { input, output, signal, computed, effect, ViewEncapsulation, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import * as i1 from '@angular/forms';
|
|
4
|
+
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
|
5
|
+
|
|
6
|
+
const MAX_PATTERN_LENGTH = 300;
|
|
7
|
+
/** Matches a group that repeats another quantifier, e.g. `(x+)+`, `(x*)*`. */
|
|
8
|
+
const NESTED_QUANTIFIER = /\([^()]*[+*][^()]*\)[+*]/;
|
|
9
|
+
/** Matches a repeated group so its alternated branches can be compared. */
|
|
10
|
+
const REPEATED_GROUP = /\(([^()]*)\)[+*]/g;
|
|
11
|
+
/**
|
|
12
|
+
* Validates that a regex `pattern` string is safe enough to compile into
|
|
13
|
+
* a `RegExp` for form validation.
|
|
14
|
+
*
|
|
15
|
+
* This is a heuristic, not a mathematical guarantee of ReDoS-safety: it
|
|
16
|
+
* rejects the pattern shapes most commonly responsible for catastrophic
|
|
17
|
+
* backtracking (nested quantifiers, overlapping alternation inside a
|
|
18
|
+
* repeated group) and enforces a length cap, but a maliciously crafted
|
|
19
|
+
* pattern could still slip through undetected. Never accept `pattern`
|
|
20
|
+
* values from a fully untrusted source without your own review.
|
|
21
|
+
*
|
|
22
|
+
* @throws {Error} If `pattern` is longer than 300 characters, or matches
|
|
23
|
+
* a known catastrophic-backtracking shape.
|
|
24
|
+
*/
|
|
25
|
+
function assertSafePattern(pattern) {
|
|
26
|
+
if (pattern.length > MAX_PATTERN_LENGTH) {
|
|
27
|
+
throw new Error(`Unsafe regex pattern: exceeds the ${MAX_PATTERN_LENGTH}-character limit.`);
|
|
28
|
+
}
|
|
29
|
+
if (NESTED_QUANTIFIER.test(pattern)) {
|
|
30
|
+
throw new Error('Unsafe regex pattern: nested quantifiers detected (e.g. (x+)+), a known catastrophic-backtracking shape.');
|
|
31
|
+
}
|
|
32
|
+
if (hasOverlappingAlternation(pattern)) {
|
|
33
|
+
throw new Error('Unsafe regex pattern: overlapping alternation inside a repeated group detected (e.g. (a|a)+), a known catastrophic-backtracking shape.');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function hasOverlappingAlternation(pattern) {
|
|
37
|
+
const repeatedGroup = new RegExp(REPEATED_GROUP);
|
|
38
|
+
let match;
|
|
39
|
+
while ((match = repeatedGroup.exec(pattern)) !== null) {
|
|
40
|
+
const branches = match[1].split('|');
|
|
41
|
+
for (let i = 0; i < branches.length; i++) {
|
|
42
|
+
for (let j = i + 1; j < branches.length; j++) {
|
|
43
|
+
if (branchesOverlap(branches[i], branches[j])) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function branchesOverlap(a, b) {
|
|
52
|
+
if (a === b)
|
|
53
|
+
return true;
|
|
54
|
+
if (!a || !b)
|
|
55
|
+
return false;
|
|
56
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// NUNCA usar [innerHTML], bypassSecurityTrustHtml, ni ningún mecanismo de inserción de HTML crudo en el DOM del componente.
|
|
60
|
+
// label, placeholder, errorMessages, y FieldOption.label se renderizan siempre con interpolación {{ }} normal de Angular — nunca vía binding de propiedad que inserte HTML.
|
|
61
|
+
// Angular's built-in validators report `minlength`/`maxlength` (all lowercase)
|
|
62
|
+
// on `control.errors`, not the camelCase keys `FieldValidatorConfig.errorMessages`
|
|
63
|
+
// uses — this bridges the two so consumer-facing config can stay camelCase.
|
|
64
|
+
const RAW_ERROR_KEY_MAP = {
|
|
65
|
+
required: 'required',
|
|
66
|
+
min: 'min',
|
|
67
|
+
max: 'max',
|
|
68
|
+
minlength: 'minLength',
|
|
69
|
+
maxlength: 'maxLength',
|
|
70
|
+
pattern: 'pattern',
|
|
71
|
+
email: 'email',
|
|
72
|
+
};
|
|
73
|
+
const DEFAULT_ERROR_MESSAGES = {
|
|
74
|
+
required: 'Este campo es obligatorio.',
|
|
75
|
+
min: 'El valor es menor al mínimo permitido.',
|
|
76
|
+
max: 'El valor supera el máximo permitido.',
|
|
77
|
+
minLength: 'El valor es demasiado corto.',
|
|
78
|
+
maxLength: 'El valor es demasiado largo.',
|
|
79
|
+
pattern: 'El formato no es válido.',
|
|
80
|
+
email: 'Ingresá un email válido.',
|
|
81
|
+
};
|
|
82
|
+
class FormBuilder {
|
|
83
|
+
/**
|
|
84
|
+
* Declarative configuration of the fields to render, in display order.
|
|
85
|
+
* @example
|
|
86
|
+
* fields: FieldConfig<User>[] = [
|
|
87
|
+
* { key: 'email', label: 'Email', type: 'email', validators: { required: true, email: true } },
|
|
88
|
+
* ];
|
|
89
|
+
*/
|
|
90
|
+
fields = input.required(...(ngDevMode ? [{ debugName: "fields" }] : /* istanbul ignore next */ []));
|
|
91
|
+
/**
|
|
92
|
+
* Number of grid columns fields are laid out in on desktop. Always
|
|
93
|
+
* collapses to a single column below a mobile breakpoint, regardless
|
|
94
|
+
* of this value — mobile-first, like the rest of the library.
|
|
95
|
+
* @default 1
|
|
96
|
+
* @example
|
|
97
|
+
* <lib-form-builder [fields]="fields" [columns]="2" />
|
|
98
|
+
*/
|
|
99
|
+
columns = input(1, ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
|
|
100
|
+
/**
|
|
101
|
+
* Form-level validators that check values across multiple fields (e.g.
|
|
102
|
+
* confirming a password). Run separately from Angular's native
|
|
103
|
+
* per-control validation, only when the native `FormGroup` is valid.
|
|
104
|
+
* @default []
|
|
105
|
+
* @example
|
|
106
|
+
* crossFieldValidators: CrossFieldValidator<User>[] = [
|
|
107
|
+
* {
|
|
108
|
+
* validate: (value) =>
|
|
109
|
+
* value.password !== value.confirmPassword
|
|
110
|
+
* ? { confirmPassword: 'Passwords must match' }
|
|
111
|
+
* : null,
|
|
112
|
+
* },
|
|
113
|
+
* ];
|
|
114
|
+
*/
|
|
115
|
+
crossFieldValidators = input([], ...(ngDevMode ? [{ debugName: "crossFieldValidators" }] : /* istanbul ignore next */ []));
|
|
116
|
+
/**
|
|
117
|
+
* Emitted with the typed form values, only when both the native
|
|
118
|
+
* `FormGroup` and every `crossFieldValidators` check pass.
|
|
119
|
+
* @example
|
|
120
|
+
* <lib-form-builder [fields]="fields" (formSubmit)="onSubmit($event)" />
|
|
121
|
+
*/
|
|
122
|
+
formSubmit = output();
|
|
123
|
+
/**
|
|
124
|
+
* Errors returned by the backend after a submit attempt (e.g. "email
|
|
125
|
+
* already registered"), keyed by field. Shown as soon as they're set,
|
|
126
|
+
* regardless of whether the field has been touched or the form
|
|
127
|
+
* submitted locally — receiving one implies a submit already
|
|
128
|
+
* happened. Each message clears itself automatically the moment the
|
|
129
|
+
* user edits that field again, so there's no need to clear this input
|
|
130
|
+
* back to `{}` manually after showing it once.
|
|
131
|
+
* @default {}
|
|
132
|
+
* @example
|
|
133
|
+
* // In the consumer's component:
|
|
134
|
+
* protected readonly serverErrors = signal<Partial<Record<keyof User, string>>>({});
|
|
135
|
+
*
|
|
136
|
+
* onFormSubmit(value: User) {
|
|
137
|
+
* this.api.register(value).subscribe({
|
|
138
|
+
* error: () => this.serverErrors.set({ email: 'This email is already registered.' }),
|
|
139
|
+
* });
|
|
140
|
+
* }
|
|
141
|
+
* // Template:
|
|
142
|
+
* <lib-form-builder [fields]="fields" [serverErrors]="serverErrors()" (formSubmit)="onFormSubmit($event)" />
|
|
143
|
+
*/
|
|
144
|
+
serverErrors = input({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
|
|
145
|
+
submitted = signal(false, ...(ngDevMode ? [{ debugName: "submitted" }] : /* istanbul ignore next */ []));
|
|
146
|
+
crossFieldErrors = signal({}, ...(ngDevMode ? [{ debugName: "crossFieldErrors" }] : /* istanbul ignore next */ []));
|
|
147
|
+
// Snapshot of each server-error field's value at the moment that error
|
|
148
|
+
// appeared. A server error is only ever about a rejected backend
|
|
149
|
+
// observation of the exact value the user submitted — the instant the
|
|
150
|
+
// control's live value no longer matches this snapshot, the message is
|
|
151
|
+
// stale and `serverErrorFor` stops showing it, without the consumer
|
|
152
|
+
// having to clear `serverErrors` themselves.
|
|
153
|
+
serverErrorSnapshot = signal({}, ...(ngDevMode ? [{ debugName: "serverErrorSnapshot" }] : /* istanbul ignore next */ []));
|
|
154
|
+
// `fields()` can change at runtime (e.g. a wizard swapping steps), so the
|
|
155
|
+
// FormGroup is derived with `computed()` rather than built once in the
|
|
156
|
+
// constructor: it's rebuilt only when the `fields()` array reference
|
|
157
|
+
// changes. `computed()` fits better than an `effect()` here because
|
|
158
|
+
// building a FormGroup is a pure derivation with no teardown to manage,
|
|
159
|
+
// and `computed()` already memoizes it — the same instance is returned
|
|
160
|
+
// across change-detection cycles until `fields()` itself changes.
|
|
161
|
+
formGroup = computed(() => this.buildFormGroup(this.fields()), ...(ngDevMode ? [{ debugName: "formGroup" }] : /* istanbul ignore next */ []));
|
|
162
|
+
static instanceCounter = 0;
|
|
163
|
+
instanceId = ++FormBuilder.instanceCounter;
|
|
164
|
+
constructor() {
|
|
165
|
+
// Runs whenever a new `serverErrors()` value comes in and captures
|
|
166
|
+
// each mentioned field's current value — the baseline `serverErrorFor`
|
|
167
|
+
// compares against to decide whether that message is still relevant.
|
|
168
|
+
effect(() => {
|
|
169
|
+
const errors = this.serverErrors();
|
|
170
|
+
const group = this.formGroup();
|
|
171
|
+
const snapshot = {};
|
|
172
|
+
for (const key of Object.keys(errors)) {
|
|
173
|
+
snapshot[key] = group.get(String(key))?.value;
|
|
174
|
+
}
|
|
175
|
+
this.serverErrorSnapshot.set(snapshot);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
buildFormGroup(fields) {
|
|
179
|
+
const controls = {};
|
|
180
|
+
for (const field of fields) {
|
|
181
|
+
const initialValue = field.defaultValue ?? (field.type === 'checkbox' ? false : null);
|
|
182
|
+
controls[String(field.key)] = new FormControl({ value: initialValue, disabled: !!field.disabled }, this.buildValidators(field));
|
|
183
|
+
}
|
|
184
|
+
return new FormGroup(controls);
|
|
185
|
+
}
|
|
186
|
+
buildValidators(field) {
|
|
187
|
+
const config = field.validators;
|
|
188
|
+
if (!config) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
const validators = [];
|
|
192
|
+
if (config.required) {
|
|
193
|
+
// A checkbox's "empty" value is `false`, which Validators.required
|
|
194
|
+
// doesn't treat as empty — requiredTrue is the correct check for it.
|
|
195
|
+
validators.push(field.type === 'checkbox' ? Validators.requiredTrue : Validators.required);
|
|
196
|
+
}
|
|
197
|
+
if (config.min !== undefined) {
|
|
198
|
+
validators.push(Validators.min(config.min));
|
|
199
|
+
}
|
|
200
|
+
if (config.max !== undefined) {
|
|
201
|
+
validators.push(Validators.max(config.max));
|
|
202
|
+
}
|
|
203
|
+
if (config.minLength !== undefined) {
|
|
204
|
+
validators.push(Validators.minLength(config.minLength));
|
|
205
|
+
}
|
|
206
|
+
if (config.maxLength !== undefined) {
|
|
207
|
+
validators.push(Validators.maxLength(config.maxLength));
|
|
208
|
+
}
|
|
209
|
+
if (config.pattern !== undefined) {
|
|
210
|
+
// An unsafe pattern is a bug in the consumer's config, not a runtime
|
|
211
|
+
// condition to recover from — let it throw instead of swallowing it.
|
|
212
|
+
assertSafePattern(config.pattern);
|
|
213
|
+
validators.push(Validators.pattern(config.pattern));
|
|
214
|
+
}
|
|
215
|
+
if (config.email) {
|
|
216
|
+
validators.push(Validators.email);
|
|
217
|
+
}
|
|
218
|
+
return validators;
|
|
219
|
+
}
|
|
220
|
+
controlFor(field) {
|
|
221
|
+
return this.formGroup().get(String(field.key));
|
|
222
|
+
}
|
|
223
|
+
optionsFor(field) {
|
|
224
|
+
return field.options ?? [];
|
|
225
|
+
}
|
|
226
|
+
// Shared by the label's `for`, the control's `id`, and (for radio groups)
|
|
227
|
+
// the `name` that keeps this field's radios from being grouped with
|
|
228
|
+
// another field's — RadioControlValueAccessor matches radios by `name`
|
|
229
|
+
// plus parent FormGroup, and every field here shares the same FormGroup.
|
|
230
|
+
fieldId(field) {
|
|
231
|
+
return `fb-${this.instanceId}-${String(field.key)}`;
|
|
232
|
+
}
|
|
233
|
+
errorMessageFor(field) {
|
|
234
|
+
const control = this.controlFor(field);
|
|
235
|
+
if (!control.errors || (!control.touched && !this.submitted())) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const errorKey = RAW_ERROR_KEY_MAP[Object.keys(control.errors)[0]];
|
|
239
|
+
if (!errorKey) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return field.validators?.errorMessages?.[errorKey] ?? DEFAULT_ERROR_MESSAGES[errorKey];
|
|
243
|
+
}
|
|
244
|
+
serverErrorFor(field) {
|
|
245
|
+
const message = this.serverErrors()[field.key];
|
|
246
|
+
if (!message) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const currentValue = this.controlFor(field).value;
|
|
250
|
+
const snapshotValue = this.serverErrorSnapshot()[field.key];
|
|
251
|
+
return currentValue === snapshotValue ? message : null;
|
|
252
|
+
}
|
|
253
|
+
crossFieldErrorFor(field) {
|
|
254
|
+
if (!this.submitted()) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
return this.crossFieldErrors()[String(field.key)] ?? null;
|
|
258
|
+
}
|
|
259
|
+
// Single source of truth for "is an error currently shown for this
|
|
260
|
+
// field, and what does it say" — both the visible `.fb-error` message
|
|
261
|
+
// and its ARIA wiring (aria-invalid, aria-describedby) read from this,
|
|
262
|
+
// so the two can never drift out of sync with each other.
|
|
263
|
+
activeErrorFor(field) {
|
|
264
|
+
return this.errorMessageFor(field) ?? this.serverErrorFor(field) ?? this.crossFieldErrorFor(field);
|
|
265
|
+
}
|
|
266
|
+
errorId(field) {
|
|
267
|
+
return `${this.fieldId(field)}-error`;
|
|
268
|
+
}
|
|
269
|
+
legendId(field) {
|
|
270
|
+
return `${this.fieldId(field)}-legend`;
|
|
271
|
+
}
|
|
272
|
+
isSubmitDisabled() {
|
|
273
|
+
return this.formGroup().invalid || Object.keys(this.crossFieldErrors()).length > 0;
|
|
274
|
+
}
|
|
275
|
+
onSubmit() {
|
|
276
|
+
this.submitted.set(true);
|
|
277
|
+
const group = this.formGroup();
|
|
278
|
+
if (group.invalid) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// `.value` excludes disabled controls entirely — `getRawValue()` keeps
|
|
282
|
+
// them, so a disabled field with a `defaultValue` still comes through
|
|
283
|
+
// as a complete `T` instead of a hole in the emitted object.
|
|
284
|
+
const value = this.coerceNumberFields(group.getRawValue());
|
|
285
|
+
const errors = this.runCrossFieldValidators(value);
|
|
286
|
+
this.crossFieldErrors.set(errors);
|
|
287
|
+
if (Object.keys(errors).length > 0) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.formSubmit.emit(value);
|
|
291
|
+
}
|
|
292
|
+
// Native `<input type="number">` always reports its value as a string
|
|
293
|
+
// through the DOM. Angular's NumberValueAccessor would normally parse it
|
|
294
|
+
// for us, but that accessor only activates for a *static* `type="number"`
|
|
295
|
+
// attribute in the template — this component binds `[type]="field.type"`
|
|
296
|
+
// dynamically, so it never matches, and DefaultValueAccessor (string in,
|
|
297
|
+
// string out) handles the control instead. Fixed up here, at the point
|
|
298
|
+
// the value leaves the component, rather than in how the FormControl
|
|
299
|
+
// stores it — Validators.min/max already parseFloat internally either
|
|
300
|
+
// way, so correcting the stored value would change nothing there but
|
|
301
|
+
// would still need this same conversion for the emitted object.
|
|
302
|
+
coerceNumberFields(rawValue) {
|
|
303
|
+
const value = { ...rawValue };
|
|
304
|
+
for (const field of this.fields()) {
|
|
305
|
+
if (field.type !== 'number') {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const key = String(field.key);
|
|
309
|
+
const current = value[key];
|
|
310
|
+
if (current === '' || current === null || current === undefined) {
|
|
311
|
+
value[key] = null;
|
|
312
|
+
}
|
|
313
|
+
else if (typeof current === 'string') {
|
|
314
|
+
value[key] = Number(current);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
runCrossFieldValidators(value) {
|
|
320
|
+
const errors = {};
|
|
321
|
+
for (const validator of this.crossFieldValidators()) {
|
|
322
|
+
const result = validator.validate(value);
|
|
323
|
+
if (result) {
|
|
324
|
+
Object.assign(errors, result);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return errors;
|
|
328
|
+
}
|
|
329
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: FormBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
330
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: FormBuilder, isStandalone: true, selector: "lib-form-builder", inputs: { fields: { classPropertyName: "fields", publicName: "fields", isSignal: true, isRequired: true, transformFunction: null }, columns: { classPropertyName: "columns", publicName: "columns", isSignal: true, isRequired: false, transformFunction: null }, crossFieldValidators: { classPropertyName: "crossFieldValidators", publicName: "crossFieldValidators", isSignal: true, isRequired: false, transformFunction: null }, serverErrors: { classPropertyName: "serverErrors", publicName: "serverErrors", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { formSubmit: "formSubmit" }, ngImport: i0, template: "<form class=\"fb-form\" [formGroup]=\"formGroup()\" (ngSubmit)=\"onSubmit()\" novalidate>\r\n <div class=\"fb-fields-grid\" [style.--fb-columns]=\"columns()\">\r\n @for (field of fields(); track field.key) {\r\n <div class=\"fb-field\" [class.fb-field--span-2]=\"field.colSpan === 2\">\r\n @if (field.type !== 'checkbox' && field.type !== 'radio') {\r\n <label class=\"fb-label\" [attr.for]=\"fieldId(field)\">{{ field.label }}</label>\r\n }\r\n\r\n @switch (field.type) {\r\n @case ('checkbox') {\r\n <label class=\"fb-checkbox-option\">\r\n <input\r\n type=\"checkbox\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ field.label }}\r\n </label>\r\n }\r\n @case ('radio') {\r\n <fieldset\r\n class=\"fb-radio-group\"\r\n role=\"radiogroup\"\r\n [attr.aria-labelledby]=\"legendId(field)\"\r\n >\r\n <legend class=\"fb-label\" [id]=\"legendId(field)\">{{ field.label }}</legend>\r\n @for (option of optionsFor(field); track option.value) {\r\n <label class=\"fb-radio-option\">\r\n <input\r\n type=\"radio\"\r\n [formControl]=\"controlFor(field)\"\r\n [name]=\"fieldId(field)\"\r\n [value]=\"option.value\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ option.label }}\r\n </label>\r\n }\r\n </fieldset>\r\n }\r\n @case ('textarea') {\r\n <textarea\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n ></textarea>\r\n }\r\n @case ('select') {\r\n <select\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n >\r\n @for (option of optionsFor(field); track option.value) {\r\n <option [ngValue]=\"option.value\">{{ option.label }}</option>\r\n }\r\n </select>\r\n }\r\n @default {\r\n <input\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [type]=\"field.type\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n }\r\n }\r\n\r\n @if (activeErrorFor(field); as message) {\r\n <p class=\"fb-error\" [id]=\"errorId(field)\">{{ message }}</p>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <button type=\"submit\" class=\"fb-submit\" [disabled]=\"isSubmitDisabled()\">Submit</button>\r\n</form>\r\n", styles: [":host{--fb-font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;--fb-font-size: .9375rem;--fb-text-color: #1f2937;--fb-label-color: #374151;--fb-border-color: #d1d5db;--fb-error-color: #dc2626;--fb-primary-color: #3b82f6;--fb-focus-color: var(--fb-primary-color);--fb-border-radius: .5rem;--fb-spacing: 1rem;--fb-control-padding-y: .5rem;--fb-control-padding-x: .75rem;--fb-transition-duration: .15s;display:block;font-family:var(--fb-font-family);font-size:var(--fb-font-size);color:var(--fb-text-color)}.fb-form{display:flex;flex-direction:column;gap:var(--fb-spacing);width:100%}.fb-fields-grid{display:grid;grid-template-columns:repeat(var(--fb-columns, 1),1fr);gap:var(--fb-spacing)}.fb-field{display:flex;flex-direction:column;gap:calc(var(--fb-spacing) / 2.5);min-width:0}.fb-field--span-2{grid-column:span 2}@media(max-width:640px){.fb-fields-grid{grid-template-columns:1fr}.fb-field--span-2{grid-column:span 1}}.fb-label{font-weight:600;color:var(--fb-label-color);padding:0;border:none}.fb-control{box-sizing:border-box;width:100%;font:inherit;color:inherit;padding:var(--fb-control-padding-y) var(--fb-control-padding-x);border:1px solid var(--fb-border-color);border-radius:var(--fb-border-radius);background:transparent;transition:border-color var(--fb-transition-duration) ease}.fb-control:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:-1px;border-color:var(--fb-focus-color)}textarea.fb-control{min-height:5rem;resize:vertical}select.fb-control{cursor:pointer}.fb-radio-group{display:flex;flex-direction:column;gap:calc(var(--fb-spacing) / 2.5)}.fb-radio-option,.fb-checkbox-option{display:inline-flex;align-items:center;gap:.5rem;font-weight:400;color:var(--fb-text-color);cursor:pointer}.fb-radio-option input,.fb-checkbox-option input{accent-color:var(--fb-primary-color)}.fb-radio-option input:focus-visible,.fb-checkbox-option input:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:2px}.fb-error{margin:0;font-size:.8125rem;color:var(--fb-error-color)}.fb-submit{appearance:none;align-self:flex-start;border:none;border-radius:var(--fb-border-radius);background:var(--fb-primary-color);color:#fff;padding:var(--fb-control-padding-y) 1.25rem;font:inherit;font-weight:600;cursor:pointer;transition:opacity var(--fb-transition-duration) ease}.fb-submit:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:2px}.fb-submit:disabled{opacity:.45;cursor:not-allowed}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
331
|
+
}
|
|
332
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: FormBuilder, decorators: [{
|
|
333
|
+
type: Component,
|
|
334
|
+
args: [{ selector: 'lib-form-builder', imports: [ReactiveFormsModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.Emulated, template: "<form class=\"fb-form\" [formGroup]=\"formGroup()\" (ngSubmit)=\"onSubmit()\" novalidate>\r\n <div class=\"fb-fields-grid\" [style.--fb-columns]=\"columns()\">\r\n @for (field of fields(); track field.key) {\r\n <div class=\"fb-field\" [class.fb-field--span-2]=\"field.colSpan === 2\">\r\n @if (field.type !== 'checkbox' && field.type !== 'radio') {\r\n <label class=\"fb-label\" [attr.for]=\"fieldId(field)\">{{ field.label }}</label>\r\n }\r\n\r\n @switch (field.type) {\r\n @case ('checkbox') {\r\n <label class=\"fb-checkbox-option\">\r\n <input\r\n type=\"checkbox\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ field.label }}\r\n </label>\r\n }\r\n @case ('radio') {\r\n <fieldset\r\n class=\"fb-radio-group\"\r\n role=\"radiogroup\"\r\n [attr.aria-labelledby]=\"legendId(field)\"\r\n >\r\n <legend class=\"fb-label\" [id]=\"legendId(field)\">{{ field.label }}</legend>\r\n @for (option of optionsFor(field); track option.value) {\r\n <label class=\"fb-radio-option\">\r\n <input\r\n type=\"radio\"\r\n [formControl]=\"controlFor(field)\"\r\n [name]=\"fieldId(field)\"\r\n [value]=\"option.value\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ option.label }}\r\n </label>\r\n }\r\n </fieldset>\r\n }\r\n @case ('textarea') {\r\n <textarea\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n ></textarea>\r\n }\r\n @case ('select') {\r\n <select\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n >\r\n @for (option of optionsFor(field); track option.value) {\r\n <option [ngValue]=\"option.value\">{{ option.label }}</option>\r\n }\r\n </select>\r\n }\r\n @default {\r\n <input\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [type]=\"field.type\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n }\r\n }\r\n\r\n @if (activeErrorFor(field); as message) {\r\n <p class=\"fb-error\" [id]=\"errorId(field)\">{{ message }}</p>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <button type=\"submit\" class=\"fb-submit\" [disabled]=\"isSubmitDisabled()\">Submit</button>\r\n</form>\r\n", styles: [":host{--fb-font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;--fb-font-size: .9375rem;--fb-text-color: #1f2937;--fb-label-color: #374151;--fb-border-color: #d1d5db;--fb-error-color: #dc2626;--fb-primary-color: #3b82f6;--fb-focus-color: var(--fb-primary-color);--fb-border-radius: .5rem;--fb-spacing: 1rem;--fb-control-padding-y: .5rem;--fb-control-padding-x: .75rem;--fb-transition-duration: .15s;display:block;font-family:var(--fb-font-family);font-size:var(--fb-font-size);color:var(--fb-text-color)}.fb-form{display:flex;flex-direction:column;gap:var(--fb-spacing);width:100%}.fb-fields-grid{display:grid;grid-template-columns:repeat(var(--fb-columns, 1),1fr);gap:var(--fb-spacing)}.fb-field{display:flex;flex-direction:column;gap:calc(var(--fb-spacing) / 2.5);min-width:0}.fb-field--span-2{grid-column:span 2}@media(max-width:640px){.fb-fields-grid{grid-template-columns:1fr}.fb-field--span-2{grid-column:span 1}}.fb-label{font-weight:600;color:var(--fb-label-color);padding:0;border:none}.fb-control{box-sizing:border-box;width:100%;font:inherit;color:inherit;padding:var(--fb-control-padding-y) var(--fb-control-padding-x);border:1px solid var(--fb-border-color);border-radius:var(--fb-border-radius);background:transparent;transition:border-color var(--fb-transition-duration) ease}.fb-control:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:-1px;border-color:var(--fb-focus-color)}textarea.fb-control{min-height:5rem;resize:vertical}select.fb-control{cursor:pointer}.fb-radio-group{display:flex;flex-direction:column;gap:calc(var(--fb-spacing) / 2.5)}.fb-radio-option,.fb-checkbox-option{display:inline-flex;align-items:center;gap:.5rem;font-weight:400;color:var(--fb-text-color);cursor:pointer}.fb-radio-option input,.fb-checkbox-option input{accent-color:var(--fb-primary-color)}.fb-radio-option input:focus-visible,.fb-checkbox-option input:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:2px}.fb-error{margin:0;font-size:.8125rem;color:var(--fb-error-color)}.fb-submit{appearance:none;align-self:flex-start;border:none;border-radius:var(--fb-border-radius);background:var(--fb-primary-color);color:#fff;padding:var(--fb-control-padding-y) 1.25rem;font:inherit;font-weight:600;cursor:pointer;transition:opacity var(--fb-transition-duration) ease}.fb-submit:focus-visible{outline:2px solid var(--fb-focus-color);outline-offset:2px}.fb-submit:disabled{opacity:.45;cursor:not-allowed}\n"] }]
|
|
335
|
+
}], ctorParameters: () => [], propDecorators: { fields: [{ type: i0.Input, args: [{ isSignal: true, alias: "fields", required: true }] }], columns: [{ type: i0.Input, args: [{ isSignal: true, alias: "columns", required: false }] }], crossFieldValidators: [{ type: i0.Input, args: [{ isSignal: true, alias: "crossFieldValidators", required: false }] }], formSubmit: [{ type: i0.Output, args: ["formSubmit"] }], serverErrors: [{ type: i0.Input, args: [{ isSignal: true, alias: "serverErrors", required: false }] }] } });
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Generated bundle index. Do not edit.
|
|
339
|
+
*/
|
|
340
|
+
|
|
341
|
+
export { FormBuilder };
|
|
342
|
+
//# sourceMappingURL=zhunam-form-builder.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zhunam-form-builder.mjs","sources":["../../../../libs/form-builder/src/lib/utils/safe-pattern.ts","../../../../libs/form-builder/src/lib/form-builder/form-builder.ts","../../../../libs/form-builder/src/lib/form-builder/form-builder.html","../../../../libs/form-builder/src/zhunam-form-builder.ts"],"sourcesContent":["const MAX_PATTERN_LENGTH = 300;\r\n\r\n/** Matches a group that repeats another quantifier, e.g. `(x+)+`, `(x*)*`. */\r\nconst NESTED_QUANTIFIER = /\\([^()]*[+*][^()]*\\)[+*]/;\r\n\r\n/** Matches a repeated group so its alternated branches can be compared. */\r\nconst REPEATED_GROUP = /\\(([^()]*)\\)[+*]/g;\r\n\r\n/**\r\n * Validates that a regex `pattern` string is safe enough to compile into\r\n * a `RegExp` for form validation.\r\n *\r\n * This is a heuristic, not a mathematical guarantee of ReDoS-safety: it\r\n * rejects the pattern shapes most commonly responsible for catastrophic\r\n * backtracking (nested quantifiers, overlapping alternation inside a\r\n * repeated group) and enforces a length cap, but a maliciously crafted\r\n * pattern could still slip through undetected. Never accept `pattern`\r\n * values from a fully untrusted source without your own review.\r\n *\r\n * @throws {Error} If `pattern` is longer than 300 characters, or matches\r\n * a known catastrophic-backtracking shape.\r\n */\r\nexport function assertSafePattern(pattern: string): void {\r\n if (pattern.length > MAX_PATTERN_LENGTH) {\r\n throw new Error(\r\n `Unsafe regex pattern: exceeds the ${MAX_PATTERN_LENGTH}-character limit.`,\r\n );\r\n }\r\n\r\n if (NESTED_QUANTIFIER.test(pattern)) {\r\n throw new Error(\r\n 'Unsafe regex pattern: nested quantifiers detected (e.g. (x+)+), a known catastrophic-backtracking shape.',\r\n );\r\n }\r\n\r\n if (hasOverlappingAlternation(pattern)) {\r\n throw new Error(\r\n 'Unsafe regex pattern: overlapping alternation inside a repeated group detected (e.g. (a|a)+), a known catastrophic-backtracking shape.',\r\n );\r\n }\r\n}\r\n\r\nfunction hasOverlappingAlternation(pattern: string): boolean {\r\n const repeatedGroup = new RegExp(REPEATED_GROUP);\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = repeatedGroup.exec(pattern)) !== null) {\r\n const branches = match[1].split('|');\r\n for (let i = 0; i < branches.length; i++) {\r\n for (let j = i + 1; j < branches.length; j++) {\r\n if (branchesOverlap(branches[i], branches[j])) {\r\n return true;\r\n }\r\n }\r\n }\r\n }\r\n\r\n return false;\r\n}\r\n\r\nfunction branchesOverlap(a: string, b: string): boolean {\r\n if (a === b) return true;\r\n if (!a || !b) return false;\r\n return a.startsWith(b) || b.startsWith(a);\r\n}\r\n","// NUNCA usar [innerHTML], bypassSecurityTrustHtml, ni ningún mecanismo de inserción de HTML crudo en el DOM del componente.\r\n// label, placeholder, errorMessages, y FieldOption.label se renderizan siempre con interpolación {{ }} normal de Angular — nunca vía binding de propiedad que inserte HTML.\r\n\r\nimport {\r\n ChangeDetectionStrategy,\r\n Component,\r\n computed,\r\n effect,\r\n input,\r\n output,\r\n signal,\r\n ViewEncapsulation,\r\n} from '@angular/core';\r\nimport { FormControl, FormGroup, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms';\r\nimport { CrossFieldValidator, FieldConfig, FieldOption } from '../models/field-config';\r\nimport { assertSafePattern } from '../utils/safe-pattern';\r\n\r\ntype ErrorKey = 'required' | 'min' | 'max' | 'minLength' | 'maxLength' | 'pattern' | 'email';\r\n\r\n// Angular's built-in validators report `minlength`/`maxlength` (all lowercase)\r\n// on `control.errors`, not the camelCase keys `FieldValidatorConfig.errorMessages`\r\n// uses — this bridges the two so consumer-facing config can stay camelCase.\r\nconst RAW_ERROR_KEY_MAP: Record<string, ErrorKey> = {\r\n required: 'required',\r\n min: 'min',\r\n max: 'max',\r\n minlength: 'minLength',\r\n maxlength: 'maxLength',\r\n pattern: 'pattern',\r\n email: 'email',\r\n};\r\n\r\nconst DEFAULT_ERROR_MESSAGES: Record<ErrorKey, string> = {\r\n required: 'Este campo es obligatorio.',\r\n min: 'El valor es menor al mínimo permitido.',\r\n max: 'El valor supera el máximo permitido.',\r\n minLength: 'El valor es demasiado corto.',\r\n maxLength: 'El valor es demasiado largo.',\r\n pattern: 'El formato no es válido.',\r\n email: 'Ingresá un email válido.',\r\n};\r\n\r\n@Component({\r\n selector: 'lib-form-builder',\r\n imports: [ReactiveFormsModule],\r\n templateUrl: './form-builder.html',\r\n styleUrl: './form-builder.scss',\r\n changeDetection: ChangeDetectionStrategy.OnPush,\r\n encapsulation: ViewEncapsulation.Emulated,\r\n})\r\nexport class FormBuilder<T> {\r\n /**\r\n * Declarative configuration of the fields to render, in display order.\r\n * @example\r\n * fields: FieldConfig<User>[] = [\r\n * { key: 'email', label: 'Email', type: 'email', validators: { required: true, email: true } },\r\n * ];\r\n */\r\n fields = input.required<FieldConfig<T>[]>();\r\n\r\n /**\r\n * Number of grid columns fields are laid out in on desktop. Always\r\n * collapses to a single column below a mobile breakpoint, regardless\r\n * of this value — mobile-first, like the rest of the library.\r\n * @default 1\r\n * @example\r\n * <lib-form-builder [fields]=\"fields\" [columns]=\"2\" />\r\n */\r\n columns = input<number>(1);\r\n\r\n /**\r\n * Form-level validators that check values across multiple fields (e.g.\r\n * confirming a password). Run separately from Angular's native\r\n * per-control validation, only when the native `FormGroup` is valid.\r\n * @default []\r\n * @example\r\n * crossFieldValidators: CrossFieldValidator<User>[] = [\r\n * {\r\n * validate: (value) =>\r\n * value.password !== value.confirmPassword\r\n * ? { confirmPassword: 'Passwords must match' }\r\n * : null,\r\n * },\r\n * ];\r\n */\r\n crossFieldValidators = input<CrossFieldValidator<T>[]>([]);\r\n\r\n /**\r\n * Emitted with the typed form values, only when both the native\r\n * `FormGroup` and every `crossFieldValidators` check pass.\r\n * @example\r\n * <lib-form-builder [fields]=\"fields\" (formSubmit)=\"onSubmit($event)\" />\r\n */\r\n formSubmit = output<T>();\r\n\r\n /**\r\n * Errors returned by the backend after a submit attempt (e.g. \"email\r\n * already registered\"), keyed by field. Shown as soon as they're set,\r\n * regardless of whether the field has been touched or the form\r\n * submitted locally — receiving one implies a submit already\r\n * happened. Each message clears itself automatically the moment the\r\n * user edits that field again, so there's no need to clear this input\r\n * back to `{}` manually after showing it once.\r\n * @default {}\r\n * @example\r\n * // In the consumer's component:\r\n * protected readonly serverErrors = signal<Partial<Record<keyof User, string>>>({});\r\n *\r\n * onFormSubmit(value: User) {\r\n * this.api.register(value).subscribe({\r\n * error: () => this.serverErrors.set({ email: 'This email is already registered.' }),\r\n * });\r\n * }\r\n * // Template:\r\n * <lib-form-builder [fields]=\"fields\" [serverErrors]=\"serverErrors()\" (formSubmit)=\"onFormSubmit($event)\" />\r\n */\r\n serverErrors = input<Partial<Record<keyof T, string>>>({});\r\n\r\n protected readonly submitted = signal(false);\r\n protected readonly crossFieldErrors = signal<Record<string, string>>({});\r\n\r\n // Snapshot of each server-error field's value at the moment that error\r\n // appeared. A server error is only ever about a rejected backend\r\n // observation of the exact value the user submitted — the instant the\r\n // control's live value no longer matches this snapshot, the message is\r\n // stale and `serverErrorFor` stops showing it, without the consumer\r\n // having to clear `serverErrors` themselves.\r\n private readonly serverErrorSnapshot = signal<Partial<Record<keyof T, unknown>>>({});\r\n\r\n // `fields()` can change at runtime (e.g. a wizard swapping steps), so the\r\n // FormGroup is derived with `computed()` rather than built once in the\r\n // constructor: it's rebuilt only when the `fields()` array reference\r\n // changes. `computed()` fits better than an `effect()` here because\r\n // building a FormGroup is a pure derivation with no teardown to manage,\r\n // and `computed()` already memoizes it — the same instance is returned\r\n // across change-detection cycles until `fields()` itself changes.\r\n protected readonly formGroup = computed(() => this.buildFormGroup(this.fields()));\r\n\r\n private static instanceCounter = 0;\r\n private readonly instanceId = ++FormBuilder.instanceCounter;\r\n\r\n constructor() {\r\n // Runs whenever a new `serverErrors()` value comes in and captures\r\n // each mentioned field's current value — the baseline `serverErrorFor`\r\n // compares against to decide whether that message is still relevant.\r\n effect(() => {\r\n const errors = this.serverErrors();\r\n const group = this.formGroup();\r\n const snapshot: Partial<Record<keyof T, unknown>> = {};\r\n for (const key of Object.keys(errors) as (keyof T)[]) {\r\n snapshot[key] = group.get(String(key))?.value;\r\n }\r\n this.serverErrorSnapshot.set(snapshot);\r\n });\r\n }\r\n\r\n private buildFormGroup(fields: FieldConfig<T>[]): FormGroup {\r\n const controls: Record<string, FormControl<unknown>> = {};\r\n for (const field of fields) {\r\n const initialValue = field.defaultValue ?? (field.type === 'checkbox' ? false : null);\r\n controls[String(field.key)] = new FormControl<unknown>(\r\n { value: initialValue, disabled: !!field.disabled },\r\n this.buildValidators(field),\r\n );\r\n }\r\n return new FormGroup(controls);\r\n }\r\n\r\n private buildValidators(field: FieldConfig<T>): ValidatorFn[] {\r\n const config = field.validators;\r\n if (!config) {\r\n return [];\r\n }\r\n\r\n const validators: ValidatorFn[] = [];\r\n if (config.required) {\r\n // A checkbox's \"empty\" value is `false`, which Validators.required\r\n // doesn't treat as empty — requiredTrue is the correct check for it.\r\n validators.push(field.type === 'checkbox' ? Validators.requiredTrue : Validators.required);\r\n }\r\n if (config.min !== undefined) {\r\n validators.push(Validators.min(config.min));\r\n }\r\n if (config.max !== undefined) {\r\n validators.push(Validators.max(config.max));\r\n }\r\n if (config.minLength !== undefined) {\r\n validators.push(Validators.minLength(config.minLength));\r\n }\r\n if (config.maxLength !== undefined) {\r\n validators.push(Validators.maxLength(config.maxLength));\r\n }\r\n if (config.pattern !== undefined) {\r\n // An unsafe pattern is a bug in the consumer's config, not a runtime\r\n // condition to recover from — let it throw instead of swallowing it.\r\n assertSafePattern(config.pattern);\r\n validators.push(Validators.pattern(config.pattern));\r\n }\r\n if (config.email) {\r\n validators.push(Validators.email);\r\n }\r\n return validators;\r\n }\r\n\r\n protected controlFor(field: FieldConfig<T>): FormControl {\r\n return this.formGroup().get(String(field.key)) as FormControl;\r\n }\r\n\r\n protected optionsFor(field: FieldConfig<T>): FieldOption[] {\r\n return field.options ?? [];\r\n }\r\n\r\n // Shared by the label's `for`, the control's `id`, and (for radio groups)\r\n // the `name` that keeps this field's radios from being grouped with\r\n // another field's — RadioControlValueAccessor matches radios by `name`\r\n // plus parent FormGroup, and every field here shares the same FormGroup.\r\n protected fieldId(field: FieldConfig<T>): string {\r\n return `fb-${this.instanceId}-${String(field.key)}`;\r\n }\r\n\r\n protected errorMessageFor(field: FieldConfig<T>): string | null {\r\n const control = this.controlFor(field);\r\n if (!control.errors || (!control.touched && !this.submitted())) {\r\n return null;\r\n }\r\n\r\n const errorKey = RAW_ERROR_KEY_MAP[Object.keys(control.errors)[0]];\r\n if (!errorKey) {\r\n return null;\r\n }\r\n\r\n return field.validators?.errorMessages?.[errorKey] ?? DEFAULT_ERROR_MESSAGES[errorKey];\r\n }\r\n\r\n protected serverErrorFor(field: FieldConfig<T>): string | null {\r\n const message = this.serverErrors()[field.key];\r\n if (!message) {\r\n return null;\r\n }\r\n\r\n const currentValue = this.controlFor(field).value;\r\n const snapshotValue = this.serverErrorSnapshot()[field.key];\r\n return currentValue === snapshotValue ? message : null;\r\n }\r\n\r\n protected crossFieldErrorFor(field: FieldConfig<T>): string | null {\r\n if (!this.submitted()) {\r\n return null;\r\n }\r\n return this.crossFieldErrors()[String(field.key)] ?? null;\r\n }\r\n\r\n // Single source of truth for \"is an error currently shown for this\r\n // field, and what does it say\" — both the visible `.fb-error` message\r\n // and its ARIA wiring (aria-invalid, aria-describedby) read from this,\r\n // so the two can never drift out of sync with each other.\r\n protected activeErrorFor(field: FieldConfig<T>): string | null {\r\n return this.errorMessageFor(field) ?? this.serverErrorFor(field) ?? this.crossFieldErrorFor(field);\r\n }\r\n\r\n protected errorId(field: FieldConfig<T>): string {\r\n return `${this.fieldId(field)}-error`;\r\n }\r\n\r\n protected legendId(field: FieldConfig<T>): string {\r\n return `${this.fieldId(field)}-legend`;\r\n }\r\n\r\n protected isSubmitDisabled(): boolean {\r\n return this.formGroup().invalid || Object.keys(this.crossFieldErrors()).length > 0;\r\n }\r\n\r\n protected onSubmit(): void {\r\n this.submitted.set(true);\r\n const group = this.formGroup();\r\n\r\n if (group.invalid) {\r\n return;\r\n }\r\n\r\n // `.value` excludes disabled controls entirely — `getRawValue()` keeps\r\n // them, so a disabled field with a `defaultValue` still comes through\r\n // as a complete `T` instead of a hole in the emitted object.\r\n const value = this.coerceNumberFields(group.getRawValue());\r\n const errors = this.runCrossFieldValidators(value);\r\n this.crossFieldErrors.set(errors);\r\n\r\n if (Object.keys(errors).length > 0) {\r\n return;\r\n }\r\n\r\n this.formSubmit.emit(value);\r\n }\r\n\r\n // Native `<input type=\"number\">` always reports its value as a string\r\n // through the DOM. Angular's NumberValueAccessor would normally parse it\r\n // for us, but that accessor only activates for a *static* `type=\"number\"`\r\n // attribute in the template — this component binds `[type]=\"field.type\"`\r\n // dynamically, so it never matches, and DefaultValueAccessor (string in,\r\n // string out) handles the control instead. Fixed up here, at the point\r\n // the value leaves the component, rather than in how the FormControl\r\n // stores it — Validators.min/max already parseFloat internally either\r\n // way, so correcting the stored value would change nothing there but\r\n // would still need this same conversion for the emitted object.\r\n private coerceNumberFields(rawValue: unknown): T {\r\n const value = { ...(rawValue as Record<string, unknown>) };\r\n for (const field of this.fields()) {\r\n if (field.type !== 'number') {\r\n continue;\r\n }\r\n\r\n const key = String(field.key);\r\n const current = value[key];\r\n if (current === '' || current === null || current === undefined) {\r\n value[key] = null;\r\n } else if (typeof current === 'string') {\r\n value[key] = Number(current);\r\n }\r\n }\r\n return value as T;\r\n }\r\n\r\n private runCrossFieldValidators(value: T): Record<string, string> {\r\n const errors: Record<string, string> = {};\r\n for (const validator of this.crossFieldValidators()) {\r\n const result = validator.validate(value);\r\n if (result) {\r\n Object.assign(errors, result);\r\n }\r\n }\r\n return errors;\r\n }\r\n}\r\n","<form class=\"fb-form\" [formGroup]=\"formGroup()\" (ngSubmit)=\"onSubmit()\" novalidate>\r\n <div class=\"fb-fields-grid\" [style.--fb-columns]=\"columns()\">\r\n @for (field of fields(); track field.key) {\r\n <div class=\"fb-field\" [class.fb-field--span-2]=\"field.colSpan === 2\">\r\n @if (field.type !== 'checkbox' && field.type !== 'radio') {\r\n <label class=\"fb-label\" [attr.for]=\"fieldId(field)\">{{ field.label }}</label>\r\n }\r\n\r\n @switch (field.type) {\r\n @case ('checkbox') {\r\n <label class=\"fb-checkbox-option\">\r\n <input\r\n type=\"checkbox\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ field.label }}\r\n </label>\r\n }\r\n @case ('radio') {\r\n <fieldset\r\n class=\"fb-radio-group\"\r\n role=\"radiogroup\"\r\n [attr.aria-labelledby]=\"legendId(field)\"\r\n >\r\n <legend class=\"fb-label\" [id]=\"legendId(field)\">{{ field.label }}</legend>\r\n @for (option of optionsFor(field); track option.value) {\r\n <label class=\"fb-radio-option\">\r\n <input\r\n type=\"radio\"\r\n [formControl]=\"controlFor(field)\"\r\n [name]=\"fieldId(field)\"\r\n [value]=\"option.value\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n {{ option.label }}\r\n </label>\r\n }\r\n </fieldset>\r\n }\r\n @case ('textarea') {\r\n <textarea\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n ></textarea>\r\n }\r\n @case ('select') {\r\n <select\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [formControl]=\"controlFor(field)\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n >\r\n @for (option of optionsFor(field); track option.value) {\r\n <option [ngValue]=\"option.value\">{{ option.label }}</option>\r\n }\r\n </select>\r\n }\r\n @default {\r\n <input\r\n class=\"fb-control\"\r\n [id]=\"fieldId(field)\"\r\n [type]=\"field.type\"\r\n [formControl]=\"controlFor(field)\"\r\n [placeholder]=\"field.placeholder ?? ''\"\r\n [disabled]=\"!!field.disabled\"\r\n [attr.aria-invalid]=\"activeErrorFor(field) ? true : null\"\r\n [attr.aria-describedby]=\"activeErrorFor(field) ? errorId(field) : null\"\r\n />\r\n }\r\n }\r\n\r\n @if (activeErrorFor(field); as message) {\r\n <p class=\"fb-error\" [id]=\"errorId(field)\">{{ message }}</p>\r\n }\r\n </div>\r\n }\r\n </div>\r\n\r\n <button type=\"submit\" class=\"fb-submit\" [disabled]=\"isSubmitDisabled()\">Submit</button>\r\n</form>\r\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './index';\n"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhunam/form-builder",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"peerDependencies": {
|
|
5
|
+
"@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0",
|
|
6
|
+
"@angular/forms": "^20.0.0 || ^21.0.0 || ^22.0.0"
|
|
7
|
+
},
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"module": "fesm2022/zhunam-form-builder.mjs",
|
|
10
|
+
"typings": "types/zhunam-form-builder.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
"./package.json": {
|
|
13
|
+
"default": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./types/zhunam-form-builder.d.ts",
|
|
17
|
+
"default": "./fesm2022/zhunam-form-builder.mjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"tslib": "^2.3.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import * as _angular_core from '@angular/core';
|
|
2
|
+
import { FormGroup, FormControl } from '@angular/forms';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Supported input types a `FieldConfig` can render.
|
|
6
|
+
*/
|
|
7
|
+
type FieldType = 'text' | 'number' | 'email' | 'password' | 'textarea' | 'select' | 'radio' | 'checkbox' | 'date';
|
|
8
|
+
/**
|
|
9
|
+
* A single selectable choice for `select` and `radio` fields.
|
|
10
|
+
*/
|
|
11
|
+
interface FieldOption {
|
|
12
|
+
/**
|
|
13
|
+
* Value submitted when this option is selected.
|
|
14
|
+
*/
|
|
15
|
+
value: string | number;
|
|
16
|
+
/**
|
|
17
|
+
* Text displayed to the user for this option.
|
|
18
|
+
*/
|
|
19
|
+
label: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Native Angular validation rules applied to a single field.
|
|
23
|
+
*/
|
|
24
|
+
interface FieldValidatorConfig {
|
|
25
|
+
/**
|
|
26
|
+
* Whether the field must have a value.
|
|
27
|
+
* @default false
|
|
28
|
+
*/
|
|
29
|
+
required?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Minimum numeric value allowed.
|
|
32
|
+
*/
|
|
33
|
+
min?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Maximum numeric value allowed.
|
|
36
|
+
*/
|
|
37
|
+
max?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Minimum string length allowed.
|
|
40
|
+
*/
|
|
41
|
+
minLength?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Maximum string length allowed.
|
|
44
|
+
*/
|
|
45
|
+
maxLength?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Regex pattern as a string (never a RegExp object — keeps config
|
|
48
|
+
* serializable). Validated internally against a length limit and a
|
|
49
|
+
* catastrophic-backtracking heuristic before use; throws if rejected.
|
|
50
|
+
* This is a heuristic, not a mathematical guarantee — never accept
|
|
51
|
+
* `pattern` values from a fully untrusted source without your own
|
|
52
|
+
* review.
|
|
53
|
+
*/
|
|
54
|
+
pattern?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the value must be a well-formed email address.
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
email?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Custom error message per validator, shown instead of the built-in
|
|
62
|
+
* default when that validator fails.
|
|
63
|
+
*/
|
|
64
|
+
errorMessages?: Partial<Record<'required' | 'min' | 'max' | 'minLength' | 'maxLength' | 'pattern' | 'email', string>>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Declarative configuration for a single form field rendered by
|
|
68
|
+
* `FormBuilder`.
|
|
69
|
+
*/
|
|
70
|
+
interface FieldConfig<T> {
|
|
71
|
+
/**
|
|
72
|
+
* Property of `T` this field reads and writes its value from.
|
|
73
|
+
*/
|
|
74
|
+
key: keyof T;
|
|
75
|
+
/**
|
|
76
|
+
* Text displayed as the field's label.
|
|
77
|
+
*/
|
|
78
|
+
label: string;
|
|
79
|
+
/**
|
|
80
|
+
* Input type this field renders as.
|
|
81
|
+
*/
|
|
82
|
+
type: FieldType;
|
|
83
|
+
/**
|
|
84
|
+
* Placeholder text shown when the field is empty.
|
|
85
|
+
*/
|
|
86
|
+
placeholder?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Choices available for `select` and `radio` fields.
|
|
89
|
+
*/
|
|
90
|
+
options?: FieldOption[];
|
|
91
|
+
/**
|
|
92
|
+
* Native Angular validation rules applied to this field.
|
|
93
|
+
*/
|
|
94
|
+
validators?: FieldValidatorConfig;
|
|
95
|
+
/**
|
|
96
|
+
* Initial value assigned to the field before user interaction.
|
|
97
|
+
*/
|
|
98
|
+
defaultValue?: T[keyof T];
|
|
99
|
+
/**
|
|
100
|
+
* How many grid columns this field spans, when the component's
|
|
101
|
+
* `columns` input is 2 or more. Has no effect at 1 column (the
|
|
102
|
+
* default) or below the mobile breakpoint, where every field always
|
|
103
|
+
* spans the full row.
|
|
104
|
+
* @default 1
|
|
105
|
+
*/
|
|
106
|
+
colSpan?: 1 | 2;
|
|
107
|
+
/**
|
|
108
|
+
* Renders the control disabled from the start and excludes it from
|
|
109
|
+
* native validation, matching Angular's own disabled-control
|
|
110
|
+
* semantics. Still included in the object `formSubmit` emits
|
|
111
|
+
* (`FormBuilder` reads the raw form value, not the disabled-excluding
|
|
112
|
+
* one) — useful for a read-only field seeded via `defaultValue`.
|
|
113
|
+
* @default false
|
|
114
|
+
*/
|
|
115
|
+
disabled?: boolean;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Declarative form-level validator that checks values across multiple
|
|
119
|
+
* fields (e.g. confirming a password), without exposing Angular's
|
|
120
|
+
* `ValidatorFn`/`AbstractControl` to the consumer.
|
|
121
|
+
*/
|
|
122
|
+
interface CrossFieldValidator<T> {
|
|
123
|
+
/**
|
|
124
|
+
* Runs the cross-field check against the current form value.
|
|
125
|
+
* @returns A map of error keys to messages when invalid, or `null`
|
|
126
|
+
* when valid.
|
|
127
|
+
*/
|
|
128
|
+
validate: (value: Partial<T>) => Record<string, string> | null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
declare class FormBuilder<T> {
|
|
132
|
+
/**
|
|
133
|
+
* Declarative configuration of the fields to render, in display order.
|
|
134
|
+
* @example
|
|
135
|
+
* fields: FieldConfig<User>[] = [
|
|
136
|
+
* { key: 'email', label: 'Email', type: 'email', validators: { required: true, email: true } },
|
|
137
|
+
* ];
|
|
138
|
+
*/
|
|
139
|
+
fields: _angular_core.InputSignal<FieldConfig<T>[]>;
|
|
140
|
+
/**
|
|
141
|
+
* Number of grid columns fields are laid out in on desktop. Always
|
|
142
|
+
* collapses to a single column below a mobile breakpoint, regardless
|
|
143
|
+
* of this value — mobile-first, like the rest of the library.
|
|
144
|
+
* @default 1
|
|
145
|
+
* @example
|
|
146
|
+
* <lib-form-builder [fields]="fields" [columns]="2" />
|
|
147
|
+
*/
|
|
148
|
+
columns: _angular_core.InputSignal<number>;
|
|
149
|
+
/**
|
|
150
|
+
* Form-level validators that check values across multiple fields (e.g.
|
|
151
|
+
* confirming a password). Run separately from Angular's native
|
|
152
|
+
* per-control validation, only when the native `FormGroup` is valid.
|
|
153
|
+
* @default []
|
|
154
|
+
* @example
|
|
155
|
+
* crossFieldValidators: CrossFieldValidator<User>[] = [
|
|
156
|
+
* {
|
|
157
|
+
* validate: (value) =>
|
|
158
|
+
* value.password !== value.confirmPassword
|
|
159
|
+
* ? { confirmPassword: 'Passwords must match' }
|
|
160
|
+
* : null,
|
|
161
|
+
* },
|
|
162
|
+
* ];
|
|
163
|
+
*/
|
|
164
|
+
crossFieldValidators: _angular_core.InputSignal<CrossFieldValidator<T>[]>;
|
|
165
|
+
/**
|
|
166
|
+
* Emitted with the typed form values, only when both the native
|
|
167
|
+
* `FormGroup` and every `crossFieldValidators` check pass.
|
|
168
|
+
* @example
|
|
169
|
+
* <lib-form-builder [fields]="fields" (formSubmit)="onSubmit($event)" />
|
|
170
|
+
*/
|
|
171
|
+
formSubmit: _angular_core.OutputEmitterRef<T>;
|
|
172
|
+
/**
|
|
173
|
+
* Errors returned by the backend after a submit attempt (e.g. "email
|
|
174
|
+
* already registered"), keyed by field. Shown as soon as they're set,
|
|
175
|
+
* regardless of whether the field has been touched or the form
|
|
176
|
+
* submitted locally — receiving one implies a submit already
|
|
177
|
+
* happened. Each message clears itself automatically the moment the
|
|
178
|
+
* user edits that field again, so there's no need to clear this input
|
|
179
|
+
* back to `{}` manually after showing it once.
|
|
180
|
+
* @default {}
|
|
181
|
+
* @example
|
|
182
|
+
* // In the consumer's component:
|
|
183
|
+
* protected readonly serverErrors = signal<Partial<Record<keyof User, string>>>({});
|
|
184
|
+
*
|
|
185
|
+
* onFormSubmit(value: User) {
|
|
186
|
+
* this.api.register(value).subscribe({
|
|
187
|
+
* error: () => this.serverErrors.set({ email: 'This email is already registered.' }),
|
|
188
|
+
* });
|
|
189
|
+
* }
|
|
190
|
+
* // Template:
|
|
191
|
+
* <lib-form-builder [fields]="fields" [serverErrors]="serverErrors()" (formSubmit)="onFormSubmit($event)" />
|
|
192
|
+
*/
|
|
193
|
+
serverErrors: _angular_core.InputSignal<Partial<Record<keyof T, string>>>;
|
|
194
|
+
protected readonly submitted: _angular_core.WritableSignal<boolean>;
|
|
195
|
+
protected readonly crossFieldErrors: _angular_core.WritableSignal<Record<string, string>>;
|
|
196
|
+
private readonly serverErrorSnapshot;
|
|
197
|
+
protected readonly formGroup: _angular_core.Signal<FormGroup<any>>;
|
|
198
|
+
private static instanceCounter;
|
|
199
|
+
private readonly instanceId;
|
|
200
|
+
constructor();
|
|
201
|
+
private buildFormGroup;
|
|
202
|
+
private buildValidators;
|
|
203
|
+
protected controlFor(field: FieldConfig<T>): FormControl;
|
|
204
|
+
protected optionsFor(field: FieldConfig<T>): FieldOption[];
|
|
205
|
+
protected fieldId(field: FieldConfig<T>): string;
|
|
206
|
+
protected errorMessageFor(field: FieldConfig<T>): string | null;
|
|
207
|
+
protected serverErrorFor(field: FieldConfig<T>): string | null;
|
|
208
|
+
protected crossFieldErrorFor(field: FieldConfig<T>): string | null;
|
|
209
|
+
protected activeErrorFor(field: FieldConfig<T>): string | null;
|
|
210
|
+
protected errorId(field: FieldConfig<T>): string;
|
|
211
|
+
protected legendId(field: FieldConfig<T>): string;
|
|
212
|
+
protected isSubmitDisabled(): boolean;
|
|
213
|
+
protected onSubmit(): void;
|
|
214
|
+
private coerceNumberFields;
|
|
215
|
+
private runCrossFieldValidators;
|
|
216
|
+
static ɵfac: _angular_core.ɵɵFactoryDeclaration<FormBuilder<any>, never>;
|
|
217
|
+
static ɵcmp: _angular_core.ɵɵComponentDeclaration<FormBuilder<any>, "lib-form-builder", never, { "fields": { "alias": "fields"; "required": true; "isSignal": true; }; "columns": { "alias": "columns"; "required": false; "isSignal": true; }; "crossFieldValidators": { "alias": "crossFieldValidators"; "required": false; "isSignal": true; }; "serverErrors": { "alias": "serverErrors"; "required": false; "isSignal": true; }; }, { "formSubmit": "formSubmit"; }, never, never, true, never>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { FormBuilder };
|
|
221
|
+
export type { CrossFieldValidator, FieldConfig, FieldOption, FieldType, FieldValidatorConfig };
|